@chrrxs/robloxstudio-mcp-inspector 2.15.2 → 2.16.1
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 +1288 -60
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +616 -236
- package/studio-plugin/MCPPlugin.rbxmx +616 -236
- package/studio-plugin/src/modules/ClientBroker.ts +32 -6
- package/studio-plugin/src/modules/Communication.ts +44 -34
- package/studio-plugin/src/modules/EvalBridges.ts +91 -64
- package/studio-plugin/src/modules/HttpDiagnostics.ts +50 -0
- package/studio-plugin/src/modules/ServerUrlSettings.ts +48 -0
- package/studio-plugin/src/modules/StopPlayMonitor.ts +152 -35
- package/studio-plugin/src/modules/handlers/EvalRuntimeHandlers.ts +34 -6
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +20 -46
- package/studio-plugin/src/server/index.server.ts +41 -11
package/dist/index.js
CHANGED
|
@@ -386,30 +386,60 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig) {
|
|
|
386
386
|
});
|
|
387
387
|
app.post("/ready", (req, res) => {
|
|
388
388
|
const { pluginSessionId, instanceId, role, placeId, placeName, dataModelName, isRunning, pluginVersion, pluginVariant } = req.body;
|
|
389
|
+
const requestContext = {
|
|
390
|
+
instanceId: typeof instanceId === "string" ? instanceId : void 0,
|
|
391
|
+
role: typeof role === "string" ? role : void 0,
|
|
392
|
+
placeId: typeof placeId === "number" ? placeId : void 0,
|
|
393
|
+
placeName: typeof placeName === "string" ? placeName : void 0,
|
|
394
|
+
dataModelName: typeof dataModelName === "string" ? dataModelName : void 0,
|
|
395
|
+
isRunning: typeof isRunning === "boolean" ? isRunning : void 0,
|
|
396
|
+
pluginVersion: typeof pluginVersion === "string" ? pluginVersion : void 0,
|
|
397
|
+
pluginVariant: typeof pluginVariant === "string" ? pluginVariant : void 0
|
|
398
|
+
};
|
|
389
399
|
if (!pluginSessionId || !instanceId || !role) {
|
|
400
|
+
const missingFields = [
|
|
401
|
+
!pluginSessionId ? "pluginSessionId" : void 0,
|
|
402
|
+
!instanceId ? "instanceId" : void 0,
|
|
403
|
+
!role ? "role" : void 0
|
|
404
|
+
].filter((field) => !!field);
|
|
390
405
|
res.status(400).json({
|
|
391
406
|
success: false,
|
|
392
|
-
error: "
|
|
407
|
+
error: "missing_ready_fields",
|
|
408
|
+
message: `/ready missing required field(s): ${missingFields.join(", ")}`,
|
|
409
|
+
missingFields,
|
|
410
|
+
request: requestContext
|
|
411
|
+
});
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
let result;
|
|
415
|
+
try {
|
|
416
|
+
result = bridge.registerInstance({
|
|
417
|
+
pluginSessionId,
|
|
418
|
+
instanceId,
|
|
419
|
+
role,
|
|
420
|
+
placeId: typeof placeId === "number" ? placeId : 0,
|
|
421
|
+
placeName: typeof placeName === "string" ? placeName : "",
|
|
422
|
+
dataModelName: typeof dataModelName === "string" ? dataModelName : "",
|
|
423
|
+
isRunning: !!isRunning,
|
|
424
|
+
pluginVersion: typeof pluginVersion === "string" ? pluginVersion : "",
|
|
425
|
+
pluginVariant: typeof pluginVariant === "string" ? pluginVariant : "unknown",
|
|
426
|
+
serverVersion: serverConfig?.version ?? ""
|
|
427
|
+
});
|
|
428
|
+
} catch (err) {
|
|
429
|
+
res.status(500).json({
|
|
430
|
+
success: false,
|
|
431
|
+
error: "ready_registration_exception",
|
|
432
|
+
message: err instanceof Error ? err.message : String(err),
|
|
433
|
+
request: requestContext
|
|
393
434
|
});
|
|
394
435
|
return;
|
|
395
436
|
}
|
|
396
|
-
const result = bridge.registerInstance({
|
|
397
|
-
pluginSessionId,
|
|
398
|
-
instanceId,
|
|
399
|
-
role,
|
|
400
|
-
placeId: typeof placeId === "number" ? placeId : 0,
|
|
401
|
-
placeName: typeof placeName === "string" ? placeName : "",
|
|
402
|
-
dataModelName: typeof dataModelName === "string" ? dataModelName : "",
|
|
403
|
-
isRunning: !!isRunning,
|
|
404
|
-
pluginVersion: typeof pluginVersion === "string" ? pluginVersion : "",
|
|
405
|
-
pluginVariant: typeof pluginVariant === "string" ? pluginVariant : "unknown",
|
|
406
|
-
serverVersion: serverConfig?.version ?? ""
|
|
407
|
-
});
|
|
408
437
|
if (!result.ok) {
|
|
409
438
|
res.status(409).json({
|
|
410
439
|
success: false,
|
|
411
440
|
error: result.error.code,
|
|
412
441
|
message: result.error.message,
|
|
442
|
+
request: requestContext,
|
|
413
443
|
existing: result.error.existing
|
|
414
444
|
});
|
|
415
445
|
return;
|
|
@@ -737,6 +767,12 @@ var init_http_server = __esm({
|
|
|
737
767
|
execute_luau: (tools, body) => tools.executeLuau(body.code, body.target, body.instance_id),
|
|
738
768
|
eval_server_runtime: (tools, body) => tools.evalServerRuntime(body.code, body.instance_id),
|
|
739
769
|
eval_client_runtime: (tools, body) => tools.evalClientRuntime(body.code, body.target, body.instance_id),
|
|
770
|
+
set_network_profile: (tools, body) => tools.setNetworkProfile(body.profile, body.target, body.overrides, body.instance_id),
|
|
771
|
+
get_simulation_state: (tools, body) => tools.getSimulationState(body.include, body.target, body.instance_id),
|
|
772
|
+
reset_simulation_state: (tools, body) => tools.resetSimulationState(body.target, body.network, body.deviceSimulator, body.instance_id),
|
|
773
|
+
get_device_simulator_state: (tools, body) => tools.getDeviceSimulatorState(body.target, body.deviceId, body.includeDeviceList, body.instance_id),
|
|
774
|
+
set_device_simulator: (tools, body) => tools.setDeviceSimulator(body.target, body.deviceId, body.orientation, body.resolution, body.pixelDensity, body.scalingMode, body.stopSimulation, body.instance_id),
|
|
775
|
+
capture_device_matrix: (tools, body) => tools.captureDeviceMatrix(body.entries, body.target, body.format, body.quality, body.settleSeconds, body.restoreAfter, body.instance_id),
|
|
740
776
|
start_playtest: (tools, body) => tools.startPlaytest(body.mode, body.numPlayers, body.instance_id),
|
|
741
777
|
stop_playtest: (tools, body) => tools.stopPlaytest(body.instance_id),
|
|
742
778
|
get_playtest_output: (tools, body) => tools.getPlaytestOutput(body.target, body.instance_id),
|
|
@@ -1261,21 +1297,21 @@ var init_opencloud_client = __esm({
|
|
|
1261
1297
|
clearTimeout(timeoutId);
|
|
1262
1298
|
if (!response.ok) {
|
|
1263
1299
|
const errorBody = await response.text();
|
|
1264
|
-
let
|
|
1300
|
+
let errorMessage2;
|
|
1265
1301
|
try {
|
|
1266
1302
|
const errorJson = JSON.parse(errorBody);
|
|
1267
|
-
|
|
1303
|
+
errorMessage2 = errorJson.detail || errorJson.message || errorBody;
|
|
1268
1304
|
} catch {
|
|
1269
|
-
|
|
1305
|
+
errorMessage2 = errorBody;
|
|
1270
1306
|
}
|
|
1271
1307
|
if (response.status === 401) {
|
|
1272
1308
|
throw new Error("Invalid or expired API key");
|
|
1273
1309
|
} else if (response.status === 403) {
|
|
1274
|
-
throw new Error(`API key lacks required permissions: ${
|
|
1310
|
+
throw new Error(`API key lacks required permissions: ${errorMessage2}`);
|
|
1275
1311
|
} else if (response.status === 429) {
|
|
1276
1312
|
throw new Error("Rate limit exceeded. Please try again later.");
|
|
1277
1313
|
} else {
|
|
1278
|
-
throw new Error(`Open Cloud API error (${response.status}): ${
|
|
1314
|
+
throw new Error(`Open Cloud API error (${response.status}): ${errorMessage2}`);
|
|
1279
1315
|
}
|
|
1280
1316
|
}
|
|
1281
1317
|
return await response.json();
|
|
@@ -1410,21 +1446,21 @@ var init_opencloud_client = __esm({
|
|
|
1410
1446
|
clearTimeout(timeoutId);
|
|
1411
1447
|
if (!response.ok) {
|
|
1412
1448
|
const errorBody = await response.text();
|
|
1413
|
-
let
|
|
1449
|
+
let errorMessage2;
|
|
1414
1450
|
try {
|
|
1415
1451
|
const errorJson = JSON.parse(errorBody);
|
|
1416
|
-
|
|
1452
|
+
errorMessage2 = errorJson.detail || errorJson.message || errorBody;
|
|
1417
1453
|
} catch {
|
|
1418
|
-
|
|
1454
|
+
errorMessage2 = errorBody;
|
|
1419
1455
|
}
|
|
1420
1456
|
if (response.status === 401) {
|
|
1421
1457
|
throw new Error("Invalid or expired API key");
|
|
1422
1458
|
} else if (response.status === 403) {
|
|
1423
|
-
throw new Error(`API key lacks required permissions: ${
|
|
1459
|
+
throw new Error(`API key lacks required permissions: ${errorMessage2}`);
|
|
1424
1460
|
} else if (response.status === 429) {
|
|
1425
1461
|
throw new Error("Rate limit exceeded. Please try again later.");
|
|
1426
1462
|
} else {
|
|
1427
|
-
throw new Error(`Open Cloud API error (${response.status}): ${
|
|
1463
|
+
throw new Error(`Open Cloud API error (${response.status}): ${errorMessage2}`);
|
|
1428
1464
|
}
|
|
1429
1465
|
}
|
|
1430
1466
|
return await response.json();
|
|
@@ -2573,7 +2609,337 @@ function encodeImageFromRgbaResponse(response, format, quality) {
|
|
|
2573
2609
|
function sleep(ms) {
|
|
2574
2610
|
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
2575
2611
|
}
|
|
2576
|
-
|
|
2612
|
+
function errorMessage(error) {
|
|
2613
|
+
return error instanceof Error ? error.message : String(error);
|
|
2614
|
+
}
|
|
2615
|
+
function normalizeNetworkProfile(profile, overrides) {
|
|
2616
|
+
if (!["great", "good", "poor", "custom"].includes(profile)) {
|
|
2617
|
+
throw new Error('profile must be "great", "good", "poor", or "custom"');
|
|
2618
|
+
}
|
|
2619
|
+
const values = profile === "custom" ? {} : { ...NETWORK_PROFILES[profile] };
|
|
2620
|
+
if (overrides !== void 0) {
|
|
2621
|
+
if (typeof overrides !== "object" || overrides === null || Array.isArray(overrides)) {
|
|
2622
|
+
throw new Error("overrides must be an object when provided");
|
|
2623
|
+
}
|
|
2624
|
+
const allowed = new Set(NETWORK_PROFILE_KEYS);
|
|
2625
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
2626
|
+
if (!allowed.has(key)) {
|
|
2627
|
+
throw new Error(`Unsupported network override "${key}". Allowed: ${NETWORK_PROFILE_KEYS.join(", ")}`);
|
|
2628
|
+
}
|
|
2629
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
2630
|
+
throw new Error(`Network override "${key}" must be a finite number`);
|
|
2631
|
+
}
|
|
2632
|
+
if (value < 0) {
|
|
2633
|
+
throw new Error(`Network override "${key}" must be greater than or equal to 0`);
|
|
2634
|
+
}
|
|
2635
|
+
if ((key === "InboundNetworkLossPercent" || key === "OutboundNetworkLossPercent") && value > MAX_NETWORK_PACKET_LOSS_PERCENT) {
|
|
2636
|
+
throw new Error(`Network override "${key}" cannot exceed ${MAX_NETWORK_PACKET_LOSS_PERCENT}; Roblox engine limits packet loss simulation to 0.5%.`);
|
|
2637
|
+
}
|
|
2638
|
+
values[key] = value;
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
if (Object.keys(values).length === 0) {
|
|
2642
|
+
throw new Error("custom profile requires at least one override");
|
|
2643
|
+
}
|
|
2644
|
+
return values;
|
|
2645
|
+
}
|
|
2646
|
+
function buildNetworkProfileLuau(profile, values) {
|
|
2647
|
+
const valuesJson = JSON.stringify(values);
|
|
2648
|
+
const keysJson = JSON.stringify(NETWORK_PROFILE_KEYS);
|
|
2649
|
+
return `
|
|
2650
|
+
local HttpService = game:GetService("HttpService")
|
|
2651
|
+
local ns = settings():GetService("NetworkSettings")
|
|
2652
|
+
local keys = HttpService:JSONDecode(${JSON.stringify(keysJson)})
|
|
2653
|
+
local desired = HttpService:JSONDecode(${JSON.stringify(valuesJson)})
|
|
2654
|
+
local before = {}
|
|
2655
|
+
for _, key in ipairs(keys) do
|
|
2656
|
+
before[key] = ns[key]
|
|
2657
|
+
end
|
|
2658
|
+
for key, value in pairs(desired) do
|
|
2659
|
+
ns[key] = value
|
|
2660
|
+
end
|
|
2661
|
+
local after = {}
|
|
2662
|
+
for _, key in ipairs(keys) do
|
|
2663
|
+
after[key] = ns[key]
|
|
2664
|
+
end
|
|
2665
|
+
return HttpService:JSONEncode({
|
|
2666
|
+
profile = ${JSON.stringify(profile)},
|
|
2667
|
+
applied = desired,
|
|
2668
|
+
before = before,
|
|
2669
|
+
after = after,
|
|
2670
|
+
})
|
|
2671
|
+
`.trim();
|
|
2672
|
+
}
|
|
2673
|
+
function buildNetworkStateLuau(operation) {
|
|
2674
|
+
const keysJson = JSON.stringify(NETWORK_PROFILE_KEYS);
|
|
2675
|
+
const resetJson = JSON.stringify(ZERO_NETWORK_PROFILE);
|
|
2676
|
+
return `
|
|
2677
|
+
local HttpService = game:GetService("HttpService")
|
|
2678
|
+
local ns = settings():GetService("NetworkSettings")
|
|
2679
|
+
local operation = ${JSON.stringify(operation)}
|
|
2680
|
+
local keys = HttpService:JSONDecode(${JSON.stringify(keysJson)})
|
|
2681
|
+
local resetValues = HttpService:JSONDecode(${JSON.stringify(resetJson)})
|
|
2682
|
+
|
|
2683
|
+
local function readState()
|
|
2684
|
+
local state = {}
|
|
2685
|
+
for _, key in ipairs(keys) do
|
|
2686
|
+
state[key] = ns[key]
|
|
2687
|
+
end
|
|
2688
|
+
return state
|
|
2689
|
+
end
|
|
2690
|
+
|
|
2691
|
+
if operation == "get" then
|
|
2692
|
+
return HttpService:JSONEncode({
|
|
2693
|
+
success = true,
|
|
2694
|
+
state = readState(),
|
|
2695
|
+
})
|
|
2696
|
+
end
|
|
2697
|
+
|
|
2698
|
+
if operation == "reset" then
|
|
2699
|
+
local before = readState()
|
|
2700
|
+
for key, value in pairs(resetValues) do
|
|
2701
|
+
ns[key] = value
|
|
2702
|
+
end
|
|
2703
|
+
return HttpService:JSONEncode({
|
|
2704
|
+
success = true,
|
|
2705
|
+
applied = resetValues,
|
|
2706
|
+
before = before,
|
|
2707
|
+
after = readState(),
|
|
2708
|
+
})
|
|
2709
|
+
end
|
|
2710
|
+
|
|
2711
|
+
error("Unsupported network simulation operation: " .. tostring(operation), 0)
|
|
2712
|
+
`.trim();
|
|
2713
|
+
}
|
|
2714
|
+
function normalizeDeviceSimulatorResolution(value) {
|
|
2715
|
+
if (value === void 0)
|
|
2716
|
+
return void 0;
|
|
2717
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
2718
|
+
throw new Error("resolution must be an object with positive integer width and height");
|
|
2719
|
+
}
|
|
2720
|
+
const resolution = value;
|
|
2721
|
+
const width = resolution.width;
|
|
2722
|
+
const height = resolution.height;
|
|
2723
|
+
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
|
|
2724
|
+
throw new Error("resolution.width and resolution.height must be positive integers");
|
|
2725
|
+
}
|
|
2726
|
+
return { width, height };
|
|
2727
|
+
}
|
|
2728
|
+
function normalizeDeviceSimulatorSettings(input) {
|
|
2729
|
+
const settings = {};
|
|
2730
|
+
if (input.deviceId !== void 0) {
|
|
2731
|
+
if (typeof input.deviceId !== "string" || input.deviceId.trim() === "") {
|
|
2732
|
+
throw new Error("deviceId must be a non-empty string");
|
|
2733
|
+
}
|
|
2734
|
+
settings.deviceId = input.deviceId;
|
|
2735
|
+
}
|
|
2736
|
+
if (input.orientation !== void 0) {
|
|
2737
|
+
if (typeof input.orientation !== "string" || input.orientation.trim() === "") {
|
|
2738
|
+
throw new Error("orientation must be a non-empty string");
|
|
2739
|
+
}
|
|
2740
|
+
settings.orientation = input.orientation;
|
|
2741
|
+
}
|
|
2742
|
+
const resolution = normalizeDeviceSimulatorResolution(input.resolution);
|
|
2743
|
+
if (resolution !== void 0)
|
|
2744
|
+
settings.resolution = resolution;
|
|
2745
|
+
if (input.pixelDensity !== void 0) {
|
|
2746
|
+
if (typeof input.pixelDensity !== "number" || !Number.isFinite(input.pixelDensity) || input.pixelDensity <= 0) {
|
|
2747
|
+
throw new Error("pixelDensity must be a positive finite number");
|
|
2748
|
+
}
|
|
2749
|
+
settings.pixelDensity = input.pixelDensity;
|
|
2750
|
+
}
|
|
2751
|
+
if (input.scalingMode !== void 0) {
|
|
2752
|
+
if (typeof input.scalingMode !== "string" || input.scalingMode.trim() === "") {
|
|
2753
|
+
throw new Error("scalingMode must be a non-empty string");
|
|
2754
|
+
}
|
|
2755
|
+
settings.scalingMode = input.scalingMode;
|
|
2756
|
+
}
|
|
2757
|
+
return settings;
|
|
2758
|
+
}
|
|
2759
|
+
function hasDeviceSimulatorSettings(settings) {
|
|
2760
|
+
return settings.deviceId !== void 0 || settings.orientation !== void 0 || settings.resolution !== void 0 || settings.pixelDensity !== void 0 || settings.scalingMode !== void 0;
|
|
2761
|
+
}
|
|
2762
|
+
function buildDeviceSimulatorLuau(operation, options) {
|
|
2763
|
+
const payload = JSON.stringify({ operation, ...options });
|
|
2764
|
+
return `
|
|
2765
|
+
local HttpService = game:GetService("HttpService")
|
|
2766
|
+
local simulator = game:GetService("StudioDeviceSimulatorService")
|
|
2767
|
+
local opts = HttpService:JSONDecode(${JSON.stringify(payload)})
|
|
2768
|
+
|
|
2769
|
+
local function plain(value)
|
|
2770
|
+
local valueType = typeof(value)
|
|
2771
|
+
if valueType == "Vector2" then
|
|
2772
|
+
return { x = value.X, y = value.Y, width = value.X, height = value.Y }
|
|
2773
|
+
end
|
|
2774
|
+
if valueType == "EnumItem" then
|
|
2775
|
+
return value.Name
|
|
2776
|
+
end
|
|
2777
|
+
if type(value) == "table" then
|
|
2778
|
+
local out = {}
|
|
2779
|
+
for k, v in pairs(value) do
|
|
2780
|
+
out[tostring(k)] = plain(v)
|
|
2781
|
+
end
|
|
2782
|
+
return out
|
|
2783
|
+
end
|
|
2784
|
+
return value
|
|
2785
|
+
end
|
|
2786
|
+
|
|
2787
|
+
local function getDeviceInfo(deviceId)
|
|
2788
|
+
local ok, info = pcall(function()
|
|
2789
|
+
return simulator:GetDeviceInfoAsync(deviceId)
|
|
2790
|
+
end)
|
|
2791
|
+
if ok then
|
|
2792
|
+
return plain(info), nil
|
|
2793
|
+
end
|
|
2794
|
+
return nil, tostring(info)
|
|
2795
|
+
end
|
|
2796
|
+
|
|
2797
|
+
local function normalizeDeviceList(rawList)
|
|
2798
|
+
local devices = {}
|
|
2799
|
+
local ids = {}
|
|
2800
|
+
for _, entry in ipairs(rawList) do
|
|
2801
|
+
local item
|
|
2802
|
+
local id
|
|
2803
|
+
if type(entry) == "table" then
|
|
2804
|
+
item = plain(entry)
|
|
2805
|
+
id = item.DeviceId or item.deviceId or item.Id or item.id or item[1]
|
|
2806
|
+
else
|
|
2807
|
+
id = tostring(entry)
|
|
2808
|
+
item = { DeviceId = id }
|
|
2809
|
+
end
|
|
2810
|
+
if id ~= nil then
|
|
2811
|
+
id = tostring(id)
|
|
2812
|
+
local info = getDeviceInfo(id)
|
|
2813
|
+
if type(info) == "table" then
|
|
2814
|
+
item = info
|
|
2815
|
+
if item.DeviceId == nil then item.DeviceId = id end
|
|
2816
|
+
end
|
|
2817
|
+
if item.IsCustom ~= true then
|
|
2818
|
+
ids[id] = true
|
|
2819
|
+
table.insert(devices, item)
|
|
2820
|
+
end
|
|
2821
|
+
end
|
|
2822
|
+
end
|
|
2823
|
+
return devices, ids
|
|
2824
|
+
end
|
|
2825
|
+
|
|
2826
|
+
local function getDeviceList()
|
|
2827
|
+
local rawList = simulator:GetDeviceListAsync()
|
|
2828
|
+
return normalizeDeviceList(rawList)
|
|
2829
|
+
end
|
|
2830
|
+
|
|
2831
|
+
local function assertBuiltInDeviceExists(deviceId)
|
|
2832
|
+
local _, ids = getDeviceList()
|
|
2833
|
+
if ids[deviceId] then return end
|
|
2834
|
+
local available = {}
|
|
2835
|
+
for id in pairs(ids) do table.insert(available, id) end
|
|
2836
|
+
table.sort(available)
|
|
2837
|
+
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)
|
|
2838
|
+
end
|
|
2839
|
+
|
|
2840
|
+
local function enumByName(enumType, raw, label)
|
|
2841
|
+
local name = tostring(raw)
|
|
2842
|
+
name = string.match(name, "([^%.]+)$") or name
|
|
2843
|
+
local available = {}
|
|
2844
|
+
for _, item in ipairs(enumType:GetEnumItems()) do
|
|
2845
|
+
table.insert(available, item.Name)
|
|
2846
|
+
if item.Name == name then
|
|
2847
|
+
return item, item.Name
|
|
2848
|
+
end
|
|
2849
|
+
end
|
|
2850
|
+
error(label .. ' "' .. tostring(raw) .. '" is not valid. Available: ' .. table.concat(available, ", "), 0)
|
|
2851
|
+
end
|
|
2852
|
+
|
|
2853
|
+
local function tryActiveGetter(state, key, fn)
|
|
2854
|
+
local ok, value = pcall(fn)
|
|
2855
|
+
if ok then
|
|
2856
|
+
state[key] = plain(value)
|
|
2857
|
+
else
|
|
2858
|
+
state.unavailable = state.unavailable or {}
|
|
2859
|
+
state.unavailable[key] = tostring(value)
|
|
2860
|
+
end
|
|
2861
|
+
end
|
|
2862
|
+
|
|
2863
|
+
local function readState(includeDeviceList, requestedDeviceId)
|
|
2864
|
+
local activeDeviceId = tostring(simulator:GetDeviceAsync())
|
|
2865
|
+
local state = {
|
|
2866
|
+
activeDeviceId = activeDeviceId,
|
|
2867
|
+
isSimulating = activeDeviceId ~= "default",
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
if includeDeviceList then
|
|
2871
|
+
local devices = getDeviceList()
|
|
2872
|
+
state.devices = devices
|
|
2873
|
+
end
|
|
2874
|
+
|
|
2875
|
+
if requestedDeviceId ~= nil then
|
|
2876
|
+
assertBuiltInDeviceExists(requestedDeviceId)
|
|
2877
|
+
state.deviceInfo = plain(simulator:GetDeviceInfoAsync(requestedDeviceId))
|
|
2878
|
+
end
|
|
2879
|
+
|
|
2880
|
+
if state.isSimulating then
|
|
2881
|
+
tryActiveGetter(state, "resolution", function() return simulator:GetResolutionAsync() end)
|
|
2882
|
+
tryActiveGetter(state, "pixelDensity", function() return simulator:GetPixelDensityAsync() end)
|
|
2883
|
+
tryActiveGetter(state, "orientation", function() return simulator:GetOrientationAsync() end)
|
|
2884
|
+
tryActiveGetter(state, "scalingMode", function() return simulator:GetScalingModeAsync() end)
|
|
2885
|
+
end
|
|
2886
|
+
|
|
2887
|
+
return state
|
|
2888
|
+
end
|
|
2889
|
+
|
|
2890
|
+
local function applySettings(settings)
|
|
2891
|
+
local applied = {}
|
|
2892
|
+
if settings.deviceId ~= nil then
|
|
2893
|
+
assertBuiltInDeviceExists(settings.deviceId)
|
|
2894
|
+
simulator:SetDeviceAsync(settings.deviceId)
|
|
2895
|
+
applied.deviceId = settings.deviceId
|
|
2896
|
+
end
|
|
2897
|
+
if settings.orientation ~= nil then
|
|
2898
|
+
local item, name = enumByName(Enum.ScreenOrientation, settings.orientation, "orientation")
|
|
2899
|
+
simulator:SetOrientationAsync(item)
|
|
2900
|
+
applied.orientation = name
|
|
2901
|
+
end
|
|
2902
|
+
if settings.resolution ~= nil then
|
|
2903
|
+
simulator:SetResolutionAsync(settings.resolution.width, settings.resolution.height)
|
|
2904
|
+
applied.resolution = { width = settings.resolution.width, height = settings.resolution.height }
|
|
2905
|
+
end
|
|
2906
|
+
if settings.pixelDensity ~= nil then
|
|
2907
|
+
simulator:SetPixelDensityAsync(settings.pixelDensity)
|
|
2908
|
+
applied.pixelDensity = settings.pixelDensity
|
|
2909
|
+
end
|
|
2910
|
+
if settings.scalingMode ~= nil then
|
|
2911
|
+
local item, name = enumByName(Enum.DeviceSimulatorScalingMode, settings.scalingMode, "scalingMode")
|
|
2912
|
+
simulator:SetScalingModeAsync(item)
|
|
2913
|
+
applied.scalingMode = name
|
|
2914
|
+
end
|
|
2915
|
+
return applied
|
|
2916
|
+
end
|
|
2917
|
+
|
|
2918
|
+
if opts.operation == "get" then
|
|
2919
|
+
return readState(opts.includeDeviceList ~= false, opts.deviceId)
|
|
2920
|
+
end
|
|
2921
|
+
|
|
2922
|
+
if opts.operation == "set" then
|
|
2923
|
+
local before = readState(false, nil)
|
|
2924
|
+
local applied
|
|
2925
|
+
if opts.stopSimulation == true then
|
|
2926
|
+
simulator:StopSimulationAsync()
|
|
2927
|
+
applied = { stopSimulation = true }
|
|
2928
|
+
else
|
|
2929
|
+
applied = applySettings(opts.settings or {})
|
|
2930
|
+
end
|
|
2931
|
+
return {
|
|
2932
|
+
success = true,
|
|
2933
|
+
applied = applied,
|
|
2934
|
+
before = before,
|
|
2935
|
+
after = readState(false, nil),
|
|
2936
|
+
}
|
|
2937
|
+
end
|
|
2938
|
+
|
|
2939
|
+
error("Unsupported device simulator operation: " .. tostring(opts.operation), 0)
|
|
2940
|
+
`.trim();
|
|
2941
|
+
}
|
|
2942
|
+
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;
|
|
2577
2943
|
var init_tools = __esm({
|
|
2578
2944
|
"../core/dist/tools/index.js"() {
|
|
2579
2945
|
"use strict";
|
|
@@ -2584,6 +2950,56 @@ var init_tools = __esm({
|
|
|
2584
2950
|
init_roblox_cookie_client();
|
|
2585
2951
|
init_jpeg_encoder();
|
|
2586
2952
|
init_png_encoder();
|
|
2953
|
+
MAX_INLINE_IMAGE_BYTES = 6e6;
|
|
2954
|
+
MAX_DEVICE_MATRIX_ENTRIES = 6;
|
|
2955
|
+
MAX_NETWORK_PACKET_LOSS_PERCENT = 0.5;
|
|
2956
|
+
NETWORK_PROFILE_KEYS = [
|
|
2957
|
+
"InboundNetworkMinDelayMs",
|
|
2958
|
+
"OutboundNetworkMinDelayMs",
|
|
2959
|
+
"InboundNetworkJitterMs",
|
|
2960
|
+
"OutboundNetworkJitterMs",
|
|
2961
|
+
"InboundNetworkLossPercent",
|
|
2962
|
+
"OutboundNetworkLossPercent"
|
|
2963
|
+
];
|
|
2964
|
+
NETWORK_PROFILES = {
|
|
2965
|
+
great: {
|
|
2966
|
+
InboundNetworkMinDelayMs: 15,
|
|
2967
|
+
OutboundNetworkMinDelayMs: 15,
|
|
2968
|
+
InboundNetworkJitterMs: 0,
|
|
2969
|
+
OutboundNetworkJitterMs: 0,
|
|
2970
|
+
InboundNetworkLossPercent: 0,
|
|
2971
|
+
OutboundNetworkLossPercent: 0
|
|
2972
|
+
},
|
|
2973
|
+
good: {
|
|
2974
|
+
InboundNetworkMinDelayMs: 50,
|
|
2975
|
+
OutboundNetworkMinDelayMs: 50,
|
|
2976
|
+
InboundNetworkJitterMs: 10,
|
|
2977
|
+
OutboundNetworkJitterMs: 10,
|
|
2978
|
+
InboundNetworkLossPercent: 0,
|
|
2979
|
+
OutboundNetworkLossPercent: 0
|
|
2980
|
+
},
|
|
2981
|
+
poor: {
|
|
2982
|
+
InboundNetworkMinDelayMs: 150,
|
|
2983
|
+
OutboundNetworkMinDelayMs: 150,
|
|
2984
|
+
InboundNetworkJitterMs: 100,
|
|
2985
|
+
OutboundNetworkJitterMs: 100,
|
|
2986
|
+
InboundNetworkLossPercent: 0.5,
|
|
2987
|
+
OutboundNetworkLossPercent: 0.5
|
|
2988
|
+
}
|
|
2989
|
+
};
|
|
2990
|
+
ZERO_NETWORK_PROFILE = {
|
|
2991
|
+
InboundNetworkMinDelayMs: 0,
|
|
2992
|
+
OutboundNetworkMinDelayMs: 0,
|
|
2993
|
+
InboundNetworkJitterMs: 0,
|
|
2994
|
+
OutboundNetworkJitterMs: 0,
|
|
2995
|
+
InboundNetworkLossPercent: 0,
|
|
2996
|
+
OutboundNetworkLossPercent: 0
|
|
2997
|
+
};
|
|
2998
|
+
SIMULATION_PERSISTENCE_NOTES = [
|
|
2999
|
+
"Normal Play client changes can write back to edit state.",
|
|
3000
|
+
"Multiplayer clients inherit baseline at startup but are isolated afterward.",
|
|
3001
|
+
"StudioTestService client device simulator state may appear stale on fresh clients, so reset after client startup is required."
|
|
3002
|
+
];
|
|
2587
3003
|
RobloxStudioTools = class _RobloxStudioTools {
|
|
2588
3004
|
client;
|
|
2589
3005
|
bridge;
|
|
@@ -2683,6 +3099,160 @@ var init_tools = __esm({
|
|
|
2683
3099
|
_clientRolesForInstance(instanceId) {
|
|
2684
3100
|
return this._rolesForInstance(instanceId).filter((role) => /^client-\d+$/.test(role)).sort((a, b) => Number(a.slice("client-".length)) - Number(b.slice("client-".length)));
|
|
2685
3101
|
}
|
|
3102
|
+
_resolveDeviceSimulatorSingleTarget(target, instance_id, toolName) {
|
|
3103
|
+
const selectedTarget = target ?? "edit";
|
|
3104
|
+
if (selectedTarget === "server" || selectedTarget === "all" || selectedTarget === "all-clients" || selectedTarget === "edit-proxy") {
|
|
3105
|
+
throw new Error(`${toolName} target must be "edit" or "client-N" (got: ${selectedTarget})`);
|
|
3106
|
+
}
|
|
3107
|
+
if (selectedTarget !== "edit" && !/^client-\d+$/.test(selectedTarget)) {
|
|
3108
|
+
throw new Error(`${toolName} target must be "edit" or "client-N" (got: ${selectedTarget})`);
|
|
3109
|
+
}
|
|
3110
|
+
const resolved = this._resolveSingleTarget(selectedTarget, instance_id);
|
|
3111
|
+
return { ...resolved, selectedTarget };
|
|
3112
|
+
}
|
|
3113
|
+
_resolveDeviceSimulatorSetTargets(target, instance_id) {
|
|
3114
|
+
const selectedTarget = target ?? "edit";
|
|
3115
|
+
if (selectedTarget === "all-clients") {
|
|
3116
|
+
const instanceId = this._resolveInstanceIdOnly(instance_id);
|
|
3117
|
+
const roles = this._clientRolesForInstance(instanceId);
|
|
3118
|
+
if (roles.length === 0) {
|
|
3119
|
+
throw new RoutingFailure({
|
|
3120
|
+
code: "target_role_not_present_on_instance",
|
|
3121
|
+
message: `instance "${instanceId}" has no connected playtest client roles. Start a playtest first.`,
|
|
3122
|
+
data: {
|
|
3123
|
+
instances: this.bridge.getPublicInstances(),
|
|
3124
|
+
count: this.bridge.getInstances().length
|
|
3125
|
+
}
|
|
3126
|
+
});
|
|
3127
|
+
}
|
|
3128
|
+
return { instanceId, selectedTarget, roles };
|
|
3129
|
+
}
|
|
3130
|
+
const resolved = this._resolveDeviceSimulatorSingleTarget(selectedTarget, instance_id, "set_device_simulator");
|
|
3131
|
+
return { instanceId: resolved.instanceId, selectedTarget, roles: [resolved.role] };
|
|
3132
|
+
}
|
|
3133
|
+
_normalizeSimulationInclude(include) {
|
|
3134
|
+
const selectedInclude = include ?? "both";
|
|
3135
|
+
if (selectedInclude !== "network" && selectedInclude !== "deviceSimulator" && selectedInclude !== "both") {
|
|
3136
|
+
throw new Error(`get_simulation_state include must be "network", "deviceSimulator", or "both" (got: ${selectedInclude})`);
|
|
3137
|
+
}
|
|
3138
|
+
return selectedInclude;
|
|
3139
|
+
}
|
|
3140
|
+
_resolveSimulationTargets(target, instance_id, toolName) {
|
|
3141
|
+
const selectedTarget = target ?? "edit-and-clients";
|
|
3142
|
+
if (selectedTarget === "server" || selectedTarget === "all" || selectedTarget === "edit-proxy") {
|
|
3143
|
+
throw new Error(`${toolName} target must be "edit", "client-N", "all-clients", or "edit-and-clients" (got: ${selectedTarget})`);
|
|
3144
|
+
}
|
|
3145
|
+
const instanceId = this._resolveInstanceIdOnly(instance_id);
|
|
3146
|
+
const connectedRoles = this._rolesForInstance(instanceId);
|
|
3147
|
+
const clientRoles = this._clientRolesForInstance(instanceId);
|
|
3148
|
+
const warnings = [];
|
|
3149
|
+
let roles;
|
|
3150
|
+
if (selectedTarget === "edit") {
|
|
3151
|
+
if (!connectedRoles.includes("edit")) {
|
|
3152
|
+
throw new RoutingFailure({
|
|
3153
|
+
code: "target_role_not_present_on_instance",
|
|
3154
|
+
message: `instance "${instanceId}" has no role "edit". Available roles: ${connectedRoles.join(", ") || "none"}.`,
|
|
3155
|
+
data: {
|
|
3156
|
+
instances: this.bridge.getPublicInstances(),
|
|
3157
|
+
count: this.bridge.getInstances().length
|
|
3158
|
+
}
|
|
3159
|
+
});
|
|
3160
|
+
}
|
|
3161
|
+
roles = ["edit"];
|
|
3162
|
+
} else if (selectedTarget === "all-clients") {
|
|
3163
|
+
roles = clientRoles;
|
|
3164
|
+
if (roles.length === 0) {
|
|
3165
|
+
warnings.push(`No connected playtest client roles found for instance "${instanceId}".`);
|
|
3166
|
+
}
|
|
3167
|
+
} else if (selectedTarget === "edit-and-clients") {
|
|
3168
|
+
roles = [];
|
|
3169
|
+
if (connectedRoles.includes("edit")) {
|
|
3170
|
+
roles.push("edit");
|
|
3171
|
+
} else {
|
|
3172
|
+
warnings.push(`No edit role found for instance "${instanceId}".`);
|
|
3173
|
+
}
|
|
3174
|
+
roles.push(...clientRoles);
|
|
3175
|
+
} else if (/^client-\d+$/.test(selectedTarget)) {
|
|
3176
|
+
if (!clientRoles.includes(selectedTarget)) {
|
|
3177
|
+
throw new RoutingFailure({
|
|
3178
|
+
code: "target_role_not_present_on_instance",
|
|
3179
|
+
message: `instance "${instanceId}" has no role "${selectedTarget}". Available client roles: ${clientRoles.join(", ") || "none"}.`,
|
|
3180
|
+
data: {
|
|
3181
|
+
instances: this.bridge.getPublicInstances(),
|
|
3182
|
+
count: this.bridge.getInstances().length
|
|
3183
|
+
}
|
|
3184
|
+
});
|
|
3185
|
+
}
|
|
3186
|
+
roles = [selectedTarget];
|
|
3187
|
+
} else {
|
|
3188
|
+
throw new Error(`${toolName} target must be "edit", "client-N", "all-clients", or "edit-and-clients" (got: ${selectedTarget})`);
|
|
3189
|
+
}
|
|
3190
|
+
return { instanceId, selectedTarget, roles, warnings };
|
|
3191
|
+
}
|
|
3192
|
+
_parseExecuteLuauJsonResponse(response, toolName) {
|
|
3193
|
+
const r = response;
|
|
3194
|
+
if (r?.success === false) {
|
|
3195
|
+
throw new Error(r.error || r.message || `${toolName} Luau execution failed`);
|
|
3196
|
+
}
|
|
3197
|
+
if (typeof r?.returnValue !== "string") {
|
|
3198
|
+
return response;
|
|
3199
|
+
}
|
|
3200
|
+
if (r.returnValue === "") {
|
|
3201
|
+
return {};
|
|
3202
|
+
}
|
|
3203
|
+
try {
|
|
3204
|
+
return JSON.parse(r.returnValue);
|
|
3205
|
+
} catch {
|
|
3206
|
+
throw new Error(`${toolName} returned non-JSON data: ${r.returnValue}`);
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
3209
|
+
async _executeNetworkStateOperation(instanceId, role, operation) {
|
|
3210
|
+
const code = buildNetworkStateLuau(operation);
|
|
3211
|
+
const response = await this.client.request("/api/execute-luau", { code }, instanceId, role);
|
|
3212
|
+
return this._parseExecuteLuauJsonResponse(response, `network simulation ${operation}`);
|
|
3213
|
+
}
|
|
3214
|
+
async _executeDeviceSimulatorOperation(instanceId, role, operation, options) {
|
|
3215
|
+
const code = buildDeviceSimulatorLuau(operation, options);
|
|
3216
|
+
const response = await this.client.request("/api/execute-luau", { code }, instanceId, role);
|
|
3217
|
+
return this._parseExecuteLuauJsonResponse(response, `device simulator ${operation}`);
|
|
3218
|
+
}
|
|
3219
|
+
_settingsFromDeviceSimulatorState(state) {
|
|
3220
|
+
const s = state;
|
|
3221
|
+
if (!s || s.isSimulating !== true || typeof s.activeDeviceId !== "string" || s.activeDeviceId === "default") {
|
|
3222
|
+
return { stopSimulation: true };
|
|
3223
|
+
}
|
|
3224
|
+
return normalizeDeviceSimulatorSettings({
|
|
3225
|
+
deviceId: s.activeDeviceId,
|
|
3226
|
+
orientation: s.orientation,
|
|
3227
|
+
resolution: s.resolution,
|
|
3228
|
+
pixelDensity: s.pixelDensity,
|
|
3229
|
+
scalingMode: s.scalingMode
|
|
3230
|
+
});
|
|
3231
|
+
}
|
|
3232
|
+
_deviceSimulatorStateWithoutDeviceList(state) {
|
|
3233
|
+
if (typeof state !== "object" || state === null || Array.isArray(state)) {
|
|
3234
|
+
return state;
|
|
3235
|
+
}
|
|
3236
|
+
const { devices: _devices, ...rest } = state;
|
|
3237
|
+
return rest;
|
|
3238
|
+
}
|
|
3239
|
+
_assertCanRestoreDeviceSimulatorState(state) {
|
|
3240
|
+
const s = state;
|
|
3241
|
+
if (!s || s.isSimulating !== true || typeof s.activeDeviceId !== "string" || s.activeDeviceId === "default") {
|
|
3242
|
+
return;
|
|
3243
|
+
}
|
|
3244
|
+
const devices = Array.isArray(s.devices) ? s.devices : [];
|
|
3245
|
+
const isBuiltIn = devices.some((entry) => {
|
|
3246
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry))
|
|
3247
|
+
return false;
|
|
3248
|
+
const device = entry;
|
|
3249
|
+
const id = device.DeviceId ?? device.deviceId ?? device.Id ?? device.id;
|
|
3250
|
+
return id === s.activeDeviceId && device.IsCustom !== true;
|
|
3251
|
+
});
|
|
3252
|
+
if (!isBuiltIn) {
|
|
3253
|
+
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.`);
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
2686
3256
|
async _waitForRuntimeRoles(instanceId, opts, timeoutSec = 30) {
|
|
2687
3257
|
const deadline = Date.now() + timeoutSec * 1e3;
|
|
2688
3258
|
while (Date.now() < deadline) {
|
|
@@ -3288,6 +3858,365 @@ ${code}`
|
|
|
3288
3858
|
]
|
|
3289
3859
|
};
|
|
3290
3860
|
}
|
|
3861
|
+
async setNetworkProfile(profile, target, overrides, instance_id) {
|
|
3862
|
+
const values = normalizeNetworkProfile(profile, overrides);
|
|
3863
|
+
const instanceId = this._resolveInstanceIdOnly(instance_id);
|
|
3864
|
+
const clientRoles = this._clientRolesForInstance(instanceId);
|
|
3865
|
+
const selectedTarget = target ?? "client-1";
|
|
3866
|
+
let targetRoles;
|
|
3867
|
+
if (selectedTarget === "all-clients") {
|
|
3868
|
+
targetRoles = clientRoles;
|
|
3869
|
+
} else if (/^client-\d+$/.test(selectedTarget)) {
|
|
3870
|
+
if (!clientRoles.includes(selectedTarget)) {
|
|
3871
|
+
throw new RoutingFailure({
|
|
3872
|
+
code: "target_role_not_present_on_instance",
|
|
3873
|
+
message: `instance "${instanceId}" has no role "${selectedTarget}". Available client roles: ${clientRoles.join(", ") || "none"}.`,
|
|
3874
|
+
data: {
|
|
3875
|
+
instances: this.bridge.getPublicInstances(),
|
|
3876
|
+
count: this.bridge.getInstances().length
|
|
3877
|
+
}
|
|
3878
|
+
});
|
|
3879
|
+
}
|
|
3880
|
+
targetRoles = [selectedTarget];
|
|
3881
|
+
} else {
|
|
3882
|
+
throw new Error(`set_network_profile target must be "client-N" or "all-clients" (got: ${selectedTarget})`);
|
|
3883
|
+
}
|
|
3884
|
+
if (targetRoles.length === 0) {
|
|
3885
|
+
throw new RoutingFailure({
|
|
3886
|
+
code: "target_role_not_present_on_instance",
|
|
3887
|
+
message: `instance "${instanceId}" has no connected playtest client roles. Start a playtest first.`,
|
|
3888
|
+
data: {
|
|
3889
|
+
instances: this.bridge.getPublicInstances(),
|
|
3890
|
+
count: this.bridge.getInstances().length
|
|
3891
|
+
}
|
|
3892
|
+
});
|
|
3893
|
+
}
|
|
3894
|
+
const code = buildNetworkProfileLuau(profile, values);
|
|
3895
|
+
const responses = await Promise.allSettled(targetRoles.map(async (role) => {
|
|
3896
|
+
const response = await this.client.request("/api/execute-luau", { code }, instanceId, role);
|
|
3897
|
+
const result = this._parseExecuteLuauJsonResponse(response, "set_network_profile");
|
|
3898
|
+
return { role, result };
|
|
3899
|
+
}));
|
|
3900
|
+
const body = {
|
|
3901
|
+
profile,
|
|
3902
|
+
target: selectedTarget,
|
|
3903
|
+
applied: values,
|
|
3904
|
+
targets: {}
|
|
3905
|
+
};
|
|
3906
|
+
const targetResults = body.targets;
|
|
3907
|
+
const failures = [];
|
|
3908
|
+
for (let i = 0; i < responses.length; i++) {
|
|
3909
|
+
const role = targetRoles[i];
|
|
3910
|
+
const response = responses[i];
|
|
3911
|
+
if (response.status === "fulfilled") {
|
|
3912
|
+
targetResults[role] = response.value.result;
|
|
3913
|
+
} else {
|
|
3914
|
+
const message = errorMessage(response.reason);
|
|
3915
|
+
targetResults[role] = { error: message };
|
|
3916
|
+
failures.push(`${role}: ${message}`);
|
|
3917
|
+
}
|
|
3918
|
+
}
|
|
3919
|
+
if (failures.length > 0) {
|
|
3920
|
+
throw new Error(`set_network_profile failed for ${failures.join("; ")}. Partial result: ${JSON.stringify(body)}`);
|
|
3921
|
+
}
|
|
3922
|
+
return {
|
|
3923
|
+
content: [
|
|
3924
|
+
{
|
|
3925
|
+
type: "text",
|
|
3926
|
+
text: JSON.stringify(body)
|
|
3927
|
+
}
|
|
3928
|
+
]
|
|
3929
|
+
};
|
|
3930
|
+
}
|
|
3931
|
+
async getSimulationState(include, target, instance_id) {
|
|
3932
|
+
const selectedInclude = this._normalizeSimulationInclude(include);
|
|
3933
|
+
const includeNetwork = selectedInclude === "network" || selectedInclude === "both";
|
|
3934
|
+
const includeDeviceSimulator = selectedInclude === "deviceSimulator" || selectedInclude === "both";
|
|
3935
|
+
const resolved = this._resolveSimulationTargets(target, instance_id, "get_simulation_state");
|
|
3936
|
+
const roleEntries = await Promise.all(resolved.roles.map(async (role) => {
|
|
3937
|
+
const state = {};
|
|
3938
|
+
const errors = {};
|
|
3939
|
+
if (includeNetwork) {
|
|
3940
|
+
try {
|
|
3941
|
+
state.network = await this._executeNetworkStateOperation(resolved.instanceId, role, "get");
|
|
3942
|
+
} catch (error) {
|
|
3943
|
+
errors.network = errorMessage(error);
|
|
3944
|
+
}
|
|
3945
|
+
}
|
|
3946
|
+
if (includeDeviceSimulator) {
|
|
3947
|
+
try {
|
|
3948
|
+
state.deviceSimulator = await this._executeDeviceSimulatorOperation(resolved.instanceId, role, "get", { includeDeviceList: false });
|
|
3949
|
+
} catch (error) {
|
|
3950
|
+
errors.deviceSimulator = errorMessage(error);
|
|
3951
|
+
}
|
|
3952
|
+
}
|
|
3953
|
+
if (Object.keys(errors).length > 0) {
|
|
3954
|
+
state.errors = errors;
|
|
3955
|
+
}
|
|
3956
|
+
return { role, state };
|
|
3957
|
+
}));
|
|
3958
|
+
const roles = {};
|
|
3959
|
+
for (const entry of roleEntries) {
|
|
3960
|
+
roles[entry.role] = entry.state;
|
|
3961
|
+
}
|
|
3962
|
+
return {
|
|
3963
|
+
content: [{
|
|
3964
|
+
type: "text",
|
|
3965
|
+
text: JSON.stringify({
|
|
3966
|
+
include: selectedInclude,
|
|
3967
|
+
target: resolved.selectedTarget,
|
|
3968
|
+
roles,
|
|
3969
|
+
warnings: resolved.warnings,
|
|
3970
|
+
persistenceNotes: SIMULATION_PERSISTENCE_NOTES
|
|
3971
|
+
})
|
|
3972
|
+
}]
|
|
3973
|
+
};
|
|
3974
|
+
}
|
|
3975
|
+
async resetSimulationState(target, network, deviceSimulator, instance_id) {
|
|
3976
|
+
const resetNetwork = network !== false;
|
|
3977
|
+
const resetDeviceSimulator = deviceSimulator !== false;
|
|
3978
|
+
if (!resetNetwork && !resetDeviceSimulator) {
|
|
3979
|
+
throw new Error("reset_simulation_state requires network=true and/or deviceSimulator=true; both default to true");
|
|
3980
|
+
}
|
|
3981
|
+
const resolved = this._resolveSimulationTargets(target, instance_id, "reset_simulation_state");
|
|
3982
|
+
const roleEntries = await Promise.all(resolved.roles.map(async (role) => {
|
|
3983
|
+
const result = {};
|
|
3984
|
+
const errors = {};
|
|
3985
|
+
if (resetNetwork) {
|
|
3986
|
+
try {
|
|
3987
|
+
result.network = await this._executeNetworkStateOperation(resolved.instanceId, role, "reset");
|
|
3988
|
+
} catch (error) {
|
|
3989
|
+
errors.network = errorMessage(error);
|
|
3990
|
+
}
|
|
3991
|
+
}
|
|
3992
|
+
if (resetDeviceSimulator) {
|
|
3993
|
+
try {
|
|
3994
|
+
result.deviceSimulator = await this._executeDeviceSimulatorOperation(resolved.instanceId, role, "set", { stopSimulation: true });
|
|
3995
|
+
} catch (error) {
|
|
3996
|
+
errors.deviceSimulator = errorMessage(error);
|
|
3997
|
+
}
|
|
3998
|
+
}
|
|
3999
|
+
if (Object.keys(errors).length > 0) {
|
|
4000
|
+
result.errors = errors;
|
|
4001
|
+
}
|
|
4002
|
+
return { role, result };
|
|
4003
|
+
}));
|
|
4004
|
+
const roles = {};
|
|
4005
|
+
const failures = [];
|
|
4006
|
+
for (const entry of roleEntries) {
|
|
4007
|
+
roles[entry.role] = entry.result;
|
|
4008
|
+
const errors = entry.result.errors;
|
|
4009
|
+
if (errors) {
|
|
4010
|
+
for (const [kind, message] of Object.entries(errors)) {
|
|
4011
|
+
failures.push(`${entry.role}.${kind}: ${message}`);
|
|
4012
|
+
}
|
|
4013
|
+
}
|
|
4014
|
+
}
|
|
4015
|
+
const body = {
|
|
4016
|
+
target: resolved.selectedTarget,
|
|
4017
|
+
network: resetNetwork,
|
|
4018
|
+
deviceSimulator: resetDeviceSimulator,
|
|
4019
|
+
roles,
|
|
4020
|
+
warnings: resolved.warnings,
|
|
4021
|
+
persistenceNotes: SIMULATION_PERSISTENCE_NOTES
|
|
4022
|
+
};
|
|
4023
|
+
if (failures.length > 0) {
|
|
4024
|
+
throw new Error(`reset_simulation_state failed for ${failures.join("; ")}. Partial result: ${JSON.stringify(body)}`);
|
|
4025
|
+
}
|
|
4026
|
+
return {
|
|
4027
|
+
content: [{
|
|
4028
|
+
type: "text",
|
|
4029
|
+
text: JSON.stringify(body)
|
|
4030
|
+
}]
|
|
4031
|
+
};
|
|
4032
|
+
}
|
|
4033
|
+
async getDeviceSimulatorState(target, deviceId, includeDeviceList, instance_id) {
|
|
4034
|
+
if (deviceId !== void 0 && (typeof deviceId !== "string" || deviceId.trim() === "")) {
|
|
4035
|
+
throw new Error("deviceId must be a non-empty string when provided");
|
|
4036
|
+
}
|
|
4037
|
+
const resolved = this._resolveDeviceSimulatorSingleTarget(target, instance_id, "get_device_simulator_state");
|
|
4038
|
+
const state = await this._executeDeviceSimulatorOperation(resolved.instanceId, resolved.role, "get", {
|
|
4039
|
+
includeDeviceList: includeDeviceList !== false,
|
|
4040
|
+
deviceId
|
|
4041
|
+
});
|
|
4042
|
+
return {
|
|
4043
|
+
content: [{
|
|
4044
|
+
type: "text",
|
|
4045
|
+
text: JSON.stringify({
|
|
4046
|
+
target: resolved.selectedTarget,
|
|
4047
|
+
role: resolved.role,
|
|
4048
|
+
...state
|
|
4049
|
+
})
|
|
4050
|
+
}]
|
|
4051
|
+
};
|
|
4052
|
+
}
|
|
4053
|
+
async setDeviceSimulator(target, deviceId, orientation, resolution, pixelDensity, scalingMode, stopSimulation, instance_id) {
|
|
4054
|
+
const settings = normalizeDeviceSimulatorSettings({ deviceId, orientation, resolution, pixelDensity, scalingMode });
|
|
4055
|
+
if (stopSimulation === true && hasDeviceSimulatorSettings(settings)) {
|
|
4056
|
+
throw new Error("stopSimulation=true cannot be combined with deviceId, orientation, resolution, pixelDensity, or scalingMode");
|
|
4057
|
+
}
|
|
4058
|
+
if (stopSimulation !== true && !hasDeviceSimulatorSettings(settings)) {
|
|
4059
|
+
throw new Error("set_device_simulator requires stopSimulation=true or at least one simulator setting");
|
|
4060
|
+
}
|
|
4061
|
+
const resolved = this._resolveDeviceSimulatorSetTargets(target, instance_id);
|
|
4062
|
+
const responses = await Promise.allSettled(resolved.roles.map(async (role) => {
|
|
4063
|
+
const result = await this._executeDeviceSimulatorOperation(resolved.instanceId, role, "set", stopSimulation === true ? { stopSimulation: true } : { settings });
|
|
4064
|
+
return { role, result };
|
|
4065
|
+
}));
|
|
4066
|
+
const body = {
|
|
4067
|
+
target: resolved.selectedTarget,
|
|
4068
|
+
targets: {}
|
|
4069
|
+
};
|
|
4070
|
+
const targets = body.targets;
|
|
4071
|
+
const failures = [];
|
|
4072
|
+
for (let i = 0; i < responses.length; i++) {
|
|
4073
|
+
const role = resolved.roles[i];
|
|
4074
|
+
const response = responses[i];
|
|
4075
|
+
if (response.status === "fulfilled") {
|
|
4076
|
+
targets[role] = response.value.result;
|
|
4077
|
+
} else {
|
|
4078
|
+
const message = errorMessage(response.reason);
|
|
4079
|
+
targets[role] = { error: message };
|
|
4080
|
+
failures.push(`${role}: ${message}`);
|
|
4081
|
+
}
|
|
4082
|
+
}
|
|
4083
|
+
if (failures.length > 0) {
|
|
4084
|
+
throw new Error(`set_device_simulator failed for ${failures.join("; ")}. Partial result: ${JSON.stringify(body)}`);
|
|
4085
|
+
}
|
|
4086
|
+
return {
|
|
4087
|
+
content: [{
|
|
4088
|
+
type: "text",
|
|
4089
|
+
text: JSON.stringify(body)
|
|
4090
|
+
}]
|
|
4091
|
+
};
|
|
4092
|
+
}
|
|
4093
|
+
async captureDeviceMatrix(entries, target, format, quality, settleSeconds, restoreAfter, instance_id) {
|
|
4094
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
4095
|
+
throw new Error("capture_device_matrix requires a non-empty entries array");
|
|
4096
|
+
}
|
|
4097
|
+
if (entries.length > MAX_DEVICE_MATRIX_ENTRIES) {
|
|
4098
|
+
throw new Error(`capture_device_matrix supports at most ${MAX_DEVICE_MATRIX_ENTRIES} entries per call; split larger matrices into multiple calls`);
|
|
4099
|
+
}
|
|
4100
|
+
const matrixEntries = entries.map((entry, index) => {
|
|
4101
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
4102
|
+
throw new Error(`entries[${index}] must be an object`);
|
|
4103
|
+
}
|
|
4104
|
+
const raw = entry;
|
|
4105
|
+
if (raw.label !== void 0 && typeof raw.label !== "string") {
|
|
4106
|
+
throw new Error(`entries[${index}].label must be a string when provided`);
|
|
4107
|
+
}
|
|
4108
|
+
return {
|
|
4109
|
+
...normalizeDeviceSimulatorSettings({
|
|
4110
|
+
deviceId: raw.deviceId,
|
|
4111
|
+
orientation: raw.orientation,
|
|
4112
|
+
resolution: raw.resolution,
|
|
4113
|
+
pixelDensity: raw.pixelDensity,
|
|
4114
|
+
scalingMode: raw.scalingMode
|
|
4115
|
+
}),
|
|
4116
|
+
label: raw.label
|
|
4117
|
+
};
|
|
4118
|
+
});
|
|
4119
|
+
const resolved = this._resolveDeviceSimulatorSingleTarget(target, instance_id, "capture_device_matrix");
|
|
4120
|
+
if (resolved.role.startsWith("client-") && await this._isMultiplayerTestRunning(resolved.instanceId)) {
|
|
4121
|
+
throw new Error("capture_device_matrix does not support StudioTestService multiplayer client targets because Roblox scopes temporary screenshot textures per client process");
|
|
4122
|
+
}
|
|
4123
|
+
const settleMs = settleSeconds === void 0 ? 300 : Math.max(0, Math.floor(settleSeconds * 1e3));
|
|
4124
|
+
const shouldRestore = restoreAfter !== false;
|
|
4125
|
+
const before = await this._executeDeviceSimulatorOperation(resolved.instanceId, resolved.role, "get", { includeDeviceList: shouldRestore });
|
|
4126
|
+
if (shouldRestore) {
|
|
4127
|
+
this._assertCanRestoreDeviceSimulatorState(before);
|
|
4128
|
+
}
|
|
4129
|
+
const summary = {
|
|
4130
|
+
target: resolved.selectedTarget,
|
|
4131
|
+
role: resolved.role,
|
|
4132
|
+
restoreAfter: shouldRestore,
|
|
4133
|
+
before: this._deviceSimulatorStateWithoutDeviceList(before),
|
|
4134
|
+
entries: []
|
|
4135
|
+
};
|
|
4136
|
+
const entrySummaries = summary.entries;
|
|
4137
|
+
const content = [];
|
|
4138
|
+
const failures = [];
|
|
4139
|
+
try {
|
|
4140
|
+
for (let i = 0; i < matrixEntries.length; i++) {
|
|
4141
|
+
const entry = matrixEntries[i];
|
|
4142
|
+
const label = entry.label ?? `entry-${i + 1}`;
|
|
4143
|
+
const entrySummary = {
|
|
4144
|
+
index: i,
|
|
4145
|
+
label,
|
|
4146
|
+
settings: entry
|
|
4147
|
+
};
|
|
4148
|
+
entrySummaries.push(entrySummary);
|
|
4149
|
+
try {
|
|
4150
|
+
const { label: _label, ...settings } = entry;
|
|
4151
|
+
const applied = await this._executeDeviceSimulatorOperation(resolved.instanceId, resolved.role, "set", { settings });
|
|
4152
|
+
entrySummary.applied = applied;
|
|
4153
|
+
if (settleMs > 0)
|
|
4154
|
+
await sleep(settleMs);
|
|
4155
|
+
const capture = await this._captureViewportImage(resolved.instanceId, resolved.role, format, quality);
|
|
4156
|
+
if (capture.success) {
|
|
4157
|
+
entrySummary.screenshot = {
|
|
4158
|
+
width: capture.width,
|
|
4159
|
+
height: capture.height,
|
|
4160
|
+
format: capture.format,
|
|
4161
|
+
quality: capture.quality,
|
|
4162
|
+
mimeType: capture.mimeType
|
|
4163
|
+
};
|
|
4164
|
+
content.push({
|
|
4165
|
+
type: "text",
|
|
4166
|
+
text: `capture_device_matrix ${i + 1}/${matrixEntries.length} ${label}: ${capture.message}`
|
|
4167
|
+
});
|
|
4168
|
+
content.push({
|
|
4169
|
+
type: "image",
|
|
4170
|
+
data: capture.data,
|
|
4171
|
+
mimeType: capture.mimeType
|
|
4172
|
+
});
|
|
4173
|
+
} else {
|
|
4174
|
+
entrySummary.error = capture.error;
|
|
4175
|
+
failures.push(`${label}: ${capture.error}`);
|
|
4176
|
+
content.push({
|
|
4177
|
+
type: "text",
|
|
4178
|
+
text: `capture_device_matrix ${i + 1}/${matrixEntries.length} ${label}: ${capture.error}`
|
|
4179
|
+
});
|
|
4180
|
+
}
|
|
4181
|
+
} catch (error) {
|
|
4182
|
+
const message = errorMessage(error);
|
|
4183
|
+
entrySummary.error = message;
|
|
4184
|
+
failures.push(`${label}: ${message}`);
|
|
4185
|
+
content.push({
|
|
4186
|
+
type: "text",
|
|
4187
|
+
text: `capture_device_matrix ${i + 1}/${matrixEntries.length} ${label}: ${message}`
|
|
4188
|
+
});
|
|
4189
|
+
}
|
|
4190
|
+
}
|
|
4191
|
+
} finally {
|
|
4192
|
+
if (shouldRestore) {
|
|
4193
|
+
try {
|
|
4194
|
+
const restoreSettings = this._settingsFromDeviceSimulatorState(before);
|
|
4195
|
+
if ("stopSimulation" in restoreSettings) {
|
|
4196
|
+
summary.restore = await this._executeDeviceSimulatorOperation(resolved.instanceId, resolved.role, "set", { stopSimulation: true });
|
|
4197
|
+
} else {
|
|
4198
|
+
summary.restore = await this._executeDeviceSimulatorOperation(resolved.instanceId, resolved.role, "set", { settings: restoreSettings });
|
|
4199
|
+
}
|
|
4200
|
+
} catch (error) {
|
|
4201
|
+
const message = errorMessage(error);
|
|
4202
|
+
summary.restoreError = message;
|
|
4203
|
+
failures.push(`restore: ${message}`);
|
|
4204
|
+
}
|
|
4205
|
+
}
|
|
4206
|
+
}
|
|
4207
|
+
if (failures.length > 0) {
|
|
4208
|
+
throw new Error(`capture_device_matrix failed for ${failures.join("; ")}. Partial result: ${JSON.stringify(summary)}`);
|
|
4209
|
+
}
|
|
4210
|
+
return {
|
|
4211
|
+
content: [
|
|
4212
|
+
{
|
|
4213
|
+
type: "text",
|
|
4214
|
+
text: JSON.stringify(summary)
|
|
4215
|
+
},
|
|
4216
|
+
...content
|
|
4217
|
+
]
|
|
4218
|
+
};
|
|
4219
|
+
}
|
|
3291
4220
|
async getRuntimeLogs(target, since, tail, filter, instance_id) {
|
|
3292
4221
|
const tgt = target ?? "all";
|
|
3293
4222
|
const data = {};
|
|
@@ -3425,9 +4354,20 @@ ${code}`
|
|
|
3425
4354
|
};
|
|
3426
4355
|
}
|
|
3427
4356
|
async stopPlaytest(instance_id) {
|
|
3428
|
-
const
|
|
4357
|
+
const { instanceId } = this._resolveSingleTarget("edit", instance_id);
|
|
4358
|
+
const response = await this.client.request("/api/stop-playtest", {}, instanceId, "edit");
|
|
4359
|
+
let wait;
|
|
4360
|
+
if (response?.success === true) {
|
|
4361
|
+
wait = await this._waitForRuntimeRoles(instanceId, { noRuntime: true }, 15);
|
|
4362
|
+
}
|
|
4363
|
+
const body = wait ? {
|
|
4364
|
+
...response,
|
|
4365
|
+
runtimeStopped: wait.ok,
|
|
4366
|
+
timedOut: wait.timedOut,
|
|
4367
|
+
roles: wait.roles
|
|
4368
|
+
} : response;
|
|
3429
4369
|
return {
|
|
3430
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
4370
|
+
content: [{ type: "text", text: JSON.stringify(body) }]
|
|
3431
4371
|
};
|
|
3432
4372
|
}
|
|
3433
4373
|
async getPlaytestOutput(target, instance_id) {
|
|
@@ -3499,15 +4439,19 @@ ${code}`
|
|
|
3499
4439
|
return false;
|
|
3500
4440
|
}
|
|
3501
4441
|
async _isMultiplayerTestRunning(instanceId) {
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
4442
|
+
const roles = this._rolesForInstance(instanceId);
|
|
4443
|
+
const hasServer = roles.includes("server");
|
|
4444
|
+
const clientCount = roles.filter((role) => role.startsWith("client-")).length;
|
|
4445
|
+
if (roles.includes("edit")) {
|
|
4446
|
+
try {
|
|
4447
|
+
const editState = await this.client.request("/api/multiplayer-test-state", {}, instanceId, "edit");
|
|
4448
|
+
const phase = editState?.session?.phase;
|
|
4449
|
+
if (phase === "starting" || phase === "running")
|
|
4450
|
+
return true;
|
|
4451
|
+
} catch {
|
|
4452
|
+
}
|
|
3510
4453
|
}
|
|
4454
|
+
return hasServer && clientCount >= 2;
|
|
3511
4455
|
}
|
|
3512
4456
|
async _waitForMultiplayerStart(instanceId, clientCount, timeoutSec = 30) {
|
|
3513
4457
|
const deadline = Date.now() + timeoutSec * 1e3;
|
|
@@ -4624,16 +5568,15 @@ ${code}`
|
|
|
4624
5568
|
}, tgt, instance_id);
|
|
4625
5569
|
return { content: [{ type: "text", text: JSON.stringify(response) }] };
|
|
4626
5570
|
}
|
|
4627
|
-
async
|
|
4628
|
-
const { instanceId, clientRole } = this._resolveRuntime(instance_id);
|
|
5571
|
+
async _captureViewportImage(instanceId, targetRole, format, quality) {
|
|
4629
5572
|
let response;
|
|
4630
|
-
if (
|
|
4631
|
-
const begin = await this._callSingle("/api/capture-begin", {},
|
|
5573
|
+
if (targetRole.startsWith("client-")) {
|
|
5574
|
+
const begin = await this._callSingle("/api/capture-begin", {}, targetRole, instanceId);
|
|
4632
5575
|
if (begin.error) {
|
|
4633
|
-
return {
|
|
5576
|
+
return { success: false, error: begin.error };
|
|
4634
5577
|
}
|
|
4635
5578
|
if (!begin.contentId) {
|
|
4636
|
-
return {
|
|
5579
|
+
return { success: false, error: "Screenshot capture failed: no content id returned from client." };
|
|
4637
5580
|
}
|
|
4638
5581
|
response = await this._callSingle("/api/capture-read", { contentId: begin.contentId }, "edit", instanceId);
|
|
4639
5582
|
} else {
|
|
@@ -4641,55 +5584,66 @@ ${code}`
|
|
|
4641
5584
|
}
|
|
4642
5585
|
if (response.error) {
|
|
4643
5586
|
let text = response.error;
|
|
4644
|
-
if (
|
|
5587
|
+
if (targetRole.startsWith("client-") && response.error.includes("Failed to load texture, unexpected format") && await this._isMultiplayerTestRunning(instanceId)) {
|
|
4645
5588
|
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}`;
|
|
4646
5589
|
}
|
|
4647
|
-
return {
|
|
4648
|
-
content: [{
|
|
4649
|
-
type: "text",
|
|
4650
|
-
text
|
|
4651
|
-
}]
|
|
4652
|
-
};
|
|
5590
|
+
return { success: false, error: text };
|
|
4653
5591
|
}
|
|
4654
5592
|
const w = response.width;
|
|
4655
5593
|
const h = response.height;
|
|
4656
5594
|
if (w === void 0 || h === void 0) {
|
|
4657
|
-
return {
|
|
5595
|
+
return { success: false, error: "Screenshot response missing dimensions." };
|
|
4658
5596
|
}
|
|
4659
5597
|
const fmt = format === "png" ? "png" : "jpeg";
|
|
4660
5598
|
const q = quality === void 0 ? 92 : Math.max(1, Math.min(100, Math.floor(quality)));
|
|
4661
|
-
const MAX_IMAGE_BYTES = 6e6;
|
|
4662
5599
|
const encoded = encodeImageFromRgbaResponse(response, fmt, q);
|
|
4663
5600
|
let { buffer } = encoded;
|
|
4664
5601
|
const { mimeType } = encoded;
|
|
4665
5602
|
let usedQ = q;
|
|
4666
5603
|
let note = "";
|
|
4667
|
-
if (buffer.length >
|
|
5604
|
+
if (buffer.length > MAX_INLINE_IMAGE_BYTES) {
|
|
4668
5605
|
if (fmt === "png") {
|
|
4669
5606
|
const mb = (buffer.length / 1048576).toFixed(1);
|
|
4670
5607
|
return {
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
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.`
|
|
4674
|
-
}]
|
|
5608
|
+
success: false,
|
|
5609
|
+
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.`
|
|
4675
5610
|
};
|
|
4676
5611
|
}
|
|
4677
|
-
while (buffer.length >
|
|
5612
|
+
while (buffer.length > MAX_INLINE_IMAGE_BYTES && usedQ > 25) {
|
|
4678
5613
|
usedQ = Math.max(25, usedQ - 20);
|
|
4679
5614
|
buffer = encodeImageFromRgbaResponse(response, "jpeg", usedQ).buffer;
|
|
4680
5615
|
}
|
|
4681
5616
|
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`;
|
|
4682
5617
|
}
|
|
5618
|
+
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.`;
|
|
5619
|
+
return {
|
|
5620
|
+
success: true,
|
|
5621
|
+
width: w,
|
|
5622
|
+
height: h,
|
|
5623
|
+
format: fmt,
|
|
5624
|
+
quality: fmt === "jpeg" ? usedQ : void 0,
|
|
5625
|
+
note,
|
|
5626
|
+
data: buffer.toString("base64"),
|
|
5627
|
+
mimeType,
|
|
5628
|
+
message
|
|
5629
|
+
};
|
|
5630
|
+
}
|
|
5631
|
+
async captureScreenshot(instance_id, format, quality) {
|
|
5632
|
+
const { instanceId, clientRole } = this._resolveRuntime(instance_id);
|
|
5633
|
+
const capture = await this._captureViewportImage(instanceId, clientRole ?? "edit", format, quality);
|
|
5634
|
+
if (!capture.success) {
|
|
5635
|
+
return { content: [{ type: "text", text: capture.error }] };
|
|
5636
|
+
}
|
|
4683
5637
|
return {
|
|
4684
5638
|
content: [
|
|
4685
5639
|
{
|
|
4686
5640
|
type: "text",
|
|
4687
|
-
text:
|
|
5641
|
+
text: capture.message
|
|
4688
5642
|
},
|
|
4689
5643
|
{
|
|
4690
5644
|
type: "image",
|
|
4691
|
-
data:
|
|
4692
|
-
mimeType
|
|
5645
|
+
data: capture.data,
|
|
5646
|
+
mimeType: capture.mimeType
|
|
4693
5647
|
}
|
|
4694
5648
|
]
|
|
4695
5649
|
};
|
|
@@ -5851,7 +6805,7 @@ var init_definitions = __esm({
|
|
|
5851
6805
|
{
|
|
5852
6806
|
name: "eval_server_runtime",
|
|
5853
6807
|
category: "write",
|
|
5854
|
-
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
|
|
6808
|
+
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.",
|
|
5855
6809
|
inputSchema: {
|
|
5856
6810
|
type: "object",
|
|
5857
6811
|
properties: {
|
|
@@ -5870,7 +6824,7 @@ var init_definitions = __esm({
|
|
|
5870
6824
|
{
|
|
5871
6825
|
name: "eval_client_runtime",
|
|
5872
6826
|
category: "write",
|
|
5873
|
-
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
|
|
6827
|
+
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.",
|
|
5874
6828
|
inputSchema: {
|
|
5875
6829
|
type: "object",
|
|
5876
6830
|
properties: {
|
|
@@ -6000,6 +6954,280 @@ var init_definitions = __esm({
|
|
|
6000
6954
|
}
|
|
6001
6955
|
}
|
|
6002
6956
|
},
|
|
6957
|
+
{
|
|
6958
|
+
name: "set_network_profile",
|
|
6959
|
+
category: "write",
|
|
6960
|
+
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.`,
|
|
6961
|
+
inputSchema: {
|
|
6962
|
+
type: "object",
|
|
6963
|
+
properties: {
|
|
6964
|
+
profile: {
|
|
6965
|
+
type: "string",
|
|
6966
|
+
enum: ["great", "good", "poor", "custom"],
|
|
6967
|
+
description: "Network condition preset. Presets set all six simulation fields; custom requires overrides."
|
|
6968
|
+
},
|
|
6969
|
+
target: {
|
|
6970
|
+
type: "string",
|
|
6971
|
+
description: 'Client target: "client-1" (default), "client-2", etc., or "all-clients" to apply to every connected playtest client.'
|
|
6972
|
+
},
|
|
6973
|
+
overrides: {
|
|
6974
|
+
type: "object",
|
|
6975
|
+
additionalProperties: false,
|
|
6976
|
+
properties: {
|
|
6977
|
+
InboundNetworkMinDelayMs: {
|
|
6978
|
+
type: "number",
|
|
6979
|
+
minimum: 0,
|
|
6980
|
+
description: "Server-to-client minimum latency in milliseconds."
|
|
6981
|
+
},
|
|
6982
|
+
OutboundNetworkMinDelayMs: {
|
|
6983
|
+
type: "number",
|
|
6984
|
+
minimum: 0,
|
|
6985
|
+
description: "Client-to-server minimum latency in milliseconds."
|
|
6986
|
+
},
|
|
6987
|
+
InboundNetworkJitterMs: {
|
|
6988
|
+
type: "number",
|
|
6989
|
+
minimum: 0,
|
|
6990
|
+
description: "Server-to-client latency jitter in milliseconds."
|
|
6991
|
+
},
|
|
6992
|
+
OutboundNetworkJitterMs: {
|
|
6993
|
+
type: "number",
|
|
6994
|
+
minimum: 0,
|
|
6995
|
+
description: "Client-to-server latency jitter in milliseconds."
|
|
6996
|
+
},
|
|
6997
|
+
InboundNetworkLossPercent: {
|
|
6998
|
+
type: "number",
|
|
6999
|
+
minimum: 0,
|
|
7000
|
+
maximum: 0.5,
|
|
7001
|
+
description: "Server-to-client packet loss percentage. Roblox engine limit is 0.5%; larger values are rejected."
|
|
7002
|
+
},
|
|
7003
|
+
OutboundNetworkLossPercent: {
|
|
7004
|
+
type: "number",
|
|
7005
|
+
minimum: 0,
|
|
7006
|
+
maximum: 0.5,
|
|
7007
|
+
description: "Client-to-server packet loss percentage. Roblox engine limit is 0.5%; larger values are rejected."
|
|
7008
|
+
}
|
|
7009
|
+
},
|
|
7010
|
+
description: "Optional exact NetworkSettings property overrides. For preset profiles, overrides replace preset fields. For custom, only these properties are applied."
|
|
7011
|
+
},
|
|
7012
|
+
instance_id: {
|
|
7013
|
+
type: "string",
|
|
7014
|
+
description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
7015
|
+
}
|
|
7016
|
+
},
|
|
7017
|
+
required: ["profile"]
|
|
7018
|
+
}
|
|
7019
|
+
},
|
|
7020
|
+
{
|
|
7021
|
+
name: "get_simulation_state",
|
|
7022
|
+
category: "read",
|
|
7023
|
+
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.',
|
|
7024
|
+
inputSchema: {
|
|
7025
|
+
type: "object",
|
|
7026
|
+
properties: {
|
|
7027
|
+
include: {
|
|
7028
|
+
type: "string",
|
|
7029
|
+
enum: ["network", "deviceSimulator", "both"],
|
|
7030
|
+
description: 'Simulation state to inspect: "network", "deviceSimulator", or "both" (default both).'
|
|
7031
|
+
},
|
|
7032
|
+
target: {
|
|
7033
|
+
type: "string",
|
|
7034
|
+
description: 'Simulation target scope: "edit-and-clients" (default), "edit", "all-clients", or a specific "client-N". Server peers are never included.'
|
|
7035
|
+
},
|
|
7036
|
+
instance_id: {
|
|
7037
|
+
type: "string",
|
|
7038
|
+
description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
7039
|
+
}
|
|
7040
|
+
}
|
|
7041
|
+
}
|
|
7042
|
+
},
|
|
7043
|
+
{
|
|
7044
|
+
name: "reset_simulation_state",
|
|
7045
|
+
category: "write",
|
|
7046
|
+
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.',
|
|
7047
|
+
inputSchema: {
|
|
7048
|
+
type: "object",
|
|
7049
|
+
properties: {
|
|
7050
|
+
target: {
|
|
7051
|
+
type: "string",
|
|
7052
|
+
description: 'Simulation target scope: "edit-and-clients" (default), "edit", "all-clients", or a specific "client-N". Server peers are skipped.'
|
|
7053
|
+
},
|
|
7054
|
+
network: {
|
|
7055
|
+
type: "boolean",
|
|
7056
|
+
description: "Reset simulated NetworkSettings fields to 0 (default true)."
|
|
7057
|
+
},
|
|
7058
|
+
deviceSimulator: {
|
|
7059
|
+
type: "boolean",
|
|
7060
|
+
description: "Stop Studio device simulation with StopSimulationAsync() (default true)."
|
|
7061
|
+
},
|
|
7062
|
+
instance_id: {
|
|
7063
|
+
type: "string",
|
|
7064
|
+
description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
7065
|
+
}
|
|
7066
|
+
}
|
|
7067
|
+
}
|
|
7068
|
+
},
|
|
7069
|
+
{
|
|
7070
|
+
name: "get_device_simulator_state",
|
|
7071
|
+
category: "read",
|
|
7072
|
+
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.',
|
|
7073
|
+
inputSchema: {
|
|
7074
|
+
type: "object",
|
|
7075
|
+
properties: {
|
|
7076
|
+
target: {
|
|
7077
|
+
type: "string",
|
|
7078
|
+
description: 'Device simulator target: "edit" (default) or a regular playtest client like "client-1". Server targets are rejected.'
|
|
7079
|
+
},
|
|
7080
|
+
deviceId: {
|
|
7081
|
+
type: "string",
|
|
7082
|
+
description: "Optional built-in device preset ID to inspect with GetDeviceInfoAsync."
|
|
7083
|
+
},
|
|
7084
|
+
includeDeviceList: {
|
|
7085
|
+
type: "boolean",
|
|
7086
|
+
description: "Include the built-in device preset list from GetDeviceListAsync (default true)."
|
|
7087
|
+
},
|
|
7088
|
+
instance_id: {
|
|
7089
|
+
type: "string",
|
|
7090
|
+
description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
7091
|
+
}
|
|
7092
|
+
}
|
|
7093
|
+
}
|
|
7094
|
+
},
|
|
7095
|
+
{
|
|
7096
|
+
name: "set_device_simulator",
|
|
7097
|
+
category: "write",
|
|
7098
|
+
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.',
|
|
7099
|
+
inputSchema: {
|
|
7100
|
+
type: "object",
|
|
7101
|
+
properties: {
|
|
7102
|
+
target: {
|
|
7103
|
+
type: "string",
|
|
7104
|
+
description: 'Device simulator target: "edit" (default), "client-1", "client-2", etc., or "all-clients".'
|
|
7105
|
+
},
|
|
7106
|
+
deviceId: {
|
|
7107
|
+
type: "string",
|
|
7108
|
+
description: "Built-in device preset ID from get_device_simulator_state."
|
|
7109
|
+
},
|
|
7110
|
+
orientation: {
|
|
7111
|
+
type: "string",
|
|
7112
|
+
description: 'ScreenOrientation enum name, e.g. "LandscapeRight", "LandscapeLeft", "Portrait", or a full Enum.ScreenOrientation.* string.'
|
|
7113
|
+
},
|
|
7114
|
+
resolution: {
|
|
7115
|
+
type: "object",
|
|
7116
|
+
additionalProperties: false,
|
|
7117
|
+
properties: {
|
|
7118
|
+
width: {
|
|
7119
|
+
type: "number",
|
|
7120
|
+
description: "Viewport width in pixels."
|
|
7121
|
+
},
|
|
7122
|
+
height: {
|
|
7123
|
+
type: "number",
|
|
7124
|
+
description: "Viewport height in pixels."
|
|
7125
|
+
}
|
|
7126
|
+
},
|
|
7127
|
+
required: ["width", "height"],
|
|
7128
|
+
description: "Optional resolution override applied after the device preset."
|
|
7129
|
+
},
|
|
7130
|
+
pixelDensity: {
|
|
7131
|
+
type: "number",
|
|
7132
|
+
description: "Optional positive pixel density override applied after the device preset."
|
|
7133
|
+
},
|
|
7134
|
+
scalingMode: {
|
|
7135
|
+
type: "string",
|
|
7136
|
+
description: 'DeviceSimulatorScalingMode enum name, e.g. "ScaleToPhysicalSize", or a full Enum.DeviceSimulatorScalingMode.* string.'
|
|
7137
|
+
},
|
|
7138
|
+
stopSimulation: {
|
|
7139
|
+
type: "boolean",
|
|
7140
|
+
description: "Stop device simulation. When true, do not pass other simulator setters."
|
|
7141
|
+
},
|
|
7142
|
+
instance_id: {
|
|
7143
|
+
type: "string",
|
|
7144
|
+
description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
7145
|
+
}
|
|
7146
|
+
}
|
|
7147
|
+
}
|
|
7148
|
+
},
|
|
7149
|
+
{
|
|
7150
|
+
name: "capture_device_matrix",
|
|
7151
|
+
category: "write",
|
|
7152
|
+
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.',
|
|
7153
|
+
inputSchema: {
|
|
7154
|
+
type: "object",
|
|
7155
|
+
properties: {
|
|
7156
|
+
entries: {
|
|
7157
|
+
type: "array",
|
|
7158
|
+
maxItems: 6,
|
|
7159
|
+
description: "Ordered device capture entries. Each entry may set a deviceId and optional simulator overrides before capture.",
|
|
7160
|
+
items: {
|
|
7161
|
+
type: "object",
|
|
7162
|
+
additionalProperties: false,
|
|
7163
|
+
properties: {
|
|
7164
|
+
label: {
|
|
7165
|
+
type: "string",
|
|
7166
|
+
description: "Optional label included in the screenshot metadata."
|
|
7167
|
+
},
|
|
7168
|
+
deviceId: {
|
|
7169
|
+
type: "string",
|
|
7170
|
+
description: "Built-in device preset ID from get_device_simulator_state."
|
|
7171
|
+
},
|
|
7172
|
+
orientation: {
|
|
7173
|
+
type: "string",
|
|
7174
|
+
description: "ScreenOrientation enum name or full Enum.ScreenOrientation.* string."
|
|
7175
|
+
},
|
|
7176
|
+
resolution: {
|
|
7177
|
+
type: "object",
|
|
7178
|
+
additionalProperties: false,
|
|
7179
|
+
properties: {
|
|
7180
|
+
width: {
|
|
7181
|
+
type: "number",
|
|
7182
|
+
description: "Viewport width in pixels."
|
|
7183
|
+
},
|
|
7184
|
+
height: {
|
|
7185
|
+
type: "number",
|
|
7186
|
+
description: "Viewport height in pixels."
|
|
7187
|
+
}
|
|
7188
|
+
},
|
|
7189
|
+
required: ["width", "height"]
|
|
7190
|
+
},
|
|
7191
|
+
pixelDensity: {
|
|
7192
|
+
type: "number",
|
|
7193
|
+
description: "Optional positive pixel density override."
|
|
7194
|
+
},
|
|
7195
|
+
scalingMode: {
|
|
7196
|
+
type: "string",
|
|
7197
|
+
description: "DeviceSimulatorScalingMode enum name or full Enum.DeviceSimulatorScalingMode.* string."
|
|
7198
|
+
}
|
|
7199
|
+
}
|
|
7200
|
+
}
|
|
7201
|
+
},
|
|
7202
|
+
target: {
|
|
7203
|
+
type: "string",
|
|
7204
|
+
description: 'Device simulator target: "edit" (default) or a regular playtest client such as "client-1". all-clients and server targets are rejected.'
|
|
7205
|
+
},
|
|
7206
|
+
format: {
|
|
7207
|
+
type: "string",
|
|
7208
|
+
enum: ["jpeg", "png"],
|
|
7209
|
+
description: 'Screenshot image format. "jpeg" (default) is compact; "png" is lossless but may exceed inline size limits.'
|
|
7210
|
+
},
|
|
7211
|
+
quality: {
|
|
7212
|
+
type: "number",
|
|
7213
|
+
description: "JPEG quality 1-100 (default 92). Ignored for png."
|
|
7214
|
+
},
|
|
7215
|
+
settleSeconds: {
|
|
7216
|
+
type: "number",
|
|
7217
|
+
description: "Seconds to wait after applying each simulator entry before capturing (default 0.3)."
|
|
7218
|
+
},
|
|
7219
|
+
restoreAfter: {
|
|
7220
|
+
type: "boolean",
|
|
7221
|
+
description: "Restore the previous default or built-in preset simulator state after the matrix finishes (default true). Custom active devices are not preserved."
|
|
7222
|
+
},
|
|
7223
|
+
instance_id: {
|
|
7224
|
+
type: "string",
|
|
7225
|
+
description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
7226
|
+
}
|
|
7227
|
+
},
|
|
7228
|
+
required: ["entries"]
|
|
7229
|
+
}
|
|
7230
|
+
},
|
|
6003
7231
|
{
|
|
6004
7232
|
name: "multiplayer_test_start",
|
|
6005
7233
|
category: "write",
|