@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 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: "pluginSessionId, instanceId, and role are required"
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 errorMessage;
1300
+ let errorMessage2;
1265
1301
  try {
1266
1302
  const errorJson = JSON.parse(errorBody);
1267
- errorMessage = errorJson.detail || errorJson.message || errorBody;
1303
+ errorMessage2 = errorJson.detail || errorJson.message || errorBody;
1268
1304
  } catch {
1269
- errorMessage = errorBody;
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: ${errorMessage}`);
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}): ${errorMessage}`);
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 errorMessage;
1449
+ let errorMessage2;
1414
1450
  try {
1415
1451
  const errorJson = JSON.parse(errorBody);
1416
- errorMessage = errorJson.detail || errorJson.message || errorBody;
1452
+ errorMessage2 = errorJson.detail || errorJson.message || errorBody;
1417
1453
  } catch {
1418
- errorMessage = errorBody;
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: ${errorMessage}`);
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}): ${errorMessage}`);
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
- var RobloxStudioTools;
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 response = await this._callSingle("/api/stop-playtest", {}, "edit", instance_id);
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(response) }]
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
- if (!this._rolesForInstance(instanceId).includes("edit"))
3503
- return false;
3504
- try {
3505
- const editState = await this.client.request("/api/multiplayer-test-state", {}, instanceId, "edit");
3506
- const phase = editState?.session?.phase;
3507
- return phase === "starting" || phase === "running";
3508
- } catch {
3509
- return false;
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 captureScreenshot(instance_id, format, quality) {
4628
- const { instanceId, clientRole } = this._resolveRuntime(instance_id);
5571
+ async _captureViewportImage(instanceId, targetRole, format, quality) {
4629
5572
  let response;
4630
- if (clientRole) {
4631
- const begin = await this._callSingle("/api/capture-begin", {}, clientRole, instanceId);
5573
+ if (targetRole.startsWith("client-")) {
5574
+ const begin = await this._callSingle("/api/capture-begin", {}, targetRole, instanceId);
4632
5575
  if (begin.error) {
4633
- return { content: [{ type: "text", text: begin.error }] };
5576
+ return { success: false, error: begin.error };
4634
5577
  }
4635
5578
  if (!begin.contentId) {
4636
- return { content: [{ type: "text", text: "Screenshot capture failed: no content id returned from client." }] };
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 (clientRole && response.error.includes("Failed to load texture, unexpected format") && await this._isMultiplayerTestRunning(instanceId)) {
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 { content: [{ type: "text", text: "Screenshot response missing dimensions." }] };
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 > MAX_IMAGE_BYTES) {
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
- content: [{
4672
- type: "text",
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 > MAX_IMAGE_BYTES && usedQ > 25) {
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: `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.`
5641
+ text: capture.message
4688
5642
  },
4689
5643
  {
4690
5644
  type: "image",
4691
- data: buffer.toString("base64"),
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 installed automatically (including for playtests started manually via the Studio Play button).",
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 installed automatically (including for playtests started manually via the Studio Play button).",
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",