@chrrxs/robloxstudio-mcp 2.15.2 → 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 +1232 -45
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +174 -133
- package/studio-plugin/MCPPlugin.rbxmx +174 -133
- package/studio-plugin/src/modules/Communication.ts +4 -12
- package/studio-plugin/src/modules/EvalBridges.ts +91 -64
- package/studio-plugin/src/modules/handlers/EvalRuntimeHandlers.ts +34 -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();
|
|
@@ -2573,7 +2579,337 @@ function encodeImageFromRgbaResponse(response, format, quality) {
|
|
|
2573
2579
|
function sleep(ms) {
|
|
2574
2580
|
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
2575
2581
|
}
|
|
2576
|
-
|
|
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;
|
|
2615
|
+
}
|
|
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]
|
|
2657
|
+
end
|
|
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
|
|
2672
|
+
end
|
|
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 }
|
|
2743
|
+
end
|
|
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)
|
|
2751
|
+
end
|
|
2752
|
+
return out
|
|
2753
|
+
end
|
|
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
|
|
2786
|
+
end
|
|
2787
|
+
if item.IsCustom ~= true then
|
|
2788
|
+
ids[id] = true
|
|
2789
|
+
table.insert(devices, item)
|
|
2790
|
+
end
|
|
2791
|
+
end
|
|
2792
|
+
end
|
|
2793
|
+
return devices, ids
|
|
2794
|
+
end
|
|
2795
|
+
|
|
2796
|
+
local function getDeviceList()
|
|
2797
|
+
local rawList = simulator:GetDeviceListAsync()
|
|
2798
|
+
return normalizeDeviceList(rawList)
|
|
2799
|
+
end
|
|
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)
|
|
2808
|
+
end
|
|
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
|
|
2819
|
+
end
|
|
2820
|
+
error(label .. ' "' .. tostring(raw) .. '" is not valid. Available: ' .. table.concat(available, ", "), 0)
|
|
2821
|
+
end
|
|
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
|
|
2831
|
+
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)
|
|
2855
|
+
end
|
|
2856
|
+
|
|
2857
|
+
return state
|
|
2858
|
+
end
|
|
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
|
|
2886
|
+
end
|
|
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();
|
|
2911
|
+
}
|
|
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;
|
|
2577
2913
|
var init_tools = __esm({
|
|
2578
2914
|
"../core/dist/tools/index.js"() {
|
|
2579
2915
|
"use strict";
|
|
@@ -2584,6 +2920,56 @@ var init_tools = __esm({
|
|
|
2584
2920
|
init_roblox_cookie_client();
|
|
2585
2921
|
init_jpeg_encoder();
|
|
2586
2922
|
init_png_encoder();
|
|
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
|
+
];
|
|
2587
2973
|
RobloxStudioTools = class _RobloxStudioTools {
|
|
2588
2974
|
client;
|
|
2589
2975
|
bridge;
|
|
@@ -2683,6 +3069,160 @@ var init_tools = __esm({
|
|
|
2683
3069
|
_clientRolesForInstance(instanceId) {
|
|
2684
3070
|
return this._rolesForInstance(instanceId).filter((role) => /^client-\d+$/.test(role)).sort((a, b) => Number(a.slice("client-".length)) - Number(b.slice("client-".length)));
|
|
2685
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
|
+
}
|
|
2686
3226
|
async _waitForRuntimeRoles(instanceId, opts, timeoutSec = 30) {
|
|
2687
3227
|
const deadline = Date.now() + timeoutSec * 1e3;
|
|
2688
3228
|
while (Date.now() < deadline) {
|
|
@@ -3288,6 +3828,365 @@ ${code}`
|
|
|
3288
3828
|
]
|
|
3289
3829
|
};
|
|
3290
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
|
+
}
|
|
3291
4190
|
async getRuntimeLogs(target, since, tail, filter, instance_id) {
|
|
3292
4191
|
const tgt = target ?? "all";
|
|
3293
4192
|
const data = {};
|
|
@@ -3499,15 +4398,19 @@ ${code}`
|
|
|
3499
4398
|
return false;
|
|
3500
4399
|
}
|
|
3501
4400
|
async _isMultiplayerTestRunning(instanceId) {
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
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
|
+
}
|
|
3510
4412
|
}
|
|
4413
|
+
return hasServer && clientCount >= 2;
|
|
3511
4414
|
}
|
|
3512
4415
|
async _waitForMultiplayerStart(instanceId, clientCount, timeoutSec = 30) {
|
|
3513
4416
|
const deadline = Date.now() + timeoutSec * 1e3;
|
|
@@ -4624,16 +5527,15 @@ ${code}`
|
|
|
4624
5527
|
}, tgt, instance_id);
|
|
4625
5528
|
return { content: [{ type: "text", text: JSON.stringify(response) }] };
|
|
4626
5529
|
}
|
|
4627
|
-
async
|
|
4628
|
-
const { instanceId, clientRole } = this._resolveRuntime(instance_id);
|
|
5530
|
+
async _captureViewportImage(instanceId, targetRole, format, quality) {
|
|
4629
5531
|
let response;
|
|
4630
|
-
if (
|
|
4631
|
-
const begin = await this._callSingle("/api/capture-begin", {},
|
|
5532
|
+
if (targetRole.startsWith("client-")) {
|
|
5533
|
+
const begin = await this._callSingle("/api/capture-begin", {}, targetRole, instanceId);
|
|
4632
5534
|
if (begin.error) {
|
|
4633
|
-
return {
|
|
5535
|
+
return { success: false, error: begin.error };
|
|
4634
5536
|
}
|
|
4635
5537
|
if (!begin.contentId) {
|
|
4636
|
-
return {
|
|
5538
|
+
return { success: false, error: "Screenshot capture failed: no content id returned from client." };
|
|
4637
5539
|
}
|
|
4638
5540
|
response = await this._callSingle("/api/capture-read", { contentId: begin.contentId }, "edit", instanceId);
|
|
4639
5541
|
} else {
|
|
@@ -4641,55 +5543,66 @@ ${code}`
|
|
|
4641
5543
|
}
|
|
4642
5544
|
if (response.error) {
|
|
4643
5545
|
let text = response.error;
|
|
4644
|
-
if (
|
|
5546
|
+
if (targetRole.startsWith("client-") && response.error.includes("Failed to load texture, unexpected format") && await this._isMultiplayerTestRunning(instanceId)) {
|
|
4645
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}`;
|
|
4646
5548
|
}
|
|
4647
|
-
return {
|
|
4648
|
-
content: [{
|
|
4649
|
-
type: "text",
|
|
4650
|
-
text
|
|
4651
|
-
}]
|
|
4652
|
-
};
|
|
5549
|
+
return { success: false, error: text };
|
|
4653
5550
|
}
|
|
4654
5551
|
const w = response.width;
|
|
4655
5552
|
const h = response.height;
|
|
4656
5553
|
if (w === void 0 || h === void 0) {
|
|
4657
|
-
return {
|
|
5554
|
+
return { success: false, error: "Screenshot response missing dimensions." };
|
|
4658
5555
|
}
|
|
4659
5556
|
const fmt = format === "png" ? "png" : "jpeg";
|
|
4660
5557
|
const q = quality === void 0 ? 92 : Math.max(1, Math.min(100, Math.floor(quality)));
|
|
4661
|
-
const MAX_IMAGE_BYTES = 6e6;
|
|
4662
5558
|
const encoded = encodeImageFromRgbaResponse(response, fmt, q);
|
|
4663
5559
|
let { buffer } = encoded;
|
|
4664
5560
|
const { mimeType } = encoded;
|
|
4665
5561
|
let usedQ = q;
|
|
4666
5562
|
let note = "";
|
|
4667
|
-
if (buffer.length >
|
|
5563
|
+
if (buffer.length > MAX_INLINE_IMAGE_BYTES) {
|
|
4668
5564
|
if (fmt === "png") {
|
|
4669
5565
|
const mb = (buffer.length / 1048576).toFixed(1);
|
|
4670
5566
|
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
|
-
}]
|
|
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.`
|
|
4675
5569
|
};
|
|
4676
5570
|
}
|
|
4677
|
-
while (buffer.length >
|
|
5571
|
+
while (buffer.length > MAX_INLINE_IMAGE_BYTES && usedQ > 25) {
|
|
4678
5572
|
usedQ = Math.max(25, usedQ - 20);
|
|
4679
5573
|
buffer = encodeImageFromRgbaResponse(response, "jpeg", usedQ).buffer;
|
|
4680
5574
|
}
|
|
4681
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`;
|
|
4682
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
|
+
}
|
|
4683
5596
|
return {
|
|
4684
5597
|
content: [
|
|
4685
5598
|
{
|
|
4686
5599
|
type: "text",
|
|
4687
|
-
text:
|
|
5600
|
+
text: capture.message
|
|
4688
5601
|
},
|
|
4689
5602
|
{
|
|
4690
5603
|
type: "image",
|
|
4691
|
-
data:
|
|
4692
|
-
mimeType
|
|
5604
|
+
data: capture.data,
|
|
5605
|
+
mimeType: capture.mimeType
|
|
4693
5606
|
}
|
|
4694
5607
|
]
|
|
4695
5608
|
};
|
|
@@ -5851,7 +6764,7 @@ var init_definitions = __esm({
|
|
|
5851
6764
|
{
|
|
5852
6765
|
name: "eval_server_runtime",
|
|
5853
6766
|
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
|
|
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.",
|
|
5855
6768
|
inputSchema: {
|
|
5856
6769
|
type: "object",
|
|
5857
6770
|
properties: {
|
|
@@ -5870,7 +6783,7 @@ var init_definitions = __esm({
|
|
|
5870
6783
|
{
|
|
5871
6784
|
name: "eval_client_runtime",
|
|
5872
6785
|
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
|
|
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.",
|
|
5874
6787
|
inputSchema: {
|
|
5875
6788
|
type: "object",
|
|
5876
6789
|
properties: {
|
|
@@ -6000,6 +6913,280 @@ var init_definitions = __esm({
|
|
|
6000
6913
|
}
|
|
6001
6914
|
}
|
|
6002
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
|
+
},
|
|
6003
7190
|
{
|
|
6004
7191
|
name: "multiplayer_test_start",
|
|
6005
7192
|
category: "write",
|