@chrrxs/robloxstudio-mcp 2.15.1 → 2.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1292 -281
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +494 -203
- package/studio-plugin/MCPPlugin.rbxmx +494 -203
- package/studio-plugin/src/modules/ClientBroker.ts +7 -2
- package/studio-plugin/src/modules/Communication.ts +6 -12
- package/studio-plugin/src/modules/EvalBridges.ts +96 -68
- package/studio-plugin/src/modules/LuauExec.ts +134 -36
- package/studio-plugin/src/modules/RuntimeLogBuffer.ts +9 -9
- package/studio-plugin/src/modules/handlers/EvalRuntimeHandlers.ts +149 -0
- package/studio-plugin/src/modules/handlers/LogHandlers.ts +5 -6
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +2 -33
- package/studio-plugin/src/server/index.server.ts +27 -8
package/dist/index.js
CHANGED
|
@@ -737,6 +737,12 @@ var init_http_server = __esm({
|
|
|
737
737
|
execute_luau: (tools, body) => tools.executeLuau(body.code, body.target, body.instance_id),
|
|
738
738
|
eval_server_runtime: (tools, body) => tools.evalServerRuntime(body.code, body.instance_id),
|
|
739
739
|
eval_client_runtime: (tools, body) => tools.evalClientRuntime(body.code, body.target, body.instance_id),
|
|
740
|
+
set_network_profile: (tools, body) => tools.setNetworkProfile(body.profile, body.target, body.overrides, body.instance_id),
|
|
741
|
+
get_simulation_state: (tools, body) => tools.getSimulationState(body.include, body.target, body.instance_id),
|
|
742
|
+
reset_simulation_state: (tools, body) => tools.resetSimulationState(body.target, body.network, body.deviceSimulator, body.instance_id),
|
|
743
|
+
get_device_simulator_state: (tools, body) => tools.getDeviceSimulatorState(body.target, body.deviceId, body.includeDeviceList, body.instance_id),
|
|
744
|
+
set_device_simulator: (tools, body) => tools.setDeviceSimulator(body.target, body.deviceId, body.orientation, body.resolution, body.pixelDensity, body.scalingMode, body.stopSimulation, body.instance_id),
|
|
745
|
+
capture_device_matrix: (tools, body) => tools.captureDeviceMatrix(body.entries, body.target, body.format, body.quality, body.settleSeconds, body.restoreAfter, body.instance_id),
|
|
740
746
|
start_playtest: (tools, body) => tools.startPlaytest(body.mode, body.numPlayers, body.instance_id),
|
|
741
747
|
stop_playtest: (tools, body) => tools.stopPlaytest(body.instance_id),
|
|
742
748
|
get_playtest_output: (tools, body) => tools.getPlaytestOutput(body.target, body.instance_id),
|
|
@@ -1261,21 +1267,21 @@ var init_opencloud_client = __esm({
|
|
|
1261
1267
|
clearTimeout(timeoutId);
|
|
1262
1268
|
if (!response.ok) {
|
|
1263
1269
|
const errorBody = await response.text();
|
|
1264
|
-
let
|
|
1270
|
+
let errorMessage2;
|
|
1265
1271
|
try {
|
|
1266
1272
|
const errorJson = JSON.parse(errorBody);
|
|
1267
|
-
|
|
1273
|
+
errorMessage2 = errorJson.detail || errorJson.message || errorBody;
|
|
1268
1274
|
} catch {
|
|
1269
|
-
|
|
1275
|
+
errorMessage2 = errorBody;
|
|
1270
1276
|
}
|
|
1271
1277
|
if (response.status === 401) {
|
|
1272
1278
|
throw new Error("Invalid or expired API key");
|
|
1273
1279
|
} else if (response.status === 403) {
|
|
1274
|
-
throw new Error(`API key lacks required permissions: ${
|
|
1280
|
+
throw new Error(`API key lacks required permissions: ${errorMessage2}`);
|
|
1275
1281
|
} else if (response.status === 429) {
|
|
1276
1282
|
throw new Error("Rate limit exceeded. Please try again later.");
|
|
1277
1283
|
} else {
|
|
1278
|
-
throw new Error(`Open Cloud API error (${response.status}): ${
|
|
1284
|
+
throw new Error(`Open Cloud API error (${response.status}): ${errorMessage2}`);
|
|
1279
1285
|
}
|
|
1280
1286
|
}
|
|
1281
1287
|
return await response.json();
|
|
@@ -1410,21 +1416,21 @@ var init_opencloud_client = __esm({
|
|
|
1410
1416
|
clearTimeout(timeoutId);
|
|
1411
1417
|
if (!response.ok) {
|
|
1412
1418
|
const errorBody = await response.text();
|
|
1413
|
-
let
|
|
1419
|
+
let errorMessage2;
|
|
1414
1420
|
try {
|
|
1415
1421
|
const errorJson = JSON.parse(errorBody);
|
|
1416
|
-
|
|
1422
|
+
errorMessage2 = errorJson.detail || errorJson.message || errorBody;
|
|
1417
1423
|
} catch {
|
|
1418
|
-
|
|
1424
|
+
errorMessage2 = errorBody;
|
|
1419
1425
|
}
|
|
1420
1426
|
if (response.status === 401) {
|
|
1421
1427
|
throw new Error("Invalid or expired API key");
|
|
1422
1428
|
} else if (response.status === 403) {
|
|
1423
|
-
throw new Error(`API key lacks required permissions: ${
|
|
1429
|
+
throw new Error(`API key lacks required permissions: ${errorMessage2}`);
|
|
1424
1430
|
} else if (response.status === 429) {
|
|
1425
1431
|
throw new Error("Rate limit exceeded. Please try again later.");
|
|
1426
1432
|
} else {
|
|
1427
|
-
throw new Error(`Open Cloud API error (${response.status}): ${
|
|
1433
|
+
throw new Error(`Open Cloud API error (${response.status}): ${errorMessage2}`);
|
|
1428
1434
|
}
|
|
1429
1435
|
}
|
|
1430
1436
|
return await response.json();
|
|
@@ -2570,223 +2576,340 @@ function encodeImageFromRgbaResponse(response, format, quality) {
|
|
|
2570
2576
|
mimeType: "image/jpeg"
|
|
2571
2577
|
};
|
|
2572
2578
|
}
|
|
2573
|
-
function
|
|
2574
|
-
|
|
2575
|
-
while (s.includes(`]${"=".repeat(level)}]`))
|
|
2576
|
-
level++;
|
|
2577
|
-
const eq = "=".repeat(level);
|
|
2578
|
-
return `[${eq}[
|
|
2579
|
-
${s}
|
|
2580
|
-
]${eq}]`;
|
|
2579
|
+
function sleep(ms) {
|
|
2580
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
2581
2581
|
}
|
|
2582
|
-
function
|
|
2583
|
-
return
|
|
2582
|
+
function errorMessage(error) {
|
|
2583
|
+
return error instanceof Error ? error.message : String(error);
|
|
2584
|
+
}
|
|
2585
|
+
function normalizeNetworkProfile(profile, overrides) {
|
|
2586
|
+
if (!["great", "good", "poor", "custom"].includes(profile)) {
|
|
2587
|
+
throw new Error('profile must be "great", "good", "poor", or "custom"');
|
|
2588
|
+
}
|
|
2589
|
+
const values = profile === "custom" ? {} : { ...NETWORK_PROFILES[profile] };
|
|
2590
|
+
if (overrides !== void 0) {
|
|
2591
|
+
if (typeof overrides !== "object" || overrides === null || Array.isArray(overrides)) {
|
|
2592
|
+
throw new Error("overrides must be an object when provided");
|
|
2593
|
+
}
|
|
2594
|
+
const allowed = new Set(NETWORK_PROFILE_KEYS);
|
|
2595
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
2596
|
+
if (!allowed.has(key)) {
|
|
2597
|
+
throw new Error(`Unsupported network override "${key}". Allowed: ${NETWORK_PROFILE_KEYS.join(", ")}`);
|
|
2598
|
+
}
|
|
2599
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
2600
|
+
throw new Error(`Network override "${key}" must be a finite number`);
|
|
2601
|
+
}
|
|
2602
|
+
if (value < 0) {
|
|
2603
|
+
throw new Error(`Network override "${key}" must be greater than or equal to 0`);
|
|
2604
|
+
}
|
|
2605
|
+
if ((key === "InboundNetworkLossPercent" || key === "OutboundNetworkLossPercent") && value > MAX_NETWORK_PACKET_LOSS_PERCENT) {
|
|
2606
|
+
throw new Error(`Network override "${key}" cannot exceed ${MAX_NETWORK_PACKET_LOSS_PERCENT}; Roblox engine limits packet loss simulation to 0.5%.`);
|
|
2607
|
+
}
|
|
2608
|
+
values[key] = value;
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
if (Object.keys(values).length === 0) {
|
|
2612
|
+
throw new Error("custom profile requires at least one override");
|
|
2613
|
+
}
|
|
2614
|
+
return values;
|
|
2584
2615
|
}
|
|
2585
|
-
function
|
|
2586
|
-
const
|
|
2587
|
-
const
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2616
|
+
function buildNetworkProfileLuau(profile, values) {
|
|
2617
|
+
const valuesJson = JSON.stringify(values);
|
|
2618
|
+
const keysJson = JSON.stringify(NETWORK_PROFILE_KEYS);
|
|
2619
|
+
return `
|
|
2620
|
+
local HttpService = game:GetService("HttpService")
|
|
2621
|
+
local ns = settings():GetService("NetworkSettings")
|
|
2622
|
+
local keys = HttpService:JSONDecode(${JSON.stringify(keysJson)})
|
|
2623
|
+
local desired = HttpService:JSONDecode(${JSON.stringify(valuesJson)})
|
|
2624
|
+
local before = {}
|
|
2625
|
+
for _, key in ipairs(keys) do
|
|
2626
|
+
before[key] = ns[key]
|
|
2627
|
+
end
|
|
2628
|
+
for key, value in pairs(desired) do
|
|
2629
|
+
ns[key] = value
|
|
2630
|
+
end
|
|
2631
|
+
local after = {}
|
|
2632
|
+
for _, key in ipairs(keys) do
|
|
2633
|
+
after[key] = ns[key]
|
|
2634
|
+
end
|
|
2635
|
+
return HttpService:JSONEncode({
|
|
2636
|
+
profile = ${JSON.stringify(profile)},
|
|
2637
|
+
applied = desired,
|
|
2638
|
+
before = before,
|
|
2639
|
+
after = after,
|
|
2640
|
+
})
|
|
2641
|
+
`.trim();
|
|
2642
|
+
}
|
|
2643
|
+
function buildNetworkStateLuau(operation) {
|
|
2644
|
+
const keysJson = JSON.stringify(NETWORK_PROFILE_KEYS);
|
|
2645
|
+
const resetJson = JSON.stringify(ZERO_NETWORK_PROFILE);
|
|
2646
|
+
return `
|
|
2647
|
+
local HttpService = game:GetService("HttpService")
|
|
2648
|
+
local ns = settings():GetService("NetworkSettings")
|
|
2649
|
+
local operation = ${JSON.stringify(operation)}
|
|
2650
|
+
local keys = HttpService:JSONDecode(${JSON.stringify(keysJson)})
|
|
2651
|
+
local resetValues = HttpService:JSONDecode(${JSON.stringify(resetJson)})
|
|
2652
|
+
|
|
2653
|
+
local function readState()
|
|
2654
|
+
local state = {}
|
|
2655
|
+
for _, key in ipairs(keys) do
|
|
2656
|
+
state[key] = ns[key]
|
|
2601
2657
|
end
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2658
|
+
return state
|
|
2659
|
+
end
|
|
2660
|
+
|
|
2661
|
+
if operation == "get" then
|
|
2662
|
+
return HttpService:JSONEncode({
|
|
2663
|
+
success = true,
|
|
2664
|
+
state = readState(),
|
|
2665
|
+
})
|
|
2666
|
+
end
|
|
2667
|
+
|
|
2668
|
+
if operation == "reset" then
|
|
2669
|
+
local before = readState()
|
|
2670
|
+
for key, value in pairs(resetValues) do
|
|
2671
|
+
ns[key] = value
|
|
2608
2672
|
end
|
|
2609
|
-
|
|
2610
|
-
|
|
2673
|
+
return HttpService:JSONEncode({
|
|
2674
|
+
success = true,
|
|
2675
|
+
applied = resetValues,
|
|
2676
|
+
before = before,
|
|
2677
|
+
after = readState(),
|
|
2678
|
+
})
|
|
2679
|
+
end
|
|
2680
|
+
|
|
2681
|
+
error("Unsupported network simulation operation: " .. tostring(operation), 0)
|
|
2682
|
+
`.trim();
|
|
2683
|
+
}
|
|
2684
|
+
function normalizeDeviceSimulatorResolution(value) {
|
|
2685
|
+
if (value === void 0)
|
|
2686
|
+
return void 0;
|
|
2687
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
2688
|
+
throw new Error("resolution must be an object with positive integer width and height");
|
|
2689
|
+
}
|
|
2690
|
+
const resolution = value;
|
|
2691
|
+
const width = resolution.width;
|
|
2692
|
+
const height = resolution.height;
|
|
2693
|
+
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
|
|
2694
|
+
throw new Error("resolution.width and resolution.height must be positive integers");
|
|
2695
|
+
}
|
|
2696
|
+
return { width, height };
|
|
2697
|
+
}
|
|
2698
|
+
function normalizeDeviceSimulatorSettings(input) {
|
|
2699
|
+
const settings = {};
|
|
2700
|
+
if (input.deviceId !== void 0) {
|
|
2701
|
+
if (typeof input.deviceId !== "string" || input.deviceId.trim() === "") {
|
|
2702
|
+
throw new Error("deviceId must be a non-empty string");
|
|
2703
|
+
}
|
|
2704
|
+
settings.deviceId = input.deviceId;
|
|
2705
|
+
}
|
|
2706
|
+
if (input.orientation !== void 0) {
|
|
2707
|
+
if (typeof input.orientation !== "string" || input.orientation.trim() === "") {
|
|
2708
|
+
throw new Error("orientation must be a non-empty string");
|
|
2709
|
+
}
|
|
2710
|
+
settings.orientation = input.orientation;
|
|
2711
|
+
}
|
|
2712
|
+
const resolution = normalizeDeviceSimulatorResolution(input.resolution);
|
|
2713
|
+
if (resolution !== void 0)
|
|
2714
|
+
settings.resolution = resolution;
|
|
2715
|
+
if (input.pixelDensity !== void 0) {
|
|
2716
|
+
if (typeof input.pixelDensity !== "number" || !Number.isFinite(input.pixelDensity) || input.pixelDensity <= 0) {
|
|
2717
|
+
throw new Error("pixelDensity must be a positive finite number");
|
|
2718
|
+
}
|
|
2719
|
+
settings.pixelDensity = input.pixelDensity;
|
|
2720
|
+
}
|
|
2721
|
+
if (input.scalingMode !== void 0) {
|
|
2722
|
+
if (typeof input.scalingMode !== "string" || input.scalingMode.trim() === "") {
|
|
2723
|
+
throw new Error("scalingMode must be a non-empty string");
|
|
2724
|
+
}
|
|
2725
|
+
settings.scalingMode = input.scalingMode;
|
|
2726
|
+
}
|
|
2727
|
+
return settings;
|
|
2728
|
+
}
|
|
2729
|
+
function hasDeviceSimulatorSettings(settings) {
|
|
2730
|
+
return settings.deviceId !== void 0 || settings.orientation !== void 0 || settings.resolution !== void 0 || settings.pixelDensity !== void 0 || settings.scalingMode !== void 0;
|
|
2731
|
+
}
|
|
2732
|
+
function buildDeviceSimulatorLuau(operation, options) {
|
|
2733
|
+
const payload = JSON.stringify({ operation, ...options });
|
|
2734
|
+
return `
|
|
2735
|
+
local HttpService = game:GetService("HttpService")
|
|
2736
|
+
local simulator = game:GetService("StudioDeviceSimulatorService")
|
|
2737
|
+
local opts = HttpService:JSONDecode(${JSON.stringify(payload)})
|
|
2738
|
+
|
|
2739
|
+
local function plain(value)
|
|
2740
|
+
local valueType = typeof(value)
|
|
2741
|
+
if valueType == "Vector2" then
|
|
2742
|
+
return { x = value.X, y = value.Y, width = value.X, height = value.Y }
|
|
2611
2743
|
end
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
-- in the wrapper. Strip the "Workspace." parent prefix too so the
|
|
2620
|
-
-- final output reads "user_code:N" not "Workspace.user_code:N".
|
|
2621
|
-
local function __mcp_user_line(payload_n)
|
|
2622
|
-
local user_n = payload_n - __mcp_LINE_OFFSET
|
|
2623
|
-
if user_n < 1 then return "1" end
|
|
2624
|
-
if user_n > __mcp_USER_LINES then return tostring(__mcp_USER_LINES) .. " (at end of input)" end
|
|
2625
|
-
return tostring(user_n)
|
|
2744
|
+
if valueType == "EnumItem" then
|
|
2745
|
+
return value.Name
|
|
2746
|
+
end
|
|
2747
|
+
if type(value) == "table" then
|
|
2748
|
+
local out = {}
|
|
2749
|
+
for k, v in pairs(value) do
|
|
2750
|
+
out[tostring(k)] = plain(v)
|
|
2626
2751
|
end
|
|
2627
|
-
|
|
2628
|
-
local n = tonumber(num)
|
|
2629
|
-
if n then return "user_code:" .. __mcp_user_line(n) end
|
|
2630
|
-
return "user_code:" .. num
|
|
2631
|
-
end)
|
|
2632
|
-
s = string.gsub(s, "__MCPEvalPayload:(%d+)", function(num)
|
|
2633
|
-
local n = tonumber(num)
|
|
2634
|
-
if n then return "user_code:" .. __mcp_user_line(n) end
|
|
2635
|
-
return "user_code:" .. num
|
|
2636
|
-
end)
|
|
2637
|
-
s = string.gsub(s, '%[string "[^"]+"%]:(%d+)', function(num)
|
|
2638
|
-
local n = tonumber(num)
|
|
2639
|
-
if n then return "user_code:" .. __mcp_user_line(n) end
|
|
2640
|
-
return "user_code:" .. num
|
|
2641
|
-
end)
|
|
2642
|
-
return s
|
|
2752
|
+
return out
|
|
2643
2753
|
end
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2754
|
+
return value
|
|
2755
|
+
end
|
|
2756
|
+
|
|
2757
|
+
local function getDeviceInfo(deviceId)
|
|
2758
|
+
local ok, info = pcall(function()
|
|
2759
|
+
return simulator:GetDeviceInfoAsync(deviceId)
|
|
2760
|
+
end)
|
|
2761
|
+
if ok then
|
|
2762
|
+
return plain(info), nil
|
|
2763
|
+
end
|
|
2764
|
+
return nil, tostring(info)
|
|
2765
|
+
end
|
|
2766
|
+
|
|
2767
|
+
local function normalizeDeviceList(rawList)
|
|
2768
|
+
local devices = {}
|
|
2769
|
+
local ids = {}
|
|
2770
|
+
for _, entry in ipairs(rawList) do
|
|
2771
|
+
local item
|
|
2772
|
+
local id
|
|
2773
|
+
if type(entry) == "table" then
|
|
2774
|
+
item = plain(entry)
|
|
2775
|
+
id = item.DeviceId or item.deviceId or item.Id or item.id or item[1]
|
|
2776
|
+
else
|
|
2777
|
+
id = tostring(entry)
|
|
2778
|
+
item = { DeviceId = id }
|
|
2779
|
+
end
|
|
2780
|
+
if id ~= nil then
|
|
2781
|
+
id = tostring(id)
|
|
2782
|
+
local info = getDeviceInfo(id)
|
|
2783
|
+
if type(info) == "table" then
|
|
2784
|
+
item = info
|
|
2785
|
+
if item.DeviceId == nil then item.DeviceId = id end
|
|
2663
2786
|
end
|
|
2664
|
-
if
|
|
2665
|
-
|
|
2787
|
+
if item.IsCustom ~= true then
|
|
2788
|
+
ids[id] = true
|
|
2789
|
+
table.insert(devices, item)
|
|
2666
2790
|
end
|
|
2667
2791
|
end
|
|
2668
|
-
return table.concat(kept, "\\n")
|
|
2669
2792
|
end
|
|
2670
|
-
|
|
2671
|
-
return { ok = ok, value = errOrValue, output = __mcp_output }
|
|
2672
|
-
end)())`;
|
|
2673
|
-
return `
|
|
2674
|
-
local HttpService = game:GetService("HttpService")
|
|
2675
|
-
local bf = game:GetService("${opts.service}"):FindFirstChild("${opts.bridgeName}")
|
|
2676
|
-
if not bf then
|
|
2677
|
-
return HttpService:JSONEncode({
|
|
2678
|
-
bridge = "missing",
|
|
2679
|
-
error = ${luaLongQuote(opts.missingError)},
|
|
2680
|
-
})
|
|
2793
|
+
return devices, ids
|
|
2681
2794
|
end
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
local __mcp_USER_LINES_OUTER = ${userLines}
|
|
2687
|
-
local function __mcp_outer_user_line(payload_n)
|
|
2688
|
-
local user_n = payload_n - ${EVAL_WRAPPER_LINE_OFFSET}
|
|
2689
|
-
if user_n < 1 then return "1" end
|
|
2690
|
-
if user_n > __mcp_USER_LINES_OUTER then return tostring(__mcp_USER_LINES_OUTER) .. " (at end of input)" end
|
|
2691
|
-
return tostring(user_n)
|
|
2795
|
+
|
|
2796
|
+
local function getDeviceList()
|
|
2797
|
+
local rawList = simulator:GetDeviceListAsync()
|
|
2798
|
+
return normalizeDeviceList(rawList)
|
|
2692
2799
|
end
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
end
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
if n then return "user_code:" .. __mcp_outer_user_line(n) end
|
|
2702
|
-
return "user_code:" .. num
|
|
2703
|
-
end)
|
|
2704
|
-
s = string.gsub(s, '%[string "[^"]+"%]:(%d+)', function(num)
|
|
2705
|
-
local n = tonumber(num)
|
|
2706
|
-
if n then return "user_code:" .. __mcp_outer_user_line(n) end
|
|
2707
|
-
return "user_code:" .. num
|
|
2708
|
-
end)
|
|
2709
|
-
return s
|
|
2800
|
+
|
|
2801
|
+
local function assertBuiltInDeviceExists(deviceId)
|
|
2802
|
+
local _, ids = getDeviceList()
|
|
2803
|
+
if ids[deviceId] then return end
|
|
2804
|
+
local available = {}
|
|
2805
|
+
for id in pairs(ids) do table.insert(available, id) end
|
|
2806
|
+
table.sort(available)
|
|
2807
|
+
error('deviceId "' .. tostring(deviceId) .. '" is not an available built-in device. Use get_device_simulator_state to list supported device IDs. Available: ' .. table.concat(available, ", "), 0)
|
|
2710
2808
|
end
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
local
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
if
|
|
2809
|
+
|
|
2810
|
+
local function enumByName(enumType, raw, label)
|
|
2811
|
+
local name = tostring(raw)
|
|
2812
|
+
name = string.match(name, "([^%.]+)$") or name
|
|
2813
|
+
local available = {}
|
|
2814
|
+
for _, item in ipairs(enumType:GetEnumItems()) do
|
|
2815
|
+
table.insert(available, item.Name)
|
|
2816
|
+
if item.Name == name then
|
|
2817
|
+
return item, item.Name
|
|
2818
|
+
end
|
|
2719
2819
|
end
|
|
2720
|
-
|
|
2820
|
+
error(label .. ' "' .. tostring(raw) .. '" is not valid. Available: ' .. table.concat(available, ", "), 0)
|
|
2721
2821
|
end
|
|
2722
|
-
|
|
2723
|
-
local
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2822
|
+
|
|
2823
|
+
local function tryActiveGetter(state, key, fn)
|
|
2824
|
+
local ok, value = pcall(fn)
|
|
2825
|
+
if ok then
|
|
2826
|
+
state[key] = plain(value)
|
|
2827
|
+
else
|
|
2828
|
+
state.unavailable = state.unavailable or {}
|
|
2829
|
+
state.unavailable[key] = tostring(value)
|
|
2830
|
+
end
|
|
2729
2831
|
end
|
|
2730
|
-
|
|
2731
|
-
local
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
end
|
|
2832
|
+
|
|
2833
|
+
local function readState(includeDeviceList, requestedDeviceId)
|
|
2834
|
+
local activeDeviceId = tostring(simulator:GetDeviceAsync())
|
|
2835
|
+
local state = {
|
|
2836
|
+
activeDeviceId = activeDeviceId,
|
|
2837
|
+
isSimulating = activeDeviceId ~= "default",
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
if includeDeviceList then
|
|
2841
|
+
local devices = getDeviceList()
|
|
2842
|
+
state.devices = devices
|
|
2843
|
+
end
|
|
2844
|
+
|
|
2845
|
+
if requestedDeviceId ~= nil then
|
|
2846
|
+
assertBuiltInDeviceExists(requestedDeviceId)
|
|
2847
|
+
state.deviceInfo = plain(simulator:GetDeviceInfoAsync(requestedDeviceId))
|
|
2848
|
+
end
|
|
2849
|
+
|
|
2850
|
+
if state.isSimulating then
|
|
2851
|
+
tryActiveGetter(state, "resolution", function() return simulator:GetResolutionAsync() end)
|
|
2852
|
+
tryActiveGetter(state, "pixelDensity", function() return simulator:GetPixelDensityAsync() end)
|
|
2853
|
+
tryActiveGetter(state, "orientation", function() return simulator:GetOrientationAsync() end)
|
|
2854
|
+
tryActiveGetter(state, "scalingMode", function() return simulator:GetScalingModeAsync() end)
|
|
2754
2855
|
end
|
|
2755
|
-
|
|
2856
|
+
|
|
2857
|
+
return state
|
|
2756
2858
|
end
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2859
|
+
|
|
2860
|
+
local function applySettings(settings)
|
|
2861
|
+
local applied = {}
|
|
2862
|
+
if settings.deviceId ~= nil then
|
|
2863
|
+
assertBuiltInDeviceExists(settings.deviceId)
|
|
2864
|
+
simulator:SetDeviceAsync(settings.deviceId)
|
|
2865
|
+
applied.deviceId = settings.deviceId
|
|
2866
|
+
end
|
|
2867
|
+
if settings.orientation ~= nil then
|
|
2868
|
+
local item, name = enumByName(Enum.ScreenOrientation, settings.orientation, "orientation")
|
|
2869
|
+
simulator:SetOrientationAsync(item)
|
|
2870
|
+
applied.orientation = name
|
|
2871
|
+
end
|
|
2872
|
+
if settings.resolution ~= nil then
|
|
2873
|
+
simulator:SetResolutionAsync(settings.resolution.width, settings.resolution.height)
|
|
2874
|
+
applied.resolution = { width = settings.resolution.width, height = settings.resolution.height }
|
|
2875
|
+
end
|
|
2876
|
+
if settings.pixelDensity ~= nil then
|
|
2877
|
+
simulator:SetPixelDensityAsync(settings.pixelDensity)
|
|
2878
|
+
applied.pixelDensity = settings.pixelDensity
|
|
2879
|
+
end
|
|
2880
|
+
if settings.scalingMode ~= nil then
|
|
2881
|
+
local item, name = enumByName(Enum.DeviceSimulatorScalingMode, settings.scalingMode, "scalingMode")
|
|
2882
|
+
simulator:SetScalingModeAsync(item)
|
|
2883
|
+
applied.scalingMode = name
|
|
2884
|
+
end
|
|
2885
|
+
return applied
|
|
2765
2886
|
end
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
}
|
|
2786
|
-
|
|
2787
|
-
|
|
2887
|
+
|
|
2888
|
+
if opts.operation == "get" then
|
|
2889
|
+
return readState(opts.includeDeviceList ~= false, opts.deviceId)
|
|
2890
|
+
end
|
|
2891
|
+
|
|
2892
|
+
if opts.operation == "set" then
|
|
2893
|
+
local before = readState(false, nil)
|
|
2894
|
+
local applied
|
|
2895
|
+
if opts.stopSimulation == true then
|
|
2896
|
+
simulator:StopSimulationAsync()
|
|
2897
|
+
applied = { stopSimulation = true }
|
|
2898
|
+
else
|
|
2899
|
+
applied = applySettings(opts.settings or {})
|
|
2900
|
+
end
|
|
2901
|
+
return {
|
|
2902
|
+
success = true,
|
|
2903
|
+
applied = applied,
|
|
2904
|
+
before = before,
|
|
2905
|
+
after = readState(false, nil),
|
|
2906
|
+
}
|
|
2907
|
+
end
|
|
2908
|
+
|
|
2909
|
+
error("Unsupported device simulator operation: " .. tostring(opts.operation), 0)
|
|
2910
|
+
`.trim();
|
|
2788
2911
|
}
|
|
2789
|
-
var
|
|
2912
|
+
var MAX_INLINE_IMAGE_BYTES, MAX_DEVICE_MATRIX_ENTRIES, MAX_NETWORK_PACKET_LOSS_PERCENT, NETWORK_PROFILE_KEYS, NETWORK_PROFILES, ZERO_NETWORK_PROFILE, SIMULATION_PERSISTENCE_NOTES, RobloxStudioTools;
|
|
2790
2913
|
var init_tools = __esm({
|
|
2791
2914
|
"../core/dist/tools/index.js"() {
|
|
2792
2915
|
"use strict";
|
|
@@ -2797,9 +2920,56 @@ var init_tools = __esm({
|
|
|
2797
2920
|
init_roblox_cookie_client();
|
|
2798
2921
|
init_jpeg_encoder();
|
|
2799
2922
|
init_png_encoder();
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2923
|
+
MAX_INLINE_IMAGE_BYTES = 6e6;
|
|
2924
|
+
MAX_DEVICE_MATRIX_ENTRIES = 6;
|
|
2925
|
+
MAX_NETWORK_PACKET_LOSS_PERCENT = 0.5;
|
|
2926
|
+
NETWORK_PROFILE_KEYS = [
|
|
2927
|
+
"InboundNetworkMinDelayMs",
|
|
2928
|
+
"OutboundNetworkMinDelayMs",
|
|
2929
|
+
"InboundNetworkJitterMs",
|
|
2930
|
+
"OutboundNetworkJitterMs",
|
|
2931
|
+
"InboundNetworkLossPercent",
|
|
2932
|
+
"OutboundNetworkLossPercent"
|
|
2933
|
+
];
|
|
2934
|
+
NETWORK_PROFILES = {
|
|
2935
|
+
great: {
|
|
2936
|
+
InboundNetworkMinDelayMs: 15,
|
|
2937
|
+
OutboundNetworkMinDelayMs: 15,
|
|
2938
|
+
InboundNetworkJitterMs: 0,
|
|
2939
|
+
OutboundNetworkJitterMs: 0,
|
|
2940
|
+
InboundNetworkLossPercent: 0,
|
|
2941
|
+
OutboundNetworkLossPercent: 0
|
|
2942
|
+
},
|
|
2943
|
+
good: {
|
|
2944
|
+
InboundNetworkMinDelayMs: 50,
|
|
2945
|
+
OutboundNetworkMinDelayMs: 50,
|
|
2946
|
+
InboundNetworkJitterMs: 10,
|
|
2947
|
+
OutboundNetworkJitterMs: 10,
|
|
2948
|
+
InboundNetworkLossPercent: 0,
|
|
2949
|
+
OutboundNetworkLossPercent: 0
|
|
2950
|
+
},
|
|
2951
|
+
poor: {
|
|
2952
|
+
InboundNetworkMinDelayMs: 150,
|
|
2953
|
+
OutboundNetworkMinDelayMs: 150,
|
|
2954
|
+
InboundNetworkJitterMs: 100,
|
|
2955
|
+
OutboundNetworkJitterMs: 100,
|
|
2956
|
+
InboundNetworkLossPercent: 0.5,
|
|
2957
|
+
OutboundNetworkLossPercent: 0.5
|
|
2958
|
+
}
|
|
2959
|
+
};
|
|
2960
|
+
ZERO_NETWORK_PROFILE = {
|
|
2961
|
+
InboundNetworkMinDelayMs: 0,
|
|
2962
|
+
OutboundNetworkMinDelayMs: 0,
|
|
2963
|
+
InboundNetworkJitterMs: 0,
|
|
2964
|
+
OutboundNetworkJitterMs: 0,
|
|
2965
|
+
InboundNetworkLossPercent: 0,
|
|
2966
|
+
OutboundNetworkLossPercent: 0
|
|
2967
|
+
};
|
|
2968
|
+
SIMULATION_PERSISTENCE_NOTES = [
|
|
2969
|
+
"Normal Play client changes can write back to edit state.",
|
|
2970
|
+
"Multiplayer clients inherit baseline at startup but are isolated afterward.",
|
|
2971
|
+
"StudioTestService client device simulator state may appear stale on fresh clients, so reset after client startup is required."
|
|
2972
|
+
];
|
|
2803
2973
|
RobloxStudioTools = class _RobloxStudioTools {
|
|
2804
2974
|
client;
|
|
2805
2975
|
bridge;
|
|
@@ -2899,6 +3069,160 @@ var init_tools = __esm({
|
|
|
2899
3069
|
_clientRolesForInstance(instanceId) {
|
|
2900
3070
|
return this._rolesForInstance(instanceId).filter((role) => /^client-\d+$/.test(role)).sort((a, b) => Number(a.slice("client-".length)) - Number(b.slice("client-".length)));
|
|
2901
3071
|
}
|
|
3072
|
+
_resolveDeviceSimulatorSingleTarget(target, instance_id, toolName) {
|
|
3073
|
+
const selectedTarget = target ?? "edit";
|
|
3074
|
+
if (selectedTarget === "server" || selectedTarget === "all" || selectedTarget === "all-clients" || selectedTarget === "edit-proxy") {
|
|
3075
|
+
throw new Error(`${toolName} target must be "edit" or "client-N" (got: ${selectedTarget})`);
|
|
3076
|
+
}
|
|
3077
|
+
if (selectedTarget !== "edit" && !/^client-\d+$/.test(selectedTarget)) {
|
|
3078
|
+
throw new Error(`${toolName} target must be "edit" or "client-N" (got: ${selectedTarget})`);
|
|
3079
|
+
}
|
|
3080
|
+
const resolved = this._resolveSingleTarget(selectedTarget, instance_id);
|
|
3081
|
+
return { ...resolved, selectedTarget };
|
|
3082
|
+
}
|
|
3083
|
+
_resolveDeviceSimulatorSetTargets(target, instance_id) {
|
|
3084
|
+
const selectedTarget = target ?? "edit";
|
|
3085
|
+
if (selectedTarget === "all-clients") {
|
|
3086
|
+
const instanceId = this._resolveInstanceIdOnly(instance_id);
|
|
3087
|
+
const roles = this._clientRolesForInstance(instanceId);
|
|
3088
|
+
if (roles.length === 0) {
|
|
3089
|
+
throw new RoutingFailure({
|
|
3090
|
+
code: "target_role_not_present_on_instance",
|
|
3091
|
+
message: `instance "${instanceId}" has no connected playtest client roles. Start a playtest first.`,
|
|
3092
|
+
data: {
|
|
3093
|
+
instances: this.bridge.getPublicInstances(),
|
|
3094
|
+
count: this.bridge.getInstances().length
|
|
3095
|
+
}
|
|
3096
|
+
});
|
|
3097
|
+
}
|
|
3098
|
+
return { instanceId, selectedTarget, roles };
|
|
3099
|
+
}
|
|
3100
|
+
const resolved = this._resolveDeviceSimulatorSingleTarget(selectedTarget, instance_id, "set_device_simulator");
|
|
3101
|
+
return { instanceId: resolved.instanceId, selectedTarget, roles: [resolved.role] };
|
|
3102
|
+
}
|
|
3103
|
+
_normalizeSimulationInclude(include) {
|
|
3104
|
+
const selectedInclude = include ?? "both";
|
|
3105
|
+
if (selectedInclude !== "network" && selectedInclude !== "deviceSimulator" && selectedInclude !== "both") {
|
|
3106
|
+
throw new Error(`get_simulation_state include must be "network", "deviceSimulator", or "both" (got: ${selectedInclude})`);
|
|
3107
|
+
}
|
|
3108
|
+
return selectedInclude;
|
|
3109
|
+
}
|
|
3110
|
+
_resolveSimulationTargets(target, instance_id, toolName) {
|
|
3111
|
+
const selectedTarget = target ?? "edit-and-clients";
|
|
3112
|
+
if (selectedTarget === "server" || selectedTarget === "all" || selectedTarget === "edit-proxy") {
|
|
3113
|
+
throw new Error(`${toolName} target must be "edit", "client-N", "all-clients", or "edit-and-clients" (got: ${selectedTarget})`);
|
|
3114
|
+
}
|
|
3115
|
+
const instanceId = this._resolveInstanceIdOnly(instance_id);
|
|
3116
|
+
const connectedRoles = this._rolesForInstance(instanceId);
|
|
3117
|
+
const clientRoles = this._clientRolesForInstance(instanceId);
|
|
3118
|
+
const warnings = [];
|
|
3119
|
+
let roles;
|
|
3120
|
+
if (selectedTarget === "edit") {
|
|
3121
|
+
if (!connectedRoles.includes("edit")) {
|
|
3122
|
+
throw new RoutingFailure({
|
|
3123
|
+
code: "target_role_not_present_on_instance",
|
|
3124
|
+
message: `instance "${instanceId}" has no role "edit". Available roles: ${connectedRoles.join(", ") || "none"}.`,
|
|
3125
|
+
data: {
|
|
3126
|
+
instances: this.bridge.getPublicInstances(),
|
|
3127
|
+
count: this.bridge.getInstances().length
|
|
3128
|
+
}
|
|
3129
|
+
});
|
|
3130
|
+
}
|
|
3131
|
+
roles = ["edit"];
|
|
3132
|
+
} else if (selectedTarget === "all-clients") {
|
|
3133
|
+
roles = clientRoles;
|
|
3134
|
+
if (roles.length === 0) {
|
|
3135
|
+
warnings.push(`No connected playtest client roles found for instance "${instanceId}".`);
|
|
3136
|
+
}
|
|
3137
|
+
} else if (selectedTarget === "edit-and-clients") {
|
|
3138
|
+
roles = [];
|
|
3139
|
+
if (connectedRoles.includes("edit")) {
|
|
3140
|
+
roles.push("edit");
|
|
3141
|
+
} else {
|
|
3142
|
+
warnings.push(`No edit role found for instance "${instanceId}".`);
|
|
3143
|
+
}
|
|
3144
|
+
roles.push(...clientRoles);
|
|
3145
|
+
} else if (/^client-\d+$/.test(selectedTarget)) {
|
|
3146
|
+
if (!clientRoles.includes(selectedTarget)) {
|
|
3147
|
+
throw new RoutingFailure({
|
|
3148
|
+
code: "target_role_not_present_on_instance",
|
|
3149
|
+
message: `instance "${instanceId}" has no role "${selectedTarget}". Available client roles: ${clientRoles.join(", ") || "none"}.`,
|
|
3150
|
+
data: {
|
|
3151
|
+
instances: this.bridge.getPublicInstances(),
|
|
3152
|
+
count: this.bridge.getInstances().length
|
|
3153
|
+
}
|
|
3154
|
+
});
|
|
3155
|
+
}
|
|
3156
|
+
roles = [selectedTarget];
|
|
3157
|
+
} else {
|
|
3158
|
+
throw new Error(`${toolName} target must be "edit", "client-N", "all-clients", or "edit-and-clients" (got: ${selectedTarget})`);
|
|
3159
|
+
}
|
|
3160
|
+
return { instanceId, selectedTarget, roles, warnings };
|
|
3161
|
+
}
|
|
3162
|
+
_parseExecuteLuauJsonResponse(response, toolName) {
|
|
3163
|
+
const r = response;
|
|
3164
|
+
if (r?.success === false) {
|
|
3165
|
+
throw new Error(r.error || r.message || `${toolName} Luau execution failed`);
|
|
3166
|
+
}
|
|
3167
|
+
if (typeof r?.returnValue !== "string") {
|
|
3168
|
+
return response;
|
|
3169
|
+
}
|
|
3170
|
+
if (r.returnValue === "") {
|
|
3171
|
+
return {};
|
|
3172
|
+
}
|
|
3173
|
+
try {
|
|
3174
|
+
return JSON.parse(r.returnValue);
|
|
3175
|
+
} catch {
|
|
3176
|
+
throw new Error(`${toolName} returned non-JSON data: ${r.returnValue}`);
|
|
3177
|
+
}
|
|
3178
|
+
}
|
|
3179
|
+
async _executeNetworkStateOperation(instanceId, role, operation) {
|
|
3180
|
+
const code = buildNetworkStateLuau(operation);
|
|
3181
|
+
const response = await this.client.request("/api/execute-luau", { code }, instanceId, role);
|
|
3182
|
+
return this._parseExecuteLuauJsonResponse(response, `network simulation ${operation}`);
|
|
3183
|
+
}
|
|
3184
|
+
async _executeDeviceSimulatorOperation(instanceId, role, operation, options) {
|
|
3185
|
+
const code = buildDeviceSimulatorLuau(operation, options);
|
|
3186
|
+
const response = await this.client.request("/api/execute-luau", { code }, instanceId, role);
|
|
3187
|
+
return this._parseExecuteLuauJsonResponse(response, `device simulator ${operation}`);
|
|
3188
|
+
}
|
|
3189
|
+
_settingsFromDeviceSimulatorState(state) {
|
|
3190
|
+
const s = state;
|
|
3191
|
+
if (!s || s.isSimulating !== true || typeof s.activeDeviceId !== "string" || s.activeDeviceId === "default") {
|
|
3192
|
+
return { stopSimulation: true };
|
|
3193
|
+
}
|
|
3194
|
+
return normalizeDeviceSimulatorSettings({
|
|
3195
|
+
deviceId: s.activeDeviceId,
|
|
3196
|
+
orientation: s.orientation,
|
|
3197
|
+
resolution: s.resolution,
|
|
3198
|
+
pixelDensity: s.pixelDensity,
|
|
3199
|
+
scalingMode: s.scalingMode
|
|
3200
|
+
});
|
|
3201
|
+
}
|
|
3202
|
+
_deviceSimulatorStateWithoutDeviceList(state) {
|
|
3203
|
+
if (typeof state !== "object" || state === null || Array.isArray(state)) {
|
|
3204
|
+
return state;
|
|
3205
|
+
}
|
|
3206
|
+
const { devices: _devices, ...rest } = state;
|
|
3207
|
+
return rest;
|
|
3208
|
+
}
|
|
3209
|
+
_assertCanRestoreDeviceSimulatorState(state) {
|
|
3210
|
+
const s = state;
|
|
3211
|
+
if (!s || s.isSimulating !== true || typeof s.activeDeviceId !== "string" || s.activeDeviceId === "default") {
|
|
3212
|
+
return;
|
|
3213
|
+
}
|
|
3214
|
+
const devices = Array.isArray(s.devices) ? s.devices : [];
|
|
3215
|
+
const isBuiltIn = devices.some((entry) => {
|
|
3216
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry))
|
|
3217
|
+
return false;
|
|
3218
|
+
const device = entry;
|
|
3219
|
+
const id = device.DeviceId ?? device.deviceId ?? device.Id ?? device.id;
|
|
3220
|
+
return id === s.activeDeviceId && device.IsCustom !== true;
|
|
3221
|
+
});
|
|
3222
|
+
if (!isBuiltIn) {
|
|
3223
|
+
throw new Error(`capture_device_matrix cannot safely restore active custom device "${s.activeDeviceId}". Switch the simulator to default or a built-in preset first, or pass restoreAfter=false only if you intentionally accept changing the simulator state.`);
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
2902
3226
|
async _waitForRuntimeRoles(instanceId, opts, timeoutSec = 30) {
|
|
2903
3227
|
const deadline = Date.now() + timeoutSec * 1e3;
|
|
2904
3228
|
while (Date.now() < deadline) {
|
|
@@ -3476,18 +3800,12 @@ ${code}`
|
|
|
3476
3800
|
if (!code) {
|
|
3477
3801
|
throw new Error("Code is required for eval_server_runtime");
|
|
3478
3802
|
}
|
|
3479
|
-
const
|
|
3480
|
-
service: "ServerScriptService",
|
|
3481
|
-
bridgeName: SERVER_LOCAL_NAME,
|
|
3482
|
-
missingError: "ServerEvalBridge not found. The bridge runs inside the play DM, so a playtest must be running. The bridge installs automatically (including for manually-started playtests); if a playtest is running and you still see this, reconnect the plugin in the edit window so the bridge reinstalls, then start the playtest again.",
|
|
3483
|
-
userCode: code
|
|
3484
|
-
});
|
|
3485
|
-
const response = await this._callSingle("/api/execute-luau", { code: wrapper }, "server", instance_id);
|
|
3803
|
+
const response = await this._callSingle("/api/eval-runtime", { code }, "server", instance_id);
|
|
3486
3804
|
return {
|
|
3487
3805
|
content: [
|
|
3488
3806
|
{
|
|
3489
3807
|
type: "text",
|
|
3490
|
-
text:
|
|
3808
|
+
text: JSON.stringify(response)
|
|
3491
3809
|
}
|
|
3492
3810
|
]
|
|
3493
3811
|
};
|
|
@@ -3500,22 +3818,375 @@ ${code}`
|
|
|
3500
3818
|
if (!clientTarget.startsWith("client-")) {
|
|
3501
3819
|
throw new Error(`eval_client_runtime requires target=client-N (got: ${clientTarget})`);
|
|
3502
3820
|
}
|
|
3503
|
-
const
|
|
3504
|
-
service: "ReplicatedStorage",
|
|
3505
|
-
bridgeName: CLIENT_LOCAL_NAME,
|
|
3506
|
-
missingError: "ClientEvalBridge not found. The bridge runs inside the play DM, so a playtest must be running. The bridge installs automatically (including for manually-started playtests); if a playtest is running and you still see this, reconnect the plugin in the edit window so the bridge reinstalls, then start the playtest again.",
|
|
3507
|
-
userCode: code
|
|
3508
|
-
});
|
|
3509
|
-
const response = await this._callSingle("/api/execute-luau", { code: wrapper }, clientTarget, instance_id);
|
|
3821
|
+
const response = await this._callSingle("/api/eval-runtime", { code }, clientTarget, instance_id);
|
|
3510
3822
|
return {
|
|
3511
3823
|
content: [
|
|
3512
3824
|
{
|
|
3513
3825
|
type: "text",
|
|
3514
|
-
text:
|
|
3826
|
+
text: JSON.stringify(response)
|
|
3515
3827
|
}
|
|
3516
3828
|
]
|
|
3517
3829
|
};
|
|
3518
3830
|
}
|
|
3831
|
+
async setNetworkProfile(profile, target, overrides, instance_id) {
|
|
3832
|
+
const values = normalizeNetworkProfile(profile, overrides);
|
|
3833
|
+
const instanceId = this._resolveInstanceIdOnly(instance_id);
|
|
3834
|
+
const clientRoles = this._clientRolesForInstance(instanceId);
|
|
3835
|
+
const selectedTarget = target ?? "client-1";
|
|
3836
|
+
let targetRoles;
|
|
3837
|
+
if (selectedTarget === "all-clients") {
|
|
3838
|
+
targetRoles = clientRoles;
|
|
3839
|
+
} else if (/^client-\d+$/.test(selectedTarget)) {
|
|
3840
|
+
if (!clientRoles.includes(selectedTarget)) {
|
|
3841
|
+
throw new RoutingFailure({
|
|
3842
|
+
code: "target_role_not_present_on_instance",
|
|
3843
|
+
message: `instance "${instanceId}" has no role "${selectedTarget}". Available client roles: ${clientRoles.join(", ") || "none"}.`,
|
|
3844
|
+
data: {
|
|
3845
|
+
instances: this.bridge.getPublicInstances(),
|
|
3846
|
+
count: this.bridge.getInstances().length
|
|
3847
|
+
}
|
|
3848
|
+
});
|
|
3849
|
+
}
|
|
3850
|
+
targetRoles = [selectedTarget];
|
|
3851
|
+
} else {
|
|
3852
|
+
throw new Error(`set_network_profile target must be "client-N" or "all-clients" (got: ${selectedTarget})`);
|
|
3853
|
+
}
|
|
3854
|
+
if (targetRoles.length === 0) {
|
|
3855
|
+
throw new RoutingFailure({
|
|
3856
|
+
code: "target_role_not_present_on_instance",
|
|
3857
|
+
message: `instance "${instanceId}" has no connected playtest client roles. Start a playtest first.`,
|
|
3858
|
+
data: {
|
|
3859
|
+
instances: this.bridge.getPublicInstances(),
|
|
3860
|
+
count: this.bridge.getInstances().length
|
|
3861
|
+
}
|
|
3862
|
+
});
|
|
3863
|
+
}
|
|
3864
|
+
const code = buildNetworkProfileLuau(profile, values);
|
|
3865
|
+
const responses = await Promise.allSettled(targetRoles.map(async (role) => {
|
|
3866
|
+
const response = await this.client.request("/api/execute-luau", { code }, instanceId, role);
|
|
3867
|
+
const result = this._parseExecuteLuauJsonResponse(response, "set_network_profile");
|
|
3868
|
+
return { role, result };
|
|
3869
|
+
}));
|
|
3870
|
+
const body = {
|
|
3871
|
+
profile,
|
|
3872
|
+
target: selectedTarget,
|
|
3873
|
+
applied: values,
|
|
3874
|
+
targets: {}
|
|
3875
|
+
};
|
|
3876
|
+
const targetResults = body.targets;
|
|
3877
|
+
const failures = [];
|
|
3878
|
+
for (let i = 0; i < responses.length; i++) {
|
|
3879
|
+
const role = targetRoles[i];
|
|
3880
|
+
const response = responses[i];
|
|
3881
|
+
if (response.status === "fulfilled") {
|
|
3882
|
+
targetResults[role] = response.value.result;
|
|
3883
|
+
} else {
|
|
3884
|
+
const message = errorMessage(response.reason);
|
|
3885
|
+
targetResults[role] = { error: message };
|
|
3886
|
+
failures.push(`${role}: ${message}`);
|
|
3887
|
+
}
|
|
3888
|
+
}
|
|
3889
|
+
if (failures.length > 0) {
|
|
3890
|
+
throw new Error(`set_network_profile failed for ${failures.join("; ")}. Partial result: ${JSON.stringify(body)}`);
|
|
3891
|
+
}
|
|
3892
|
+
return {
|
|
3893
|
+
content: [
|
|
3894
|
+
{
|
|
3895
|
+
type: "text",
|
|
3896
|
+
text: JSON.stringify(body)
|
|
3897
|
+
}
|
|
3898
|
+
]
|
|
3899
|
+
};
|
|
3900
|
+
}
|
|
3901
|
+
async getSimulationState(include, target, instance_id) {
|
|
3902
|
+
const selectedInclude = this._normalizeSimulationInclude(include);
|
|
3903
|
+
const includeNetwork = selectedInclude === "network" || selectedInclude === "both";
|
|
3904
|
+
const includeDeviceSimulator = selectedInclude === "deviceSimulator" || selectedInclude === "both";
|
|
3905
|
+
const resolved = this._resolveSimulationTargets(target, instance_id, "get_simulation_state");
|
|
3906
|
+
const roleEntries = await Promise.all(resolved.roles.map(async (role) => {
|
|
3907
|
+
const state = {};
|
|
3908
|
+
const errors = {};
|
|
3909
|
+
if (includeNetwork) {
|
|
3910
|
+
try {
|
|
3911
|
+
state.network = await this._executeNetworkStateOperation(resolved.instanceId, role, "get");
|
|
3912
|
+
} catch (error) {
|
|
3913
|
+
errors.network = errorMessage(error);
|
|
3914
|
+
}
|
|
3915
|
+
}
|
|
3916
|
+
if (includeDeviceSimulator) {
|
|
3917
|
+
try {
|
|
3918
|
+
state.deviceSimulator = await this._executeDeviceSimulatorOperation(resolved.instanceId, role, "get", { includeDeviceList: false });
|
|
3919
|
+
} catch (error) {
|
|
3920
|
+
errors.deviceSimulator = errorMessage(error);
|
|
3921
|
+
}
|
|
3922
|
+
}
|
|
3923
|
+
if (Object.keys(errors).length > 0) {
|
|
3924
|
+
state.errors = errors;
|
|
3925
|
+
}
|
|
3926
|
+
return { role, state };
|
|
3927
|
+
}));
|
|
3928
|
+
const roles = {};
|
|
3929
|
+
for (const entry of roleEntries) {
|
|
3930
|
+
roles[entry.role] = entry.state;
|
|
3931
|
+
}
|
|
3932
|
+
return {
|
|
3933
|
+
content: [{
|
|
3934
|
+
type: "text",
|
|
3935
|
+
text: JSON.stringify({
|
|
3936
|
+
include: selectedInclude,
|
|
3937
|
+
target: resolved.selectedTarget,
|
|
3938
|
+
roles,
|
|
3939
|
+
warnings: resolved.warnings,
|
|
3940
|
+
persistenceNotes: SIMULATION_PERSISTENCE_NOTES
|
|
3941
|
+
})
|
|
3942
|
+
}]
|
|
3943
|
+
};
|
|
3944
|
+
}
|
|
3945
|
+
async resetSimulationState(target, network, deviceSimulator, instance_id) {
|
|
3946
|
+
const resetNetwork = network !== false;
|
|
3947
|
+
const resetDeviceSimulator = deviceSimulator !== false;
|
|
3948
|
+
if (!resetNetwork && !resetDeviceSimulator) {
|
|
3949
|
+
throw new Error("reset_simulation_state requires network=true and/or deviceSimulator=true; both default to true");
|
|
3950
|
+
}
|
|
3951
|
+
const resolved = this._resolveSimulationTargets(target, instance_id, "reset_simulation_state");
|
|
3952
|
+
const roleEntries = await Promise.all(resolved.roles.map(async (role) => {
|
|
3953
|
+
const result = {};
|
|
3954
|
+
const errors = {};
|
|
3955
|
+
if (resetNetwork) {
|
|
3956
|
+
try {
|
|
3957
|
+
result.network = await this._executeNetworkStateOperation(resolved.instanceId, role, "reset");
|
|
3958
|
+
} catch (error) {
|
|
3959
|
+
errors.network = errorMessage(error);
|
|
3960
|
+
}
|
|
3961
|
+
}
|
|
3962
|
+
if (resetDeviceSimulator) {
|
|
3963
|
+
try {
|
|
3964
|
+
result.deviceSimulator = await this._executeDeviceSimulatorOperation(resolved.instanceId, role, "set", { stopSimulation: true });
|
|
3965
|
+
} catch (error) {
|
|
3966
|
+
errors.deviceSimulator = errorMessage(error);
|
|
3967
|
+
}
|
|
3968
|
+
}
|
|
3969
|
+
if (Object.keys(errors).length > 0) {
|
|
3970
|
+
result.errors = errors;
|
|
3971
|
+
}
|
|
3972
|
+
return { role, result };
|
|
3973
|
+
}));
|
|
3974
|
+
const roles = {};
|
|
3975
|
+
const failures = [];
|
|
3976
|
+
for (const entry of roleEntries) {
|
|
3977
|
+
roles[entry.role] = entry.result;
|
|
3978
|
+
const errors = entry.result.errors;
|
|
3979
|
+
if (errors) {
|
|
3980
|
+
for (const [kind, message] of Object.entries(errors)) {
|
|
3981
|
+
failures.push(`${entry.role}.${kind}: ${message}`);
|
|
3982
|
+
}
|
|
3983
|
+
}
|
|
3984
|
+
}
|
|
3985
|
+
const body = {
|
|
3986
|
+
target: resolved.selectedTarget,
|
|
3987
|
+
network: resetNetwork,
|
|
3988
|
+
deviceSimulator: resetDeviceSimulator,
|
|
3989
|
+
roles,
|
|
3990
|
+
warnings: resolved.warnings,
|
|
3991
|
+
persistenceNotes: SIMULATION_PERSISTENCE_NOTES
|
|
3992
|
+
};
|
|
3993
|
+
if (failures.length > 0) {
|
|
3994
|
+
throw new Error(`reset_simulation_state failed for ${failures.join("; ")}. Partial result: ${JSON.stringify(body)}`);
|
|
3995
|
+
}
|
|
3996
|
+
return {
|
|
3997
|
+
content: [{
|
|
3998
|
+
type: "text",
|
|
3999
|
+
text: JSON.stringify(body)
|
|
4000
|
+
}]
|
|
4001
|
+
};
|
|
4002
|
+
}
|
|
4003
|
+
async getDeviceSimulatorState(target, deviceId, includeDeviceList, instance_id) {
|
|
4004
|
+
if (deviceId !== void 0 && (typeof deviceId !== "string" || deviceId.trim() === "")) {
|
|
4005
|
+
throw new Error("deviceId must be a non-empty string when provided");
|
|
4006
|
+
}
|
|
4007
|
+
const resolved = this._resolveDeviceSimulatorSingleTarget(target, instance_id, "get_device_simulator_state");
|
|
4008
|
+
const state = await this._executeDeviceSimulatorOperation(resolved.instanceId, resolved.role, "get", {
|
|
4009
|
+
includeDeviceList: includeDeviceList !== false,
|
|
4010
|
+
deviceId
|
|
4011
|
+
});
|
|
4012
|
+
return {
|
|
4013
|
+
content: [{
|
|
4014
|
+
type: "text",
|
|
4015
|
+
text: JSON.stringify({
|
|
4016
|
+
target: resolved.selectedTarget,
|
|
4017
|
+
role: resolved.role,
|
|
4018
|
+
...state
|
|
4019
|
+
})
|
|
4020
|
+
}]
|
|
4021
|
+
};
|
|
4022
|
+
}
|
|
4023
|
+
async setDeviceSimulator(target, deviceId, orientation, resolution, pixelDensity, scalingMode, stopSimulation, instance_id) {
|
|
4024
|
+
const settings = normalizeDeviceSimulatorSettings({ deviceId, orientation, resolution, pixelDensity, scalingMode });
|
|
4025
|
+
if (stopSimulation === true && hasDeviceSimulatorSettings(settings)) {
|
|
4026
|
+
throw new Error("stopSimulation=true cannot be combined with deviceId, orientation, resolution, pixelDensity, or scalingMode");
|
|
4027
|
+
}
|
|
4028
|
+
if (stopSimulation !== true && !hasDeviceSimulatorSettings(settings)) {
|
|
4029
|
+
throw new Error("set_device_simulator requires stopSimulation=true or at least one simulator setting");
|
|
4030
|
+
}
|
|
4031
|
+
const resolved = this._resolveDeviceSimulatorSetTargets(target, instance_id);
|
|
4032
|
+
const responses = await Promise.allSettled(resolved.roles.map(async (role) => {
|
|
4033
|
+
const result = await this._executeDeviceSimulatorOperation(resolved.instanceId, role, "set", stopSimulation === true ? { stopSimulation: true } : { settings });
|
|
4034
|
+
return { role, result };
|
|
4035
|
+
}));
|
|
4036
|
+
const body = {
|
|
4037
|
+
target: resolved.selectedTarget,
|
|
4038
|
+
targets: {}
|
|
4039
|
+
};
|
|
4040
|
+
const targets = body.targets;
|
|
4041
|
+
const failures = [];
|
|
4042
|
+
for (let i = 0; i < responses.length; i++) {
|
|
4043
|
+
const role = resolved.roles[i];
|
|
4044
|
+
const response = responses[i];
|
|
4045
|
+
if (response.status === "fulfilled") {
|
|
4046
|
+
targets[role] = response.value.result;
|
|
4047
|
+
} else {
|
|
4048
|
+
const message = errorMessage(response.reason);
|
|
4049
|
+
targets[role] = { error: message };
|
|
4050
|
+
failures.push(`${role}: ${message}`);
|
|
4051
|
+
}
|
|
4052
|
+
}
|
|
4053
|
+
if (failures.length > 0) {
|
|
4054
|
+
throw new Error(`set_device_simulator failed for ${failures.join("; ")}. Partial result: ${JSON.stringify(body)}`);
|
|
4055
|
+
}
|
|
4056
|
+
return {
|
|
4057
|
+
content: [{
|
|
4058
|
+
type: "text",
|
|
4059
|
+
text: JSON.stringify(body)
|
|
4060
|
+
}]
|
|
4061
|
+
};
|
|
4062
|
+
}
|
|
4063
|
+
async captureDeviceMatrix(entries, target, format, quality, settleSeconds, restoreAfter, instance_id) {
|
|
4064
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
4065
|
+
throw new Error("capture_device_matrix requires a non-empty entries array");
|
|
4066
|
+
}
|
|
4067
|
+
if (entries.length > MAX_DEVICE_MATRIX_ENTRIES) {
|
|
4068
|
+
throw new Error(`capture_device_matrix supports at most ${MAX_DEVICE_MATRIX_ENTRIES} entries per call; split larger matrices into multiple calls`);
|
|
4069
|
+
}
|
|
4070
|
+
const matrixEntries = entries.map((entry, index) => {
|
|
4071
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
4072
|
+
throw new Error(`entries[${index}] must be an object`);
|
|
4073
|
+
}
|
|
4074
|
+
const raw = entry;
|
|
4075
|
+
if (raw.label !== void 0 && typeof raw.label !== "string") {
|
|
4076
|
+
throw new Error(`entries[${index}].label must be a string when provided`);
|
|
4077
|
+
}
|
|
4078
|
+
return {
|
|
4079
|
+
...normalizeDeviceSimulatorSettings({
|
|
4080
|
+
deviceId: raw.deviceId,
|
|
4081
|
+
orientation: raw.orientation,
|
|
4082
|
+
resolution: raw.resolution,
|
|
4083
|
+
pixelDensity: raw.pixelDensity,
|
|
4084
|
+
scalingMode: raw.scalingMode
|
|
4085
|
+
}),
|
|
4086
|
+
label: raw.label
|
|
4087
|
+
};
|
|
4088
|
+
});
|
|
4089
|
+
const resolved = this._resolveDeviceSimulatorSingleTarget(target, instance_id, "capture_device_matrix");
|
|
4090
|
+
if (resolved.role.startsWith("client-") && await this._isMultiplayerTestRunning(resolved.instanceId)) {
|
|
4091
|
+
throw new Error("capture_device_matrix does not support StudioTestService multiplayer client targets because Roblox scopes temporary screenshot textures per client process");
|
|
4092
|
+
}
|
|
4093
|
+
const settleMs = settleSeconds === void 0 ? 300 : Math.max(0, Math.floor(settleSeconds * 1e3));
|
|
4094
|
+
const shouldRestore = restoreAfter !== false;
|
|
4095
|
+
const before = await this._executeDeviceSimulatorOperation(resolved.instanceId, resolved.role, "get", { includeDeviceList: shouldRestore });
|
|
4096
|
+
if (shouldRestore) {
|
|
4097
|
+
this._assertCanRestoreDeviceSimulatorState(before);
|
|
4098
|
+
}
|
|
4099
|
+
const summary = {
|
|
4100
|
+
target: resolved.selectedTarget,
|
|
4101
|
+
role: resolved.role,
|
|
4102
|
+
restoreAfter: shouldRestore,
|
|
4103
|
+
before: this._deviceSimulatorStateWithoutDeviceList(before),
|
|
4104
|
+
entries: []
|
|
4105
|
+
};
|
|
4106
|
+
const entrySummaries = summary.entries;
|
|
4107
|
+
const content = [];
|
|
4108
|
+
const failures = [];
|
|
4109
|
+
try {
|
|
4110
|
+
for (let i = 0; i < matrixEntries.length; i++) {
|
|
4111
|
+
const entry = matrixEntries[i];
|
|
4112
|
+
const label = entry.label ?? `entry-${i + 1}`;
|
|
4113
|
+
const entrySummary = {
|
|
4114
|
+
index: i,
|
|
4115
|
+
label,
|
|
4116
|
+
settings: entry
|
|
4117
|
+
};
|
|
4118
|
+
entrySummaries.push(entrySummary);
|
|
4119
|
+
try {
|
|
4120
|
+
const { label: _label, ...settings } = entry;
|
|
4121
|
+
const applied = await this._executeDeviceSimulatorOperation(resolved.instanceId, resolved.role, "set", { settings });
|
|
4122
|
+
entrySummary.applied = applied;
|
|
4123
|
+
if (settleMs > 0)
|
|
4124
|
+
await sleep(settleMs);
|
|
4125
|
+
const capture = await this._captureViewportImage(resolved.instanceId, resolved.role, format, quality);
|
|
4126
|
+
if (capture.success) {
|
|
4127
|
+
entrySummary.screenshot = {
|
|
4128
|
+
width: capture.width,
|
|
4129
|
+
height: capture.height,
|
|
4130
|
+
format: capture.format,
|
|
4131
|
+
quality: capture.quality,
|
|
4132
|
+
mimeType: capture.mimeType
|
|
4133
|
+
};
|
|
4134
|
+
content.push({
|
|
4135
|
+
type: "text",
|
|
4136
|
+
text: `capture_device_matrix ${i + 1}/${matrixEntries.length} ${label}: ${capture.message}`
|
|
4137
|
+
});
|
|
4138
|
+
content.push({
|
|
4139
|
+
type: "image",
|
|
4140
|
+
data: capture.data,
|
|
4141
|
+
mimeType: capture.mimeType
|
|
4142
|
+
});
|
|
4143
|
+
} else {
|
|
4144
|
+
entrySummary.error = capture.error;
|
|
4145
|
+
failures.push(`${label}: ${capture.error}`);
|
|
4146
|
+
content.push({
|
|
4147
|
+
type: "text",
|
|
4148
|
+
text: `capture_device_matrix ${i + 1}/${matrixEntries.length} ${label}: ${capture.error}`
|
|
4149
|
+
});
|
|
4150
|
+
}
|
|
4151
|
+
} catch (error) {
|
|
4152
|
+
const message = errorMessage(error);
|
|
4153
|
+
entrySummary.error = message;
|
|
4154
|
+
failures.push(`${label}: ${message}`);
|
|
4155
|
+
content.push({
|
|
4156
|
+
type: "text",
|
|
4157
|
+
text: `capture_device_matrix ${i + 1}/${matrixEntries.length} ${label}: ${message}`
|
|
4158
|
+
});
|
|
4159
|
+
}
|
|
4160
|
+
}
|
|
4161
|
+
} finally {
|
|
4162
|
+
if (shouldRestore) {
|
|
4163
|
+
try {
|
|
4164
|
+
const restoreSettings = this._settingsFromDeviceSimulatorState(before);
|
|
4165
|
+
if ("stopSimulation" in restoreSettings) {
|
|
4166
|
+
summary.restore = await this._executeDeviceSimulatorOperation(resolved.instanceId, resolved.role, "set", { stopSimulation: true });
|
|
4167
|
+
} else {
|
|
4168
|
+
summary.restore = await this._executeDeviceSimulatorOperation(resolved.instanceId, resolved.role, "set", { settings: restoreSettings });
|
|
4169
|
+
}
|
|
4170
|
+
} catch (error) {
|
|
4171
|
+
const message = errorMessage(error);
|
|
4172
|
+
summary.restoreError = message;
|
|
4173
|
+
failures.push(`restore: ${message}`);
|
|
4174
|
+
}
|
|
4175
|
+
}
|
|
4176
|
+
}
|
|
4177
|
+
if (failures.length > 0) {
|
|
4178
|
+
throw new Error(`capture_device_matrix failed for ${failures.join("; ")}. Partial result: ${JSON.stringify(summary)}`);
|
|
4179
|
+
}
|
|
4180
|
+
return {
|
|
4181
|
+
content: [
|
|
4182
|
+
{
|
|
4183
|
+
type: "text",
|
|
4184
|
+
text: JSON.stringify(summary)
|
|
4185
|
+
},
|
|
4186
|
+
...content
|
|
4187
|
+
]
|
|
4188
|
+
};
|
|
4189
|
+
}
|
|
3519
4190
|
async getRuntimeLogs(target, since, tail, filter, instance_id) {
|
|
3520
4191
|
const tgt = target ?? "all";
|
|
3521
4192
|
const data = {};
|
|
@@ -3529,11 +4200,19 @@ ${code}`
|
|
|
3529
4200
|
if (!resolved.ok)
|
|
3530
4201
|
throw new RoutingFailure(resolved.error);
|
|
3531
4202
|
if (resolved.mode === "single") {
|
|
4203
|
+
const originPeerReliable2 = await this._isMultiplayerTestRunning(resolved.targetInstanceId);
|
|
3532
4204
|
const response = await this.client.request("/api/get-runtime-logs", data, resolved.targetInstanceId, resolved.targetRole);
|
|
3533
|
-
response.
|
|
4205
|
+
response.capturedBy = resolved.targetRole;
|
|
4206
|
+
delete response.peer;
|
|
4207
|
+
response.originPeerReliable = originPeerReliable2;
|
|
4208
|
+
response.peerAttribution = originPeerReliable2 ? "guaranteed_multiplayer" : "unavailable_shared_logservice";
|
|
4209
|
+
if (originPeerReliable2)
|
|
4210
|
+
response.peer = resolved.targetRole;
|
|
3534
4211
|
if (Array.isArray(response.entries)) {
|
|
3535
4212
|
for (const e of response.entries) {
|
|
3536
|
-
|
|
4213
|
+
e.capturedBy = resolved.targetRole;
|
|
4214
|
+
delete e.peer;
|
|
4215
|
+
if (originPeerReliable2)
|
|
3537
4216
|
e.peer = resolved.targetRole;
|
|
3538
4217
|
}
|
|
3539
4218
|
}
|
|
@@ -3542,35 +4221,38 @@ ${code}`
|
|
|
3542
4221
|
};
|
|
3543
4222
|
}
|
|
3544
4223
|
const targets = resolved.targets.filter((t) => t.targetRole !== "edit-proxy");
|
|
4224
|
+
const originPeerReliable = targets.length > 0 ? await this._isMultiplayerTestRunning(targets[0].targetInstanceId) : false;
|
|
3545
4225
|
const responses = await Promise.allSettled(targets.map(async (t) => {
|
|
3546
4226
|
const r = await this.client.request("/api/get-runtime-logs", data, t.targetInstanceId, t.targetRole);
|
|
3547
|
-
return { ...r,
|
|
4227
|
+
return { ...r, capturedBy: t.targetRole };
|
|
3548
4228
|
}));
|
|
3549
4229
|
const merged = [];
|
|
3550
|
-
const
|
|
3551
|
-
const
|
|
4230
|
+
const perCaptureNextSince = {};
|
|
4231
|
+
const perCaptureErrors = {};
|
|
3552
4232
|
let totalDropped = 0;
|
|
3553
4233
|
for (const r of responses) {
|
|
3554
4234
|
if (r.status !== "fulfilled")
|
|
3555
4235
|
continue;
|
|
3556
4236
|
const v = r.value;
|
|
3557
|
-
const
|
|
4237
|
+
const capturedBy = v.capturedBy ?? "unknown";
|
|
3558
4238
|
if (v.error) {
|
|
3559
|
-
|
|
4239
|
+
perCaptureErrors[capturedBy] = v.error;
|
|
3560
4240
|
continue;
|
|
3561
4241
|
}
|
|
3562
4242
|
if (v.nextSince !== void 0)
|
|
3563
|
-
|
|
4243
|
+
perCaptureNextSince[capturedBy] = v.nextSince;
|
|
3564
4244
|
totalDropped += v.totalDropped ?? 0;
|
|
3565
4245
|
for (const e of v.entries ?? []) {
|
|
3566
|
-
|
|
4246
|
+
const entry = { ...e };
|
|
4247
|
+
delete entry.peer;
|
|
4248
|
+
merged.push({ ...entry, capturedBy });
|
|
3567
4249
|
}
|
|
3568
4250
|
}
|
|
3569
4251
|
merged.sort((a, b) => a.ts !== b.ts ? a.ts - b.ts : a.seq - b.seq);
|
|
3570
4252
|
const DEDUP_WINDOW = 2;
|
|
3571
4253
|
const deduped = [];
|
|
3572
4254
|
for (const e of merged) {
|
|
3573
|
-
const isDup = deduped.some((d) => d.message === e.message && d.level === e.level && Math.abs(d.ts - e.ts) <= DEDUP_WINDOW && d.
|
|
4255
|
+
const isDup = deduped.some((d) => d.message === e.message && d.level === e.level && Math.abs(d.ts - e.ts) <= DEDUP_WINDOW && d.capturedBy !== e.capturedBy);
|
|
3574
4256
|
if (!isDup)
|
|
3575
4257
|
deduped.push(e);
|
|
3576
4258
|
}
|
|
@@ -3578,13 +4260,21 @@ ${code}`
|
|
|
3578
4260
|
if (tail !== void 0 && deduped.length > tail) {
|
|
3579
4261
|
final = deduped.slice(deduped.length - tail);
|
|
3580
4262
|
}
|
|
4263
|
+
const finalEntries = originPeerReliable ? final.map((e) => ({ ...e, peer: e.capturedBy })) : final;
|
|
3581
4264
|
const body = {
|
|
3582
|
-
entries:
|
|
4265
|
+
entries: finalEntries,
|
|
3583
4266
|
totalDropped,
|
|
3584
|
-
|
|
4267
|
+
perCaptureNextSince,
|
|
4268
|
+
originPeerReliable,
|
|
4269
|
+
peerAttribution: originPeerReliable ? "guaranteed_multiplayer" : "unavailable_shared_logservice"
|
|
3585
4270
|
};
|
|
3586
|
-
if (
|
|
3587
|
-
body.
|
|
4271
|
+
if (originPeerReliable) {
|
|
4272
|
+
body.perPeerNextSince = perCaptureNextSince;
|
|
4273
|
+
}
|
|
4274
|
+
if (Object.keys(perCaptureErrors).length > 0) {
|
|
4275
|
+
body.perCaptureErrors = perCaptureErrors;
|
|
4276
|
+
if (originPeerReliable)
|
|
4277
|
+
body.perPeerErrors = perCaptureErrors;
|
|
3588
4278
|
}
|
|
3589
4279
|
return {
|
|
3590
4280
|
content: [{ type: "text", text: JSON.stringify(body) }]
|
|
@@ -3708,15 +4398,19 @@ ${code}`
|
|
|
3708
4398
|
return false;
|
|
3709
4399
|
}
|
|
3710
4400
|
async _isMultiplayerTestRunning(instanceId) {
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
4401
|
+
const roles = this._rolesForInstance(instanceId);
|
|
4402
|
+
const hasServer = roles.includes("server");
|
|
4403
|
+
const clientCount = roles.filter((role) => role.startsWith("client-")).length;
|
|
4404
|
+
if (roles.includes("edit")) {
|
|
4405
|
+
try {
|
|
4406
|
+
const editState = await this.client.request("/api/multiplayer-test-state", {}, instanceId, "edit");
|
|
4407
|
+
const phase = editState?.session?.phase;
|
|
4408
|
+
if (phase === "starting" || phase === "running")
|
|
4409
|
+
return true;
|
|
4410
|
+
} catch {
|
|
4411
|
+
}
|
|
3719
4412
|
}
|
|
4413
|
+
return hasServer && clientCount >= 2;
|
|
3720
4414
|
}
|
|
3721
4415
|
async _waitForMultiplayerStart(instanceId, clientCount, timeoutSec = 30) {
|
|
3722
4416
|
const deadline = Date.now() + timeoutSec * 1e3;
|
|
@@ -4833,16 +5527,15 @@ ${code}`
|
|
|
4833
5527
|
}, tgt, instance_id);
|
|
4834
5528
|
return { content: [{ type: "text", text: JSON.stringify(response) }] };
|
|
4835
5529
|
}
|
|
4836
|
-
async
|
|
4837
|
-
const { instanceId, clientRole } = this._resolveRuntime(instance_id);
|
|
5530
|
+
async _captureViewportImage(instanceId, targetRole, format, quality) {
|
|
4838
5531
|
let response;
|
|
4839
|
-
if (
|
|
4840
|
-
const begin = await this._callSingle("/api/capture-begin", {},
|
|
5532
|
+
if (targetRole.startsWith("client-")) {
|
|
5533
|
+
const begin = await this._callSingle("/api/capture-begin", {}, targetRole, instanceId);
|
|
4841
5534
|
if (begin.error) {
|
|
4842
|
-
return {
|
|
5535
|
+
return { success: false, error: begin.error };
|
|
4843
5536
|
}
|
|
4844
5537
|
if (!begin.contentId) {
|
|
4845
|
-
return {
|
|
5538
|
+
return { success: false, error: "Screenshot capture failed: no content id returned from client." };
|
|
4846
5539
|
}
|
|
4847
5540
|
response = await this._callSingle("/api/capture-read", { contentId: begin.contentId }, "edit", instanceId);
|
|
4848
5541
|
} else {
|
|
@@ -4850,55 +5543,66 @@ ${code}`
|
|
|
4850
5543
|
}
|
|
4851
5544
|
if (response.error) {
|
|
4852
5545
|
let text = response.error;
|
|
4853
|
-
if (
|
|
5546
|
+
if (targetRole.startsWith("client-") && response.error.includes("Failed to load texture, unexpected format") && await this._isMultiplayerTestRunning(instanceId)) {
|
|
4854
5547
|
text = `Screenshot capture reached the multiplayer client, but Roblox returned a temporary screenshot texture that the edit peer cannot read in StudioTestService multiplayer sessions. Regular start_playtest capture works because the temporary rbxtemp:// handle is readable from the edit process; multiplayer client handles appear to be scoped to the client process. Raw error: ${response.error}`;
|
|
4855
5548
|
}
|
|
4856
|
-
return {
|
|
4857
|
-
content: [{
|
|
4858
|
-
type: "text",
|
|
4859
|
-
text
|
|
4860
|
-
}]
|
|
4861
|
-
};
|
|
5549
|
+
return { success: false, error: text };
|
|
4862
5550
|
}
|
|
4863
5551
|
const w = response.width;
|
|
4864
5552
|
const h = response.height;
|
|
4865
5553
|
if (w === void 0 || h === void 0) {
|
|
4866
|
-
return {
|
|
5554
|
+
return { success: false, error: "Screenshot response missing dimensions." };
|
|
4867
5555
|
}
|
|
4868
5556
|
const fmt = format === "png" ? "png" : "jpeg";
|
|
4869
5557
|
const q = quality === void 0 ? 92 : Math.max(1, Math.min(100, Math.floor(quality)));
|
|
4870
|
-
const MAX_IMAGE_BYTES = 6e6;
|
|
4871
5558
|
const encoded = encodeImageFromRgbaResponse(response, fmt, q);
|
|
4872
5559
|
let { buffer } = encoded;
|
|
4873
5560
|
const { mimeType } = encoded;
|
|
4874
5561
|
let usedQ = q;
|
|
4875
5562
|
let note = "";
|
|
4876
|
-
if (buffer.length >
|
|
5563
|
+
if (buffer.length > MAX_INLINE_IMAGE_BYTES) {
|
|
4877
5564
|
if (fmt === "png") {
|
|
4878
5565
|
const mb = (buffer.length / 1048576).toFixed(1);
|
|
4879
5566
|
return {
|
|
4880
|
-
|
|
4881
|
-
|
|
4882
|
-
text: `PNG screenshot is ${mb}MB, over the ~${(MAX_IMAGE_BYTES / 1048576).toFixed(0)}MB inline image limit. Use the default jpeg format (optionally with a "quality" value) or make the Studio window smaller for a lossless capture.`
|
|
4883
|
-
}]
|
|
5567
|
+
success: false,
|
|
5568
|
+
error: `PNG screenshot is ${mb}MB, over the ~${(MAX_INLINE_IMAGE_BYTES / 1048576).toFixed(0)}MB inline image limit. Use the default jpeg format (optionally with a "quality" value) or make the Studio window smaller for a lossless capture.`
|
|
4884
5569
|
};
|
|
4885
5570
|
}
|
|
4886
|
-
while (buffer.length >
|
|
5571
|
+
while (buffer.length > MAX_INLINE_IMAGE_BYTES && usedQ > 25) {
|
|
4887
5572
|
usedQ = Math.max(25, usedQ - 20);
|
|
4888
5573
|
buffer = encodeImageFromRgbaResponse(response, "jpeg", usedQ).buffer;
|
|
4889
5574
|
}
|
|
4890
5575
|
note = ` \u2014 auto-reduced to q${usedQ} to fit the inline size limit; enlarge the Studio window or capture a smaller region for finer detail`;
|
|
4891
5576
|
}
|
|
5577
|
+
const message = `Screenshot ${w}x${h}px (${fmt}${fmt === "jpeg" ? ` q${usedQ}` : ""})${note}. For simulate_mouse_input, x/y are pixel coordinates in this exact image with (0,0) at the top-left; it is not downscaled, so use coordinates as you read them off the image.`;
|
|
5578
|
+
return {
|
|
5579
|
+
success: true,
|
|
5580
|
+
width: w,
|
|
5581
|
+
height: h,
|
|
5582
|
+
format: fmt,
|
|
5583
|
+
quality: fmt === "jpeg" ? usedQ : void 0,
|
|
5584
|
+
note,
|
|
5585
|
+
data: buffer.toString("base64"),
|
|
5586
|
+
mimeType,
|
|
5587
|
+
message
|
|
5588
|
+
};
|
|
5589
|
+
}
|
|
5590
|
+
async captureScreenshot(instance_id, format, quality) {
|
|
5591
|
+
const { instanceId, clientRole } = this._resolveRuntime(instance_id);
|
|
5592
|
+
const capture = await this._captureViewportImage(instanceId, clientRole ?? "edit", format, quality);
|
|
5593
|
+
if (!capture.success) {
|
|
5594
|
+
return { content: [{ type: "text", text: capture.error }] };
|
|
5595
|
+
}
|
|
4892
5596
|
return {
|
|
4893
5597
|
content: [
|
|
4894
5598
|
{
|
|
4895
5599
|
type: "text",
|
|
4896
|
-
text:
|
|
5600
|
+
text: capture.message
|
|
4897
5601
|
},
|
|
4898
5602
|
{
|
|
4899
5603
|
type: "image",
|
|
4900
|
-
data:
|
|
4901
|
-
mimeType
|
|
5604
|
+
data: capture.data,
|
|
5605
|
+
mimeType: capture.mimeType
|
|
4902
5606
|
}
|
|
4903
5607
|
]
|
|
4904
5608
|
};
|
|
@@ -6060,7 +6764,7 @@ var init_definitions = __esm({
|
|
|
6060
6764
|
{
|
|
6061
6765
|
name: "eval_server_runtime",
|
|
6062
6766
|
category: "write",
|
|
6063
|
-
description: "Execute Luau on the server peer in the running game's Script VM (shares require cache with user game scripts, unlike execute_luau target=server which runs in plugin context). Requires a running playtest; the bridge is
|
|
6767
|
+
description: "Execute Luau on the server peer in the running game's Script VM (shares require cache with user game scripts, unlike execute_luau target=server which runs in plugin context). Requires a running playtest; the runtime bridge is created automatically inside the play DataModel, including for playtests started manually via the Studio Play button.",
|
|
6064
6768
|
inputSchema: {
|
|
6065
6769
|
type: "object",
|
|
6066
6770
|
properties: {
|
|
@@ -6079,7 +6783,7 @@ var init_definitions = __esm({
|
|
|
6079
6783
|
{
|
|
6080
6784
|
name: "eval_client_runtime",
|
|
6081
6785
|
category: "write",
|
|
6082
|
-
description: "Execute Luau on a client peer in the running game's LocalScript VM (shares require cache with user game scripts, unlike execute_luau target=client-N which runs in plugin context). Requires a running playtest; the bridge is
|
|
6786
|
+
description: "Execute Luau on a client peer in the running game's LocalScript VM (shares require cache with user game scripts, unlike execute_luau target=client-N which runs in plugin context). Requires a running playtest; the runtime bridge is created automatically inside the play DataModel, including for playtests started manually via the Studio Play button.",
|
|
6083
6787
|
inputSchema: {
|
|
6084
6788
|
type: "object",
|
|
6085
6789
|
properties: {
|
|
@@ -6209,6 +6913,280 @@ var init_definitions = __esm({
|
|
|
6209
6913
|
}
|
|
6210
6914
|
}
|
|
6211
6915
|
},
|
|
6916
|
+
{
|
|
6917
|
+
name: "set_network_profile",
|
|
6918
|
+
category: "write",
|
|
6919
|
+
description: `Apply simulated network conditions to active playtest client peers via NetworkSettings in plugin context. Requires a running playtest and targets only client peers: pass target="client-1", "client-2", etc., or target="all-clients". Presets: great = 30ms total latency (15ms in / 15ms out), 0ms jitter, 0% packet loss; good = 100ms total latency (50ms in / 50ms out), 10ms jitter, 0% packet loss; poor = 300ms (150ms in / 150ms out), 100ms jitter, 0.5% packet loss. profile="custom" applies only the numeric overrides provided; packet loss values above Roblox's 0.5% engine limit are rejected.`,
|
|
6920
|
+
inputSchema: {
|
|
6921
|
+
type: "object",
|
|
6922
|
+
properties: {
|
|
6923
|
+
profile: {
|
|
6924
|
+
type: "string",
|
|
6925
|
+
enum: ["great", "good", "poor", "custom"],
|
|
6926
|
+
description: "Network condition preset. Presets set all six simulation fields; custom requires overrides."
|
|
6927
|
+
},
|
|
6928
|
+
target: {
|
|
6929
|
+
type: "string",
|
|
6930
|
+
description: 'Client target: "client-1" (default), "client-2", etc., or "all-clients" to apply to every connected playtest client.'
|
|
6931
|
+
},
|
|
6932
|
+
overrides: {
|
|
6933
|
+
type: "object",
|
|
6934
|
+
additionalProperties: false,
|
|
6935
|
+
properties: {
|
|
6936
|
+
InboundNetworkMinDelayMs: {
|
|
6937
|
+
type: "number",
|
|
6938
|
+
minimum: 0,
|
|
6939
|
+
description: "Server-to-client minimum latency in milliseconds."
|
|
6940
|
+
},
|
|
6941
|
+
OutboundNetworkMinDelayMs: {
|
|
6942
|
+
type: "number",
|
|
6943
|
+
minimum: 0,
|
|
6944
|
+
description: "Client-to-server minimum latency in milliseconds."
|
|
6945
|
+
},
|
|
6946
|
+
InboundNetworkJitterMs: {
|
|
6947
|
+
type: "number",
|
|
6948
|
+
minimum: 0,
|
|
6949
|
+
description: "Server-to-client latency jitter in milliseconds."
|
|
6950
|
+
},
|
|
6951
|
+
OutboundNetworkJitterMs: {
|
|
6952
|
+
type: "number",
|
|
6953
|
+
minimum: 0,
|
|
6954
|
+
description: "Client-to-server latency jitter in milliseconds."
|
|
6955
|
+
},
|
|
6956
|
+
InboundNetworkLossPercent: {
|
|
6957
|
+
type: "number",
|
|
6958
|
+
minimum: 0,
|
|
6959
|
+
maximum: 0.5,
|
|
6960
|
+
description: "Server-to-client packet loss percentage. Roblox engine limit is 0.5%; larger values are rejected."
|
|
6961
|
+
},
|
|
6962
|
+
OutboundNetworkLossPercent: {
|
|
6963
|
+
type: "number",
|
|
6964
|
+
minimum: 0,
|
|
6965
|
+
maximum: 0.5,
|
|
6966
|
+
description: "Client-to-server packet loss percentage. Roblox engine limit is 0.5%; larger values are rejected."
|
|
6967
|
+
}
|
|
6968
|
+
},
|
|
6969
|
+
description: "Optional exact NetworkSettings property overrides. For preset profiles, overrides replace preset fields. For custom, only these properties are applied."
|
|
6970
|
+
},
|
|
6971
|
+
instance_id: {
|
|
6972
|
+
type: "string",
|
|
6973
|
+
description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
6974
|
+
}
|
|
6975
|
+
},
|
|
6976
|
+
required: ["profile"]
|
|
6977
|
+
}
|
|
6978
|
+
},
|
|
6979
|
+
{
|
|
6980
|
+
name: "get_simulation_state",
|
|
6981
|
+
category: "read",
|
|
6982
|
+
description: 'Inspect current NetworkSettings and/or StudioDeviceSimulatorService state for edit and connected playtest clients only. Defaults to include="both" and target="edit-and-clients"; server peers are skipped. Use before diagnosing network or device-sensitive tests, especially because normal Play can write client simulator changes back to edit and StudioTestService clients can inherit stale device simulator state.',
|
|
6983
|
+
inputSchema: {
|
|
6984
|
+
type: "object",
|
|
6985
|
+
properties: {
|
|
6986
|
+
include: {
|
|
6987
|
+
type: "string",
|
|
6988
|
+
enum: ["network", "deviceSimulator", "both"],
|
|
6989
|
+
description: 'Simulation state to inspect: "network", "deviceSimulator", or "both" (default both).'
|
|
6990
|
+
},
|
|
6991
|
+
target: {
|
|
6992
|
+
type: "string",
|
|
6993
|
+
description: 'Simulation target scope: "edit-and-clients" (default), "edit", "all-clients", or a specific "client-N". Server peers are never included.'
|
|
6994
|
+
},
|
|
6995
|
+
instance_id: {
|
|
6996
|
+
type: "string",
|
|
6997
|
+
description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
6998
|
+
}
|
|
6999
|
+
}
|
|
7000
|
+
}
|
|
7001
|
+
},
|
|
7002
|
+
{
|
|
7003
|
+
name: "reset_simulation_state",
|
|
7004
|
+
category: "write",
|
|
7005
|
+
description: 'Reset reachable simulation state to a clean baseline for deterministic tests. Defaults to target="edit-and-clients" and resets both network and device simulator state. Network reset sets all six simulated NetworkSettings fields to 0; device reset calls StopSimulationAsync(). Call before tests, after starting Play or multiplayer, before stopping, and again on edit after stopping.',
|
|
7006
|
+
inputSchema: {
|
|
7007
|
+
type: "object",
|
|
7008
|
+
properties: {
|
|
7009
|
+
target: {
|
|
7010
|
+
type: "string",
|
|
7011
|
+
description: 'Simulation target scope: "edit-and-clients" (default), "edit", "all-clients", or a specific "client-N". Server peers are skipped.'
|
|
7012
|
+
},
|
|
7013
|
+
network: {
|
|
7014
|
+
type: "boolean",
|
|
7015
|
+
description: "Reset simulated NetworkSettings fields to 0 (default true)."
|
|
7016
|
+
},
|
|
7017
|
+
deviceSimulator: {
|
|
7018
|
+
type: "boolean",
|
|
7019
|
+
description: "Stop Studio device simulation with StopSimulationAsync() (default true)."
|
|
7020
|
+
},
|
|
7021
|
+
instance_id: {
|
|
7022
|
+
type: "string",
|
|
7023
|
+
description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
7024
|
+
}
|
|
7025
|
+
}
|
|
7026
|
+
}
|
|
7027
|
+
},
|
|
7028
|
+
{
|
|
7029
|
+
name: "get_device_simulator_state",
|
|
7030
|
+
category: "read",
|
|
7031
|
+
description: 'Inspect StudioDeviceSimulatorService state and supported built-in device presets. Defaults to target="edit"; also supports a regular playtest client target such as "client-1". Server targets are not supported. When no simulated device is active, active-only fields are omitted and isSimulating=false.',
|
|
7032
|
+
inputSchema: {
|
|
7033
|
+
type: "object",
|
|
7034
|
+
properties: {
|
|
7035
|
+
target: {
|
|
7036
|
+
type: "string",
|
|
7037
|
+
description: 'Device simulator target: "edit" (default) or a regular playtest client like "client-1". Server targets are rejected.'
|
|
7038
|
+
},
|
|
7039
|
+
deviceId: {
|
|
7040
|
+
type: "string",
|
|
7041
|
+
description: "Optional built-in device preset ID to inspect with GetDeviceInfoAsync."
|
|
7042
|
+
},
|
|
7043
|
+
includeDeviceList: {
|
|
7044
|
+
type: "boolean",
|
|
7045
|
+
description: "Include the built-in device preset list from GetDeviceListAsync (default true)."
|
|
7046
|
+
},
|
|
7047
|
+
instance_id: {
|
|
7048
|
+
type: "string",
|
|
7049
|
+
description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
7050
|
+
}
|
|
7051
|
+
}
|
|
7052
|
+
}
|
|
7053
|
+
},
|
|
7054
|
+
{
|
|
7055
|
+
name: "set_device_simulator",
|
|
7056
|
+
category: "write",
|
|
7057
|
+
description: 'Set or stop StudioDeviceSimulatorService using built-in device presets only. Defaults to target="edit"; supports "client-N" and "all-clients"; rejects server targets. Applies deviceId first, then orientation, resolution, pixelDensity, and scalingMode overrides.',
|
|
7058
|
+
inputSchema: {
|
|
7059
|
+
type: "object",
|
|
7060
|
+
properties: {
|
|
7061
|
+
target: {
|
|
7062
|
+
type: "string",
|
|
7063
|
+
description: 'Device simulator target: "edit" (default), "client-1", "client-2", etc., or "all-clients".'
|
|
7064
|
+
},
|
|
7065
|
+
deviceId: {
|
|
7066
|
+
type: "string",
|
|
7067
|
+
description: "Built-in device preset ID from get_device_simulator_state."
|
|
7068
|
+
},
|
|
7069
|
+
orientation: {
|
|
7070
|
+
type: "string",
|
|
7071
|
+
description: 'ScreenOrientation enum name, e.g. "LandscapeRight", "LandscapeLeft", "Portrait", or a full Enum.ScreenOrientation.* string.'
|
|
7072
|
+
},
|
|
7073
|
+
resolution: {
|
|
7074
|
+
type: "object",
|
|
7075
|
+
additionalProperties: false,
|
|
7076
|
+
properties: {
|
|
7077
|
+
width: {
|
|
7078
|
+
type: "number",
|
|
7079
|
+
description: "Viewport width in pixels."
|
|
7080
|
+
},
|
|
7081
|
+
height: {
|
|
7082
|
+
type: "number",
|
|
7083
|
+
description: "Viewport height in pixels."
|
|
7084
|
+
}
|
|
7085
|
+
},
|
|
7086
|
+
required: ["width", "height"],
|
|
7087
|
+
description: "Optional resolution override applied after the device preset."
|
|
7088
|
+
},
|
|
7089
|
+
pixelDensity: {
|
|
7090
|
+
type: "number",
|
|
7091
|
+
description: "Optional positive pixel density override applied after the device preset."
|
|
7092
|
+
},
|
|
7093
|
+
scalingMode: {
|
|
7094
|
+
type: "string",
|
|
7095
|
+
description: 'DeviceSimulatorScalingMode enum name, e.g. "ScaleToPhysicalSize", or a full Enum.DeviceSimulatorScalingMode.* string.'
|
|
7096
|
+
},
|
|
7097
|
+
stopSimulation: {
|
|
7098
|
+
type: "boolean",
|
|
7099
|
+
description: "Stop device simulation. When true, do not pass other simulator setters."
|
|
7100
|
+
},
|
|
7101
|
+
instance_id: {
|
|
7102
|
+
type: "string",
|
|
7103
|
+
description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
7104
|
+
}
|
|
7105
|
+
}
|
|
7106
|
+
}
|
|
7107
|
+
},
|
|
7108
|
+
{
|
|
7109
|
+
name: "capture_device_matrix",
|
|
7110
|
+
category: "write",
|
|
7111
|
+
description: 'Apply up to 6 ordered Studio device simulator settings, capture each viewport screenshot, and restore the previous simulator state by default when the prior state is default or a built-in preset. Custom device persistence is intentionally unsupported. Defaults to target="edit"; supports regular playtest client targets but not server or all-clients targets.',
|
|
7112
|
+
inputSchema: {
|
|
7113
|
+
type: "object",
|
|
7114
|
+
properties: {
|
|
7115
|
+
entries: {
|
|
7116
|
+
type: "array",
|
|
7117
|
+
maxItems: 6,
|
|
7118
|
+
description: "Ordered device capture entries. Each entry may set a deviceId and optional simulator overrides before capture.",
|
|
7119
|
+
items: {
|
|
7120
|
+
type: "object",
|
|
7121
|
+
additionalProperties: false,
|
|
7122
|
+
properties: {
|
|
7123
|
+
label: {
|
|
7124
|
+
type: "string",
|
|
7125
|
+
description: "Optional label included in the screenshot metadata."
|
|
7126
|
+
},
|
|
7127
|
+
deviceId: {
|
|
7128
|
+
type: "string",
|
|
7129
|
+
description: "Built-in device preset ID from get_device_simulator_state."
|
|
7130
|
+
},
|
|
7131
|
+
orientation: {
|
|
7132
|
+
type: "string",
|
|
7133
|
+
description: "ScreenOrientation enum name or full Enum.ScreenOrientation.* string."
|
|
7134
|
+
},
|
|
7135
|
+
resolution: {
|
|
7136
|
+
type: "object",
|
|
7137
|
+
additionalProperties: false,
|
|
7138
|
+
properties: {
|
|
7139
|
+
width: {
|
|
7140
|
+
type: "number",
|
|
7141
|
+
description: "Viewport width in pixels."
|
|
7142
|
+
},
|
|
7143
|
+
height: {
|
|
7144
|
+
type: "number",
|
|
7145
|
+
description: "Viewport height in pixels."
|
|
7146
|
+
}
|
|
7147
|
+
},
|
|
7148
|
+
required: ["width", "height"]
|
|
7149
|
+
},
|
|
7150
|
+
pixelDensity: {
|
|
7151
|
+
type: "number",
|
|
7152
|
+
description: "Optional positive pixel density override."
|
|
7153
|
+
},
|
|
7154
|
+
scalingMode: {
|
|
7155
|
+
type: "string",
|
|
7156
|
+
description: "DeviceSimulatorScalingMode enum name or full Enum.DeviceSimulatorScalingMode.* string."
|
|
7157
|
+
}
|
|
7158
|
+
}
|
|
7159
|
+
}
|
|
7160
|
+
},
|
|
7161
|
+
target: {
|
|
7162
|
+
type: "string",
|
|
7163
|
+
description: 'Device simulator target: "edit" (default) or a regular playtest client such as "client-1". all-clients and server targets are rejected.'
|
|
7164
|
+
},
|
|
7165
|
+
format: {
|
|
7166
|
+
type: "string",
|
|
7167
|
+
enum: ["jpeg", "png"],
|
|
7168
|
+
description: 'Screenshot image format. "jpeg" (default) is compact; "png" is lossless but may exceed inline size limits.'
|
|
7169
|
+
},
|
|
7170
|
+
quality: {
|
|
7171
|
+
type: "number",
|
|
7172
|
+
description: "JPEG quality 1-100 (default 92). Ignored for png."
|
|
7173
|
+
},
|
|
7174
|
+
settleSeconds: {
|
|
7175
|
+
type: "number",
|
|
7176
|
+
description: "Seconds to wait after applying each simulator entry before capturing (default 0.3)."
|
|
7177
|
+
},
|
|
7178
|
+
restoreAfter: {
|
|
7179
|
+
type: "boolean",
|
|
7180
|
+
description: "Restore the previous default or built-in preset simulator state after the matrix finishes (default true). Custom active devices are not preserved."
|
|
7181
|
+
},
|
|
7182
|
+
instance_id: {
|
|
7183
|
+
type: "string",
|
|
7184
|
+
description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
7185
|
+
}
|
|
7186
|
+
},
|
|
7187
|
+
required: ["entries"]
|
|
7188
|
+
}
|
|
7189
|
+
},
|
|
6212
7190
|
{
|
|
6213
7191
|
name: "multiplayer_test_start",
|
|
6214
7192
|
category: "write",
|
|
@@ -6318,17 +7296,17 @@ var init_definitions = __esm({
|
|
|
6318
7296
|
{
|
|
6319
7297
|
name: "get_runtime_logs",
|
|
6320
7298
|
category: "read",
|
|
6321
|
-
description: "Read the in-memory log
|
|
7299
|
+
description: "Read the in-memory log buffers captured by Studio plugin peers. Each buffer captures ~64 KB of recent LogService.MessageOut entries; oldest entries drop when over budget. Entries include capturedBy for the plugin buffer that observed the log. In ordinary Studio play/run sessions, LogService reflects logs across edit/server/client, so script-origin peer is not reliable and entries omit peer. In StudioTestService multiplayer sessions only, peer attribution is reliable and entries also include peer. target=all (default) merges buffers and dedups same-message-and-level entries captured within 2s across different buffers.",
|
|
6322
7300
|
inputSchema: {
|
|
6323
7301
|
type: "object",
|
|
6324
7302
|
properties: {
|
|
6325
7303
|
target: {
|
|
6326
7304
|
type: "string",
|
|
6327
|
-
description: '
|
|
7305
|
+
description: 'Capture buffer to read from: "edit", "server", "client-N", or "all" (default). "all" merges buffers and dedups cross-buffer reflections within a 2s window.'
|
|
6328
7306
|
},
|
|
6329
7307
|
since: {
|
|
6330
7308
|
type: "number",
|
|
6331
|
-
description: "Return only entries with seq > since. Pass back the previous response's nextSince (single
|
|
7309
|
+
description: "Return only entries with seq > since. Pass back the previous response's nextSince (single target) or perCaptureNextSince entry (target=all) for incremental polling."
|
|
6332
7310
|
},
|
|
6333
7311
|
tail: {
|
|
6334
7312
|
type: "number",
|
|
@@ -7351,7 +8329,7 @@ function handleVariantConflict({ pluginsFolder, otherAssetName, replace, log = c
|
|
|
7351
8329
|
warn(`
|
|
7352
8330
|
[install-plugin] WARNING: ${otherAssetName} is already present in ${pluginsFolder}.
|
|
7353
8331
|
Only one MCP plugin variant should be present. If both variants are in the Studio Plugins folder, Studio loads both and runtime routing can become unpredictable.
|
|
7354
|
-
|
|
8332
|
+
Delete ${otherAssetName} manually or use the default CLI installer behavior to replace it.
|
|
7355
8333
|
`);
|
|
7356
8334
|
}
|
|
7357
8335
|
var init_install_plugin_helpers = __esm({
|
|
@@ -7473,6 +8451,27 @@ function bundledAssetPath() {
|
|
|
7473
8451
|
];
|
|
7474
8452
|
return candidates.find((candidate) => existsSync3(candidate)) ?? null;
|
|
7475
8453
|
}
|
|
8454
|
+
function packageVersion() {
|
|
8455
|
+
const currentDir = dirname2(fileURLToPath(import.meta.url));
|
|
8456
|
+
const pkg = JSON.parse(readFileSync3(join3(currentDir, "..", "package.json"), "utf8"));
|
|
8457
|
+
if (!pkg.version) {
|
|
8458
|
+
throw new Error("Package version not found");
|
|
8459
|
+
}
|
|
8460
|
+
return pkg.version;
|
|
8461
|
+
}
|
|
8462
|
+
function bundledPluginVersion(source) {
|
|
8463
|
+
const match = readFileSync3(source, "utf8").match(/local CURRENT_VERSION = "([^"]+)"/);
|
|
8464
|
+
return match ? match[1] : null;
|
|
8465
|
+
}
|
|
8466
|
+
function assertBundledPluginVersion(source) {
|
|
8467
|
+
const expected = packageVersion();
|
|
8468
|
+
const actual = bundledPluginVersion(source);
|
|
8469
|
+
if (actual !== expected) {
|
|
8470
|
+
throw new Error(
|
|
8471
|
+
`Bundled ${ASSET_NAME} version ${actual ?? "unknown"} does not match package version ${expected}. Run npm run build:plugin before starting with --auto-install-plugin.`
|
|
8472
|
+
);
|
|
8473
|
+
}
|
|
8474
|
+
}
|
|
7476
8475
|
function filesMatch(a, b) {
|
|
7477
8476
|
if (!existsSync3(b)) return false;
|
|
7478
8477
|
const aBytes = readFileSync3(a);
|
|
@@ -7482,11 +8481,12 @@ function filesMatch(a, b) {
|
|
|
7482
8481
|
async function installBundledPlugin(options = {}) {
|
|
7483
8482
|
const log = options.log ?? console.log;
|
|
7484
8483
|
const warn = options.warn ?? console.warn;
|
|
7485
|
-
const replaceVariant = options.replaceVariant ??
|
|
8484
|
+
const replaceVariant = options.replaceVariant ?? true;
|
|
7486
8485
|
const source = bundledAssetPath();
|
|
7487
8486
|
if (!source) {
|
|
7488
8487
|
throw new Error(`Bundled ${ASSET_NAME} not found in package`);
|
|
7489
8488
|
}
|
|
8489
|
+
assertBundledPluginVersion(source);
|
|
7490
8490
|
const pluginsFolder = prepareInstall({ replaceVariant, log, warn });
|
|
7491
8491
|
const dest = join3(pluginsFolder, ASSET_NAME);
|
|
7492
8492
|
if (filesMatch(source, dest)) return;
|
|
@@ -7495,10 +8495,22 @@ async function installBundledPlugin(options = {}) {
|
|
|
7495
8495
|
}
|
|
7496
8496
|
async function installPlugin(options = {}) {
|
|
7497
8497
|
const dev = options.dev ?? process.argv.includes("--dev");
|
|
7498
|
-
const replaceVariant = options.replaceVariant ??
|
|
8498
|
+
const replaceVariant = options.replaceVariant ?? true;
|
|
7499
8499
|
const log = options.log ?? console.log;
|
|
7500
8500
|
const warn = options.warn ?? console.warn;
|
|
7501
8501
|
const pluginsFolder = prepareInstall({ replaceVariant, log, warn });
|
|
8502
|
+
const bundled = bundledAssetPath();
|
|
8503
|
+
if (bundled) {
|
|
8504
|
+
assertBundledPluginVersion(bundled);
|
|
8505
|
+
const dest2 = join3(pluginsFolder, ASSET_NAME);
|
|
8506
|
+
if (filesMatch(bundled, dest2)) {
|
|
8507
|
+
log(`${ASSET_NAME} already installed.`);
|
|
8508
|
+
return;
|
|
8509
|
+
}
|
|
8510
|
+
copyFileSync(bundled, dest2);
|
|
8511
|
+
log(`Installed bundled ${ASSET_NAME} to ${dest2}`);
|
|
8512
|
+
return;
|
|
8513
|
+
}
|
|
7502
8514
|
log(dev ? "Fetching latest dev prerelease..." : "Fetching latest release...");
|
|
7503
8515
|
const release = dev ? await findDevRelease() : await fetchJson(`https://api.github.com/repos/${REPO}/releases/latest`);
|
|
7504
8516
|
const asset = release.assets?.find((a) => a.name === ASSET_NAME);
|
|
@@ -7528,7 +8540,7 @@ init_dist();
|
|
|
7528
8540
|
import { createRequire } from "module";
|
|
7529
8541
|
if (process.argv.includes("--install-plugin")) {
|
|
7530
8542
|
const { installPlugin: installPlugin2 } = await Promise.resolve().then(() => (init_install_plugin(), install_plugin_exports));
|
|
7531
|
-
installPlugin2().catch((err) => {
|
|
8543
|
+
await installPlugin2().catch((err) => {
|
|
7532
8544
|
console.error(err instanceof Error ? err.message : String(err));
|
|
7533
8545
|
process.exitCode = 1;
|
|
7534
8546
|
});
|
|
@@ -7536,7 +8548,6 @@ if (process.argv.includes("--install-plugin")) {
|
|
|
7536
8548
|
if (process.argv.includes("--auto-install-plugin")) {
|
|
7537
8549
|
const { installBundledPlugin: installBundledPlugin2 } = await Promise.resolve().then(() => (init_install_plugin(), install_plugin_exports));
|
|
7538
8550
|
await installBundledPlugin2({
|
|
7539
|
-
replaceVariant: process.argv.includes("--replace-variant"),
|
|
7540
8551
|
log: (message) => console.error(`[install-plugin] ${message}`),
|
|
7541
8552
|
warn: (message) => console.error(message)
|
|
7542
8553
|
}).catch((err) => {
|