@chrrxs/robloxstudio-mcp 2.12.0 → 2.14.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 +1508 -64
- package/package.json +2 -2
- package/studio-plugin/INSTALLATION.md +1 -0
- package/studio-plugin/MCPInspectorPlugin.rbxmx +1312 -325
- package/studio-plugin/MCPPlugin.rbxmx +705 -97
- package/studio-plugin/src/modules/ClientBroker.ts +91 -1
- package/studio-plugin/src/modules/Communication.ts +22 -0
- package/studio-plugin/src/modules/EvalBridges.ts +60 -11
- package/studio-plugin/src/modules/RenderMonitor.ts +60 -0
- package/studio-plugin/src/modules/handlers/CaptureHandlers.ts +45 -3
- package/studio-plugin/src/modules/handlers/InputHandlers.ts +100 -39
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +257 -18
- package/studio-plugin/src/server/index.server.ts +6 -0
|
@@ -12,6 +12,11 @@ local Communication = TS.import(script, script, "modules", "Communication")
|
|
|
12
12
|
local ClientBroker = TS.import(script, script, "modules", "ClientBroker")
|
|
13
13
|
local RuntimeLogBuffer = TS.import(script, script, "modules", "RuntimeLogBuffer")
|
|
14
14
|
local StopPlayMonitor = TS.import(script, script, "modules", "StopPlayMonitor")
|
|
15
|
+
local RenderMonitor = TS.import(script, script, "modules", "RenderMonitor")
|
|
16
|
+
-- Track render-loop liveness so input/screenshot tools can report "window
|
|
17
|
+
-- minimized / not rendering" instead of silently no-op'ing. No-op in the
|
|
18
|
+
-- server DM (RenderStepped can't connect there).
|
|
19
|
+
RenderMonitor.start()
|
|
15
20
|
-- Attach the per-peer LogService.MessageOut listener as early as possible so
|
|
16
21
|
-- boot-time prints from the user's place scripts are captured. Powers the
|
|
17
22
|
-- get_runtime_logs MCP tool. Idempotent; safe to call before UI.init().
|
|
@@ -97,7 +102,10 @@ local RunService = _services.RunService
|
|
|
97
102
|
local ServerStorage = _services.ServerStorage
|
|
98
103
|
local RuntimeLogBuffer = TS.import(script, script.Parent, "RuntimeLogBuffer")
|
|
99
104
|
local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
|
|
105
|
+
local CaptureHandlers = TS.import(script, script.Parent, "handlers", "CaptureHandlers")
|
|
106
|
+
local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandlers")
|
|
100
107
|
local LuauExec = TS.import(script, script.Parent, "LuauExec")
|
|
108
|
+
local StudioTestService = game:GetService("StudioTestService")
|
|
101
109
|
-- Mirror of Communication.computeInstanceId() — duplicated here because the
|
|
102
110
|
-- client broker runs in the play-server DM where it can't easily import from
|
|
103
111
|
-- the edit-side module, and the place identifier must match what the edit-DM
|
|
@@ -151,6 +159,7 @@ end
|
|
|
151
159
|
-- signaling, which works regardless of MCP server state.)
|
|
152
160
|
local MCP_URL = "http://localhost:58741"
|
|
153
161
|
local BROKER_NAME = "__MCPClientBroker"
|
|
162
|
+
local BROKER_OWNER_ATTRIBUTE = "__MCPBrokerOwner"
|
|
154
163
|
-- Endpoints the server-peer broker is allowed to forward to the client peer.
|
|
155
164
|
-- Each requires the client peer's plugin VM (because the buffer / require
|
|
156
165
|
-- cache / etc. lives there) so the server peer alone can't satisfy them.
|
|
@@ -158,6 +167,11 @@ local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
|
|
|
158
167
|
["/api/execute-luau"] = true,
|
|
159
168
|
["/api/get-runtime-logs"] = true,
|
|
160
169
|
["/api/get-memory-breakdown"] = true,
|
|
170
|
+
["/api/multiplayer-test-state"] = true,
|
|
171
|
+
["/api/multiplayer-test-leave-client"] = true,
|
|
172
|
+
["/api/capture-begin"] = true,
|
|
173
|
+
["/api/simulate-mouse-input"] = true,
|
|
174
|
+
["/api/simulate-keyboard-input"] = true,
|
|
161
175
|
}
|
|
162
176
|
-- Throttle re-ready calls per proxyId so a brief window of unknownInstance
|
|
163
177
|
-- polls doesn't cause a re-register stampede.
|
|
@@ -236,6 +250,77 @@ local function handleGetRuntimeLogs(data)
|
|
|
236
250
|
filter = filter,
|
|
237
251
|
}, "client")
|
|
238
252
|
end
|
|
253
|
+
local function handleMultiplayerTestState()
|
|
254
|
+
local argsOk, args = pcall(function()
|
|
255
|
+
return StudioTestService:GetTestArgs()
|
|
256
|
+
end)
|
|
257
|
+
local canLeaveOk, canLeave = pcall(function()
|
|
258
|
+
return StudioTestService:CanLeaveTest()
|
|
259
|
+
end)
|
|
260
|
+
local _exp = Players:GetPlayers()
|
|
261
|
+
-- ▼ ReadonlyArray.map ▼
|
|
262
|
+
local _newValue = table.create(#_exp)
|
|
263
|
+
local _callback = function(player)
|
|
264
|
+
return {
|
|
265
|
+
name = player.Name,
|
|
266
|
+
userId = player.UserId,
|
|
267
|
+
displayName = player.DisplayName,
|
|
268
|
+
}
|
|
269
|
+
end
|
|
270
|
+
for _k, _v in _exp do
|
|
271
|
+
_newValue[_k] = _callback(_v, _k - 1, _exp)
|
|
272
|
+
end
|
|
273
|
+
-- ▲ ReadonlyArray.map ▲
|
|
274
|
+
local players = _newValue
|
|
275
|
+
table.sort(players, function(a, b)
|
|
276
|
+
return a.name < b.name
|
|
277
|
+
end)
|
|
278
|
+
return {
|
|
279
|
+
success = true,
|
|
280
|
+
peer = "client",
|
|
281
|
+
isRunning = RunService:IsRunning(),
|
|
282
|
+
isRunMode = RunService:IsRunMode(),
|
|
283
|
+
editModeActive = StudioTestService.EditModeActive,
|
|
284
|
+
testArgsOk = argsOk,
|
|
285
|
+
testArgs = if argsOk then args else nil,
|
|
286
|
+
testArgsError = if argsOk then nil else tostring(args),
|
|
287
|
+
players = players,
|
|
288
|
+
playerCount = #players,
|
|
289
|
+
localPlayer = if Players.LocalPlayer then Players.LocalPlayer.Name else nil,
|
|
290
|
+
canLeaveOk = canLeaveOk,
|
|
291
|
+
canLeave = if canLeaveOk then canLeave else false,
|
|
292
|
+
canLeaveError = if canLeaveOk then nil else tostring(canLeave),
|
|
293
|
+
}
|
|
294
|
+
end
|
|
295
|
+
local function handleMultiplayerTestLeaveClient()
|
|
296
|
+
local canLeaveOk, canLeave = pcall(function()
|
|
297
|
+
return StudioTestService:CanLeaveTest()
|
|
298
|
+
end)
|
|
299
|
+
if not canLeaveOk then
|
|
300
|
+
return {
|
|
301
|
+
error = tostring(canLeave),
|
|
302
|
+
canLeaveOk = false,
|
|
303
|
+
}
|
|
304
|
+
end
|
|
305
|
+
if not canLeave then
|
|
306
|
+
return {
|
|
307
|
+
error = "This client cannot leave the current test session.",
|
|
308
|
+
canLeaveOk = true,
|
|
309
|
+
canLeave = false,
|
|
310
|
+
}
|
|
311
|
+
end
|
|
312
|
+
local localPlayer = if Players.LocalPlayer then Players.LocalPlayer.Name else nil
|
|
313
|
+
task.defer(function()
|
|
314
|
+
pcall(function()
|
|
315
|
+
return StudioTestService:LeaveTest()
|
|
316
|
+
end)
|
|
317
|
+
end)
|
|
318
|
+
return {
|
|
319
|
+
success = true,
|
|
320
|
+
message = "Client leave requested.",
|
|
321
|
+
localPlayer = localPlayer,
|
|
322
|
+
}
|
|
323
|
+
end
|
|
239
324
|
local function setupClientBroker()
|
|
240
325
|
local rf = ReplicatedStorage:WaitForChild(BROKER_NAME, 10)
|
|
241
326
|
if not rf or not rf:IsA("RemoteFunction") then
|
|
@@ -255,6 +340,21 @@ local function setupClientBroker()
|
|
|
255
340
|
if payload and payload.endpoint == "/api/get-memory-breakdown" then
|
|
256
341
|
return MemoryHandlers.getMemoryBreakdown(payload.data or {})
|
|
257
342
|
end
|
|
343
|
+
if payload and payload.endpoint == "/api/multiplayer-test-state" then
|
|
344
|
+
return handleMultiplayerTestState()
|
|
345
|
+
end
|
|
346
|
+
if payload and payload.endpoint == "/api/multiplayer-test-leave-client" then
|
|
347
|
+
return handleMultiplayerTestLeaveClient()
|
|
348
|
+
end
|
|
349
|
+
if payload and payload.endpoint == "/api/capture-begin" then
|
|
350
|
+
return CaptureHandlers.captureBegin()
|
|
351
|
+
end
|
|
352
|
+
if payload and payload.endpoint == "/api/simulate-mouse-input" then
|
|
353
|
+
return InputHandlers.simulateMouseInput(payload.data or {})
|
|
354
|
+
end
|
|
355
|
+
if payload and payload.endpoint == "/api/simulate-keyboard-input" then
|
|
356
|
+
return InputHandlers.simulateKeyboardInput(payload.data or {})
|
|
357
|
+
end
|
|
258
358
|
if payload and payload.endpoint == "/api/execute-luau" then
|
|
259
359
|
return handleExecuteLuau(payload.data)
|
|
260
360
|
end
|
|
@@ -263,6 +363,7 @@ local function setupClientBroker()
|
|
|
263
363
|
end
|
|
264
364
|
end
|
|
265
365
|
local proxyByPlayer = {}
|
|
366
|
+
local serverBrokerStarted = false
|
|
266
367
|
local function pollProxy(proxyId, player, rf)
|
|
267
368
|
while true do
|
|
268
369
|
local _condition = player.Parent ~= nil
|
|
@@ -318,8 +419,12 @@ local function pollProxy(proxyId, player, rf)
|
|
|
318
419
|
}
|
|
319
420
|
end
|
|
320
421
|
else
|
|
422
|
+
local allowed = {}
|
|
423
|
+
for ep in CLIENT_BROKER_ALLOWED_ENDPOINTS do
|
|
424
|
+
table.insert(allowed, ep)
|
|
425
|
+
end
|
|
321
426
|
response = {
|
|
322
|
-
error = `Client-proxy does not forward {tostring(request.endpoint)}. ` .. `Allowed:
|
|
427
|
+
error = `Client-proxy does not forward {tostring(request.endpoint)}. ` .. `Allowed: {table.concat(allowed, ", ")}.`,
|
|
323
428
|
}
|
|
324
429
|
end
|
|
325
430
|
postJson("/response", {
|
|
@@ -370,12 +475,20 @@ end
|
|
|
370
475
|
-- plugin:SetSetting flag consumed by StopPlayMonitor in the play-server DM,
|
|
371
476
|
-- which doesn't depend on MCP server state or peer registration at all.)
|
|
372
477
|
local function setupServerBroker()
|
|
478
|
+
if serverBrokerStarted then
|
|
479
|
+
return nil
|
|
480
|
+
end
|
|
373
481
|
local rf = ReplicatedStorage:FindFirstChild(BROKER_NAME)
|
|
374
482
|
if not rf then
|
|
375
483
|
rf = Instance.new("RemoteFunction")
|
|
376
484
|
rf.Name = BROKER_NAME
|
|
377
485
|
rf.Parent = ReplicatedStorage
|
|
378
486
|
end
|
|
487
|
+
if rf:GetAttribute(BROKER_OWNER_ATTRIBUTE) ~= nil then
|
|
488
|
+
return nil
|
|
489
|
+
end
|
|
490
|
+
rf:SetAttribute(BROKER_OWNER_ATTRIBUTE, HttpService:GenerateGUID(false))
|
|
491
|
+
serverBrokerStarted = true
|
|
379
492
|
local broker = rf
|
|
380
493
|
Players.PlayerAdded:Connect(function(p)
|
|
381
494
|
return registerProxy(p, broker)
|
|
@@ -424,6 +537,7 @@ local ServerStorage = _services.ServerStorage
|
|
|
424
537
|
local State = TS.import(script, script.Parent, "State")
|
|
425
538
|
local Utils = TS.import(script, script.Parent, "Utils")
|
|
426
539
|
local UI = TS.import(script, script.Parent, "UI")
|
|
540
|
+
local ensureBridgesInstalled = TS.import(script, script.Parent, "EvalBridges").ensureBridgesInstalled
|
|
427
541
|
local QueryHandlers = TS.import(script, script.Parent, "handlers", "QueryHandlers")
|
|
428
542
|
local PropertyHandlers = TS.import(script, script.Parent, "handlers", "PropertyHandlers")
|
|
429
543
|
local InstanceHandlers = TS.import(script, script.Parent, "handlers", "InstanceHandlers")
|
|
@@ -547,6 +661,11 @@ local routeMap = {
|
|
|
547
661
|
["/api/start-playtest"] = TestHandlers.startPlaytest,
|
|
548
662
|
["/api/stop-playtest"] = TestHandlers.stopPlaytest,
|
|
549
663
|
["/api/get-playtest-output"] = TestHandlers.getPlaytestOutput,
|
|
664
|
+
["/api/multiplayer-test-start"] = TestHandlers.multiplayerTestStart,
|
|
665
|
+
["/api/multiplayer-test-state"] = TestHandlers.multiplayerTestState,
|
|
666
|
+
["/api/multiplayer-test-add-players"] = TestHandlers.multiplayerTestAddPlayers,
|
|
667
|
+
["/api/multiplayer-test-leave-client"] = TestHandlers.multiplayerTestLeaveClient,
|
|
668
|
+
["/api/multiplayer-test-end"] = TestHandlers.multiplayerTestEnd,
|
|
550
669
|
["/api/character-navigation"] = TestHandlers.characterNavigation,
|
|
551
670
|
["/api/export-build"] = BuildHandlers.exportBuild,
|
|
552
671
|
["/api/import-build"] = BuildHandlers.importBuild,
|
|
@@ -555,6 +674,8 @@ local routeMap = {
|
|
|
555
674
|
["/api/insert-asset"] = AssetHandlers.insertAsset,
|
|
556
675
|
["/api/preview-asset"] = AssetHandlers.previewAsset,
|
|
557
676
|
["/api/capture-screenshot"] = CaptureHandlers.captureScreenshot,
|
|
677
|
+
["/api/capture-begin"] = CaptureHandlers.captureBegin,
|
|
678
|
+
["/api/capture-read"] = CaptureHandlers.captureRead,
|
|
558
679
|
["/api/simulate-mouse-input"] = InputHandlers.simulateMouseInput,
|
|
559
680
|
["/api/simulate-keyboard-input"] = InputHandlers.simulateKeyboardInput,
|
|
560
681
|
["/api/find-and-replace-in-scripts"] = ScriptHandlers.findAndReplaceInScripts,
|
|
@@ -895,6 +1016,19 @@ local function activatePlugin(connIndex)
|
|
|
895
1016
|
-- Initial /ready; pollForRequests will also re-fire ready if the server
|
|
896
1017
|
-- later reports knownInstance=false (process restart, etc).
|
|
897
1018
|
sendReady(conn)
|
|
1019
|
+
-- Keep the eval bridges present in the edit DM so that ANY playtest —
|
|
1020
|
+
-- including one the dev starts manually via the Studio Play button —
|
|
1021
|
+
-- clones them into the play DMs and eval_*_runtime works with no setup
|
|
1022
|
+
-- roundtrip. Only the edit DM installs; play DMs already have the cloned
|
|
1023
|
+
-- copies. Idempotent, so reconnects don't re-dirty the place.
|
|
1024
|
+
if not RunService:IsRunning() then
|
|
1025
|
+
task.spawn(function()
|
|
1026
|
+
local result = ensureBridgesInstalled()
|
|
1027
|
+
if not result.installed then
|
|
1028
|
+
warn(`[MCPPlugin] Eval bridge install failed: {result.error}`)
|
|
1029
|
+
end
|
|
1030
|
+
end)
|
|
1031
|
+
end
|
|
898
1032
|
-- Watch for game.Name updates so a stale "Place1" captured at first
|
|
899
1033
|
-- /ready gets refreshed once Studio settles on the real DM name.
|
|
900
1034
|
ensureNameChangeWatcher(conn)
|
|
@@ -1016,11 +1150,16 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
|
1016
1150
|
-- ServerScriptService.LoadStringEnabled, so eval_server_runtime works even
|
|
1017
1151
|
-- when LoadStringEnabled=false (the default in fresh places).
|
|
1018
1152
|
--
|
|
1019
|
-
-- Lifecycle:
|
|
1020
|
-
--
|
|
1021
|
-
--
|
|
1022
|
-
--
|
|
1023
|
-
--
|
|
1153
|
+
-- Lifecycle: the bridges live PERMANENTLY in the edit DM. Communication
|
|
1154
|
+
-- installs them (ensureBridgesInstalled) when the plugin connects in edit,
|
|
1155
|
+
-- and TestHandlers.startPlaytest force-refreshes them right before
|
|
1156
|
+
-- ExecutePlayModeAsync. ExecutePlayModeAsync clones the DataModel into the
|
|
1157
|
+
-- play DMs, so the scripts come along and run there. We keep them in the edit
|
|
1158
|
+
-- DM after a playtest ends (rather than cleaning up) so that a playtest the
|
|
1159
|
+
-- dev starts MANUALLY via the Studio Play button — not the MCP start_playtest
|
|
1160
|
+
-- tool — also gets the bridges cloned in. This is intentionally a little
|
|
1161
|
+
-- intrusive (two helper scripts visible in Explorer) in exchange for a
|
|
1162
|
+
-- zero-roundtrip eval_*_runtime experience for devs working 1:1 with an agent.
|
|
1024
1163
|
--
|
|
1025
1164
|
-- Archivable handling: ExecutePlayModeAsync's deep-clone SKIPS instances
|
|
1026
1165
|
-- with Archivable=false (verified empirically in v2.9.0 testing - bridges
|
|
@@ -1052,9 +1191,9 @@ local BRIDGE_NAMES = {
|
|
|
1052
1191
|
-- Embedded Luau. The double `${...}` references our exported names so a
|
|
1053
1192
|
-- rename here propagates to both the script source and the tool wrappers.
|
|
1054
1193
|
local SERVER_BRIDGE_SOURCE = `\
|
|
1055
|
-
--
|
|
1056
|
-
--
|
|
1057
|
-
--
|
|
1194
|
+
-- Installed by @chrrxs/robloxstudio-mcp to power the eval_server_runtime MCP\
|
|
1195
|
+
-- tool (shared-require-cache eval on the server during playtests). Inert\
|
|
1196
|
+
-- outside Studio (no-ops in live games); safe to leave in place.\
|
|
1058
1197
|
\
|
|
1059
1198
|
local ServerScriptService = game:GetService("ServerScriptService")\
|
|
1060
1199
|
local RunService = game:GetService("RunService")\
|
|
@@ -1078,9 +1217,9 @@ bf.OnInvoke = function(payload)\
|
|
|
1078
1217
|
end\
|
|
1079
1218
|
`
|
|
1080
1219
|
local CLIENT_BRIDGE_SOURCE = `\
|
|
1081
|
-
--
|
|
1082
|
-
--
|
|
1083
|
-
--
|
|
1220
|
+
-- Installed by @chrrxs/robloxstudio-mcp to power the eval_client_runtime MCP\
|
|
1221
|
+
-- tool (shared-require-cache eval on the client during playtests). Inert\
|
|
1222
|
+
-- outside Studio (no-ops in live games); safe to leave in place.\
|
|
1084
1223
|
\
|
|
1085
1224
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")\
|
|
1086
1225
|
local RunService = game:GetService("RunService")\
|
|
@@ -1103,6 +1242,25 @@ bf.OnInvoke = function(payload)\
|
|
|
1103
1242
|
return pcall(require, payload)\
|
|
1104
1243
|
end\
|
|
1105
1244
|
`
|
|
1245
|
+
-- Stamp written onto each installed bridge Script so we can tell whether the
|
|
1246
|
+
-- bridge currently in the DM was produced by THIS plugin build. It's a djb2
|
|
1247
|
+
-- hash of the actual bridge source plus the plugin version, so ANY change to
|
|
1248
|
+
-- the source (or a version bump) yields a new stamp — which makes
|
|
1249
|
+
-- ensureBridgesInstalled() force a refresh on the next plugin load instead of
|
|
1250
|
+
-- keeping a stale bridge that happens to still be present (e.g. one saved into
|
|
1251
|
+
-- the .rbxl from an older build).
|
|
1252
|
+
local STAMP_ATTR = "__MCPBridgeStamp"
|
|
1253
|
+
local function computeBridgeStamp()
|
|
1254
|
+
local combined = `{SERVER_BRIDGE_SOURCE}|{CLIENT_BRIDGE_SOURCE}`
|
|
1255
|
+
local h = 5381
|
|
1256
|
+
for i = 1, #combined do
|
|
1257
|
+
h = (h * 33 + (string.byte(combined, i))) % 2147483647
|
|
1258
|
+
end
|
|
1259
|
+
-- "2.14.0" is replaced with the package version at package time
|
|
1260
|
+
-- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
|
|
1261
|
+
return `{tostring(h)}-2.14.0`
|
|
1262
|
+
end
|
|
1263
|
+
local BRIDGE_STAMP = computeBridgeStamp()
|
|
1106
1264
|
local function setSource(scriptInst, source)
|
|
1107
1265
|
-- ScriptEditorService is the cleaner API and integrates with Studio's
|
|
1108
1266
|
-- edit history; fall back to direct Source mutation (allowed in plugin
|
|
@@ -1138,7 +1296,31 @@ local function cleanupBridges()
|
|
|
1138
1296
|
end)
|
|
1139
1297
|
end
|
|
1140
1298
|
end
|
|
1141
|
-
|
|
1299
|
+
-- Idempotent variant: install only if the bridge scripts aren't already
|
|
1300
|
+
-- present in the edit DM. Used to keep the bridges always available (so a
|
|
1301
|
+
-- playtest the dev starts manually — not via the MCP start_playtest tool —
|
|
1302
|
+
-- still clones them into the play DMs). Cheap no-op when already installed,
|
|
1303
|
+
-- which avoids re-dirtying the place on every plugin reconnect.
|
|
1304
|
+
local installBridges
|
|
1305
|
+
local function ensureBridgesInstalled()
|
|
1306
|
+
local _binding = findBridges()
|
|
1307
|
+
local server = _binding.server
|
|
1308
|
+
local client = _binding.client
|
|
1309
|
+
if server and client then
|
|
1310
|
+
-- Both present — but only skip the reinstall if they were produced by
|
|
1311
|
+
-- THIS build. A mismatched/absent stamp means a stale bridge (older
|
|
1312
|
+
-- plugin, or one persisted in the saved place), so force a refresh.
|
|
1313
|
+
local sStamp = server:GetAttribute(STAMP_ATTR)
|
|
1314
|
+
local cStamp = client:GetAttribute(STAMP_ATTR)
|
|
1315
|
+
if sStamp == BRIDGE_STAMP and cStamp == BRIDGE_STAMP then
|
|
1316
|
+
return {
|
|
1317
|
+
installed = true,
|
|
1318
|
+
}
|
|
1319
|
+
end
|
|
1320
|
+
end
|
|
1321
|
+
return installBridges()
|
|
1322
|
+
end
|
|
1323
|
+
function installBridges()
|
|
1142
1324
|
-- Defensive: clear any stale bridges from a prior unclean exit before
|
|
1143
1325
|
-- inserting fresh. The injected script also self-cleans its
|
|
1144
1326
|
-- ReplicatedStorage/ServerScriptService children at startup, but the
|
|
@@ -1151,6 +1333,7 @@ local function installBridges()
|
|
|
1151
1333
|
-- script. cleanupBridges() removes it from the edit DM when the
|
|
1152
1334
|
-- playtest ends.
|
|
1153
1335
|
setSource(serverScript, SERVER_BRIDGE_SOURCE)
|
|
1336
|
+
serverScript:SetAttribute(STAMP_ATTR, BRIDGE_STAMP)
|
|
1154
1337
|
serverScript.Parent = ServerScriptService
|
|
1155
1338
|
local sps = getStarterPlayerScripts()
|
|
1156
1339
|
if not sps then
|
|
@@ -1159,6 +1342,7 @@ local function installBridges()
|
|
|
1159
1342
|
local clientScript = Instance.new("LocalScript")
|
|
1160
1343
|
clientScript.Name = CLIENT_SCRIPT_NAME
|
|
1161
1344
|
setSource(clientScript, CLIENT_BRIDGE_SOURCE)
|
|
1345
|
+
clientScript:SetAttribute(STAMP_ATTR, BRIDGE_STAMP)
|
|
1162
1346
|
clientScript.Parent = sps
|
|
1163
1347
|
end)
|
|
1164
1348
|
if not ok then
|
|
@@ -1173,6 +1357,7 @@ local function installBridges()
|
|
|
1173
1357
|
end
|
|
1174
1358
|
return {
|
|
1175
1359
|
cleanupBridges = cleanupBridges,
|
|
1360
|
+
ensureBridgesInstalled = ensureBridgesInstalled,
|
|
1176
1361
|
installBridges = installBridges,
|
|
1177
1362
|
BRIDGE_NAMES = BRIDGE_NAMES,
|
|
1178
1363
|
}
|
|
@@ -2038,6 +2223,8 @@ return {
|
|
|
2038
2223
|
<Properties>
|
|
2039
2224
|
<string name="Name">CaptureHandlers</string>
|
|
2040
2225
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
2226
|
+
local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
|
|
2227
|
+
local RenderMonitor = TS.import(script, script.Parent.Parent, "RenderMonitor")
|
|
2041
2228
|
local CaptureService = game:GetService("CaptureService")
|
|
2042
2229
|
local AssetService = game:GetService("AssetService")
|
|
2043
2230
|
local MAX_TILE_SIZE = 1024
|
|
@@ -2147,7 +2334,20 @@ local function readPixelsTiled(img, w, h)
|
|
|
2147
2334
|
end
|
|
2148
2335
|
return fullBuf
|
|
2149
2336
|
end
|
|
2150
|
-
|
|
2337
|
+
-- Triggers CaptureService:CaptureScreenshot and waits for the temporary
|
|
2338
|
+
-- content id. Works in any DM, including the play CLIENT (where reading the
|
|
2339
|
+
-- pixels back is blocked, but capturing is not). The returned rbxtemp:// id is
|
|
2340
|
+
-- a process-scoped handle: it can be dereferenced from a DIFFERENT, more
|
|
2341
|
+
-- privileged DM (the edit DM) — see captureRead.
|
|
2342
|
+
local function doCaptureScreenshot()
|
|
2343
|
+
-- Fast-fail with a clear reason if the window isn't rendering — otherwise
|
|
2344
|
+
-- CaptureScreenshot's callback never fires and we'd block for the full 10s.
|
|
2345
|
+
local notRendering = RenderMonitor.notRenderingReason()
|
|
2346
|
+
if notRendering ~= nil then
|
|
2347
|
+
return {
|
|
2348
|
+
error = notRendering,
|
|
2349
|
+
}
|
|
2350
|
+
end
|
|
2151
2351
|
local contentId
|
|
2152
2352
|
CaptureService:CaptureScreenshot(function(id)
|
|
2153
2353
|
contentId = id
|
|
@@ -2156,11 +2356,21 @@ local function captureScreenshotData()
|
|
|
2156
2356
|
while contentId == nil do
|
|
2157
2357
|
if tick() - startTime > 10 then
|
|
2158
2358
|
return {
|
|
2159
|
-
error = "Screenshot capture timed out.
|
|
2359
|
+
error = "Screenshot capture timed out (CaptureScreenshot callback never fired). The Studio window is likely minimized or occluded — restore it so the viewport renders. (Known Roblox bug: capture can also fail if the viewport renders a solid color.)",
|
|
2160
2360
|
}
|
|
2161
2361
|
end
|
|
2162
2362
|
task.wait(0.1)
|
|
2163
2363
|
end
|
|
2364
|
+
return {
|
|
2365
|
+
contentId = contentId,
|
|
2366
|
+
}
|
|
2367
|
+
end
|
|
2368
|
+
-- Promotes a CaptureScreenshot content id into an EditableImage and reads its
|
|
2369
|
+
-- RGBA pixels. MUST run in the edit/plugin context: the running game VM lacks
|
|
2370
|
+
-- the privilege to create an EditableImage from a temporary texture id (errors
|
|
2371
|
+
-- "cannot currently create editable image from temporary texture id"), while
|
|
2372
|
+
-- the edit DM can — even for an id captured in the play client DM.
|
|
2373
|
+
local function readContentToBase64(contentId)
|
|
2164
2374
|
local editableOk, editableResult = pcall(function()
|
|
2165
2375
|
return AssetService:CreateEditableImageAsync(Content.fromUri(contentId))
|
|
2166
2376
|
end)
|
|
@@ -2190,12 +2400,36 @@ local function captureScreenshotData()
|
|
|
2190
2400
|
data = base64Data,
|
|
2191
2401
|
}
|
|
2192
2402
|
end
|
|
2403
|
+
-- Edit-mode single shot: capture and read back in the same (edit) context.
|
|
2404
|
+
local function captureScreenshotData()
|
|
2405
|
+
local cap = doCaptureScreenshot()
|
|
2406
|
+
if cap.error ~= nil then
|
|
2407
|
+
return cap
|
|
2408
|
+
end
|
|
2409
|
+
return readContentToBase64(cap.contentId)
|
|
2410
|
+
end
|
|
2193
2411
|
local function captureScreenshot()
|
|
2194
2412
|
return captureScreenshotData()
|
|
2195
2413
|
end
|
|
2414
|
+
-- Play-mode step 1 (run on the CLIENT): capture only, return the temp id.
|
|
2415
|
+
local function captureBegin()
|
|
2416
|
+
return doCaptureScreenshot()
|
|
2417
|
+
end
|
|
2418
|
+
-- Play-mode step 2 (run on EDIT): read pixels from a temp id captured elsewhere.
|
|
2419
|
+
local function captureRead(requestData)
|
|
2420
|
+
local contentId = requestData.contentId
|
|
2421
|
+
if not (contentId ~= "" and contentId) then
|
|
2422
|
+
return {
|
|
2423
|
+
error = "contentId is required",
|
|
2424
|
+
}
|
|
2425
|
+
end
|
|
2426
|
+
return readContentToBase64(contentId)
|
|
2427
|
+
end
|
|
2196
2428
|
return {
|
|
2197
2429
|
captureScreenshotData = captureScreenshotData,
|
|
2198
2430
|
captureScreenshot = captureScreenshot,
|
|
2431
|
+
captureBegin = captureBegin,
|
|
2432
|
+
captureRead = captureRead,
|
|
2199
2433
|
}
|
|
2200
2434
|
]]></string>
|
|
2201
2435
|
</Properties>
|
|
@@ -2204,19 +2438,56 @@ return {
|
|
|
2204
2438
|
<Properties>
|
|
2205
2439
|
<string name="Name">InputHandlers</string>
|
|
2206
2440
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
2207
|
-
local
|
|
2208
|
-
|
|
2209
|
-
|
|
2441
|
+
local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
|
|
2442
|
+
-- Virtual input via UserInputService:CreateVirtualInput().
|
|
2443
|
+
--
|
|
2444
|
+
-- We deliberately do NOT use VirtualInputManager:Send*Event — those methods
|
|
2445
|
+
-- are gated behind RobloxScriptSecurity ("lacking capability RobloxScript")
|
|
2446
|
+
-- in every context a plugin can reach (edit DM, play server/client DMs), so
|
|
2447
|
+
-- they silently never worked. CreateVirtualInput() is callable without that
|
|
2448
|
+
-- capability and drives the REAL input pipeline: SendKey feeds
|
|
2449
|
+
-- UserInputService.InputBegan/Ended and the control modules (so WASD walks the
|
|
2450
|
+
-- character at full WalkSpeed with controls intact, no Humanoid hijack),
|
|
2451
|
+
-- SendMouseButton feeds UIS and activates GUI buttons (and hit-tests against
|
|
2452
|
+
-- CoreGui), and SendTextInput types into the focused TextBox.
|
|
2453
|
+
--
|
|
2454
|
+
-- Method set on the VirtualInput object (verified live):
|
|
2455
|
+
-- SendKey(isDown: boolean, keyCode: Enum.KeyCode)
|
|
2456
|
+
-- SendMouseButton(position: Vector2, inputType: Enum.UserInputType, isDown: boolean)
|
|
2457
|
+
-- SendTextInput(text: string)
|
|
2458
|
+
-- There is NO SendMouseMove / SendMouseWheel / SendKeyEvent — so "move" and
|
|
2459
|
+
-- "scroll" mouse actions are not supported.
|
|
2460
|
+
--
|
|
2461
|
+
-- Coordinate space: SendMouseButton coordinates are viewport pixels matching
|
|
2462
|
+
-- what capture_screenshot returns (window space, origin at the top-left of the
|
|
2463
|
+
-- rendered viewport). Pass screenshot pixel coordinates straight through. Note
|
|
2464
|
+
-- that UserInputService reports input positions in GUI space, which is offset
|
|
2465
|
+
-- from this by GuiService:GetGuiInset() (~58px on the Y axis) — irrelevant for
|
|
2466
|
+
-- callers who pick coordinates off a screenshot, which is why we do not
|
|
2467
|
+
-- translate here.
|
|
2468
|
+
local RenderMonitor = TS.import(script, script.Parent.Parent, "RenderMonitor")
|
|
2469
|
+
local UserInputService = game:GetService("UserInputService")
|
|
2470
|
+
-- One VirtualInput per plugin VM, reused across calls so that a key held down
|
|
2471
|
+
-- in one call (action="press") and released in a later call (action="release")
|
|
2472
|
+
-- share the same input source.
|
|
2473
|
+
local cachedVI
|
|
2474
|
+
local function getVI()
|
|
2475
|
+
if cachedVI then
|
|
2476
|
+
return cachedVI
|
|
2477
|
+
end
|
|
2478
|
+
local ok, vi = pcall(function()
|
|
2479
|
+
return UserInputService:CreateVirtualInput()
|
|
2210
2480
|
end)
|
|
2211
|
-
if ok and
|
|
2212
|
-
|
|
2481
|
+
if ok and vi ~= nil then
|
|
2482
|
+
cachedVI = vi
|
|
2483
|
+
return cachedVI
|
|
2213
2484
|
end
|
|
2214
2485
|
return nil
|
|
2215
2486
|
end
|
|
2216
|
-
local
|
|
2217
|
-
Left =
|
|
2218
|
-
Right =
|
|
2219
|
-
Middle =
|
|
2487
|
+
local MOUSE_TYPE_MAP = {
|
|
2488
|
+
Left = Enum.UserInputType.MouseButton1,
|
|
2489
|
+
Right = Enum.UserInputType.MouseButton2,
|
|
2490
|
+
Middle = Enum.UserInputType.MouseButton3,
|
|
2220
2491
|
}
|
|
2221
2492
|
local function simulateMouseInput(requestData)
|
|
2222
2493
|
local action = requestData.action
|
|
@@ -2227,56 +2498,43 @@ local function simulateMouseInput(requestData)
|
|
|
2227
2498
|
_condition = "Left"
|
|
2228
2499
|
end
|
|
2229
2500
|
local button = _condition
|
|
2230
|
-
local scrollDirection = requestData.scrollDirection
|
|
2231
2501
|
if not (action ~= "" and action) then
|
|
2232
2502
|
return {
|
|
2233
2503
|
error = "action is required",
|
|
2234
2504
|
}
|
|
2235
2505
|
end
|
|
2236
|
-
|
|
2237
|
-
if not vim then
|
|
2506
|
+
if x == nil or y == nil then
|
|
2238
2507
|
return {
|
|
2239
|
-
error = "
|
|
2508
|
+
error = "x and y are required",
|
|
2240
2509
|
}
|
|
2241
2510
|
end
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2511
|
+
-- Input is silently dropped by the engine when the window isn't rendering
|
|
2512
|
+
-- (e.g. minimized). Surface that instead of returning a false success.
|
|
2513
|
+
local notRendering = RenderMonitor.notRenderingReason()
|
|
2514
|
+
if notRendering ~= nil then
|
|
2515
|
+
return {
|
|
2516
|
+
error = notRendering,
|
|
2517
|
+
}
|
|
2518
|
+
end
|
|
2519
|
+
local vi = getVI()
|
|
2520
|
+
if not vi then
|
|
2521
|
+
return {
|
|
2522
|
+
error = "UserInputService:CreateVirtualInput() is not available in this context",
|
|
2523
|
+
}
|
|
2245
2524
|
end
|
|
2246
|
-
local
|
|
2525
|
+
local inputType = MOUSE_TYPE_MAP[button] or Enum.UserInputType.MouseButton1
|
|
2526
|
+
local pos = Vector2.new(x, y)
|
|
2247
2527
|
local success, err = pcall(function()
|
|
2248
2528
|
if action == "click" then
|
|
2249
|
-
|
|
2250
|
-
error("x and y are required for click")
|
|
2251
|
-
end
|
|
2252
|
-
vim:SendMouseButtonEvent(x, y, buttonNum, true)
|
|
2529
|
+
vi:SendMouseButton(pos, inputType, true)
|
|
2253
2530
|
task.wait(0.05)
|
|
2254
|
-
|
|
2531
|
+
vi:SendMouseButton(pos, inputType, false)
|
|
2255
2532
|
elseif action == "mouseDown" then
|
|
2256
|
-
|
|
2257
|
-
error("x and y are required for mouseDown")
|
|
2258
|
-
end
|
|
2259
|
-
vim:SendMouseButtonEvent(x, y, buttonNum, true)
|
|
2533
|
+
vi:SendMouseButton(pos, inputType, true)
|
|
2260
2534
|
elseif action == "mouseUp" then
|
|
2261
|
-
|
|
2262
|
-
error("x and y are required for mouseUp")
|
|
2263
|
-
end
|
|
2264
|
-
vim:SendMouseButtonEvent(x, y, buttonNum, false)
|
|
2265
|
-
elseif action == "move" then
|
|
2266
|
-
if x == nil or y == nil then
|
|
2267
|
-
error("x and y are required for move")
|
|
2268
|
-
end
|
|
2269
|
-
vim:SendMouseMoveEvent(x, y)
|
|
2270
|
-
elseif action == "scroll" then
|
|
2271
|
-
if x == nil or y == nil then
|
|
2272
|
-
error("x and y are required for scroll")
|
|
2273
|
-
end
|
|
2274
|
-
if not (scrollDirection ~= "" and scrollDirection) then
|
|
2275
|
-
error("scrollDirection is required for scroll")
|
|
2276
|
-
end
|
|
2277
|
-
vim:SendMouseWheelEvent(x, y, scrollDirection == "up")
|
|
2535
|
+
vi:SendMouseButton(pos, inputType, false)
|
|
2278
2536
|
else
|
|
2279
|
-
error(`
|
|
2537
|
+
error(`Unsupported action "{action}". CreateVirtualInput supports click, mouseDown, mouseUp ` .. `(no move/scroll — those methods don't exist on VirtualInput).`)
|
|
2280
2538
|
end
|
|
2281
2539
|
end)
|
|
2282
2540
|
if success then
|
|
@@ -2293,7 +2551,40 @@ local function simulateMouseInput(requestData)
|
|
|
2293
2551
|
}
|
|
2294
2552
|
end
|
|
2295
2553
|
local function simulateKeyboardInput(requestData)
|
|
2554
|
+
local notRendering = RenderMonitor.notRenderingReason()
|
|
2555
|
+
if notRendering ~= nil then
|
|
2556
|
+
return {
|
|
2557
|
+
error = notRendering,
|
|
2558
|
+
}
|
|
2559
|
+
end
|
|
2560
|
+
local vi = getVI()
|
|
2561
|
+
if not vi then
|
|
2562
|
+
return {
|
|
2563
|
+
error = "UserInputService:CreateVirtualInput() is not available in this context",
|
|
2564
|
+
}
|
|
2565
|
+
end
|
|
2566
|
+
-- Text mode: type a string into the focused TextBox.
|
|
2567
|
+
local text = requestData.text
|
|
2568
|
+
if text ~= nil then
|
|
2569
|
+
local ok, err = pcall(function()
|
|
2570
|
+
return vi:SendTextInput(text)
|
|
2571
|
+
end)
|
|
2572
|
+
if ok then
|
|
2573
|
+
return {
|
|
2574
|
+
success = true,
|
|
2575
|
+
text = text,
|
|
2576
|
+
}
|
|
2577
|
+
end
|
|
2578
|
+
return {
|
|
2579
|
+
error = `Failed to send text input: {err}`,
|
|
2580
|
+
}
|
|
2581
|
+
end
|
|
2296
2582
|
local keyCodeName = requestData.keyCode
|
|
2583
|
+
if not (keyCodeName ~= "" and keyCodeName) then
|
|
2584
|
+
return {
|
|
2585
|
+
error = "keyCode (or text) is required",
|
|
2586
|
+
}
|
|
2587
|
+
end
|
|
2297
2588
|
local _condition = (requestData.action)
|
|
2298
2589
|
if _condition == nil then
|
|
2299
2590
|
_condition = "tap"
|
|
@@ -2304,17 +2595,6 @@ local function simulateKeyboardInput(requestData)
|
|
|
2304
2595
|
_condition_1 = 0.1
|
|
2305
2596
|
end
|
|
2306
2597
|
local duration = _condition_1
|
|
2307
|
-
if not (keyCodeName ~= "" and keyCodeName) then
|
|
2308
|
-
return {
|
|
2309
|
-
error = "keyCode is required",
|
|
2310
|
-
}
|
|
2311
|
-
end
|
|
2312
|
-
local vim = getVIM()
|
|
2313
|
-
if not vim then
|
|
2314
|
-
return {
|
|
2315
|
-
error = "VirtualInputManager is not available in this context",
|
|
2316
|
-
}
|
|
2317
|
-
end
|
|
2318
2598
|
local enumOk, keyCode = pcall(function()
|
|
2319
2599
|
return (Enum.KeyCode)[keyCodeName]
|
|
2320
2600
|
end)
|
|
@@ -2325,13 +2605,13 @@ local function simulateKeyboardInput(requestData)
|
|
|
2325
2605
|
end
|
|
2326
2606
|
local success, err = pcall(function()
|
|
2327
2607
|
if action == "press" then
|
|
2328
|
-
|
|
2608
|
+
vi:SendKey(true, keyCode)
|
|
2329
2609
|
elseif action == "release" then
|
|
2330
|
-
|
|
2610
|
+
vi:SendKey(false, keyCode)
|
|
2331
2611
|
elseif action == "tap" then
|
|
2332
|
-
|
|
2612
|
+
vi:SendKey(true, keyCode)
|
|
2333
2613
|
task.wait(duration)
|
|
2334
|
-
|
|
2614
|
+
vi:SendKey(false, keyCode)
|
|
2335
2615
|
else
|
|
2336
2616
|
error(`Unknown action: {action}`)
|
|
2337
2617
|
end
|
|
@@ -5679,10 +5959,11 @@ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
|
|
|
5679
5959
|
local _services = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services")
|
|
5680
5960
|
local HttpService = _services.HttpService
|
|
5681
5961
|
local LogService = _services.LogService
|
|
5962
|
+
local Players = _services.Players
|
|
5682
5963
|
local RunService = _services.RunService
|
|
5683
5964
|
local _EvalBridges = TS.import(script, script.Parent.Parent, "EvalBridges")
|
|
5684
5965
|
local installBridges = _EvalBridges.installBridges
|
|
5685
|
-
local
|
|
5966
|
+
local ensureBridgesInstalled = _EvalBridges.ensureBridgesInstalled
|
|
5686
5967
|
local StopPlayMonitor = TS.import(script, script.Parent.Parent, "StopPlayMonitor")
|
|
5687
5968
|
local StudioTestService = game:GetService("StudioTestService")
|
|
5688
5969
|
local ServerScriptService = game:GetService("ServerScriptService")
|
|
@@ -5700,6 +5981,63 @@ local testResult
|
|
|
5700
5981
|
local testError
|
|
5701
5982
|
local stopListenerScript
|
|
5702
5983
|
local navResultCallback
|
|
5984
|
+
local multiplayerState = {
|
|
5985
|
+
phase = "idle",
|
|
5986
|
+
}
|
|
5987
|
+
local function detectPeerRole()
|
|
5988
|
+
if not RunService:IsRunning() then
|
|
5989
|
+
return "edit"
|
|
5990
|
+
end
|
|
5991
|
+
if RunService:IsServer() then
|
|
5992
|
+
return "server"
|
|
5993
|
+
end
|
|
5994
|
+
return "client"
|
|
5995
|
+
end
|
|
5996
|
+
local function getPlayersSnapshot()
|
|
5997
|
+
local _exp = Players:GetPlayers()
|
|
5998
|
+
-- ▼ ReadonlyArray.map ▼
|
|
5999
|
+
local _newValue = table.create(#_exp)
|
|
6000
|
+
local _callback = function(player)
|
|
6001
|
+
return {
|
|
6002
|
+
name = player.Name,
|
|
6003
|
+
userId = player.UserId,
|
|
6004
|
+
displayName = player.DisplayName,
|
|
6005
|
+
}
|
|
6006
|
+
end
|
|
6007
|
+
for _k, _v in _exp do
|
|
6008
|
+
_newValue[_k] = _callback(_v, _k - 1, _exp)
|
|
6009
|
+
end
|
|
6010
|
+
-- ▲ ReadonlyArray.map ▲
|
|
6011
|
+
local players = _newValue
|
|
6012
|
+
table.sort(players, function(a, b)
|
|
6013
|
+
return a.name < b.name
|
|
6014
|
+
end)
|
|
6015
|
+
return players
|
|
6016
|
+
end
|
|
6017
|
+
local function cloneMultiplayerState()
|
|
6018
|
+
return {
|
|
6019
|
+
phase = multiplayerState.phase,
|
|
6020
|
+
testId = multiplayerState.testId,
|
|
6021
|
+
numPlayers = multiplayerState.numPlayers,
|
|
6022
|
+
testArgs = multiplayerState.testArgs,
|
|
6023
|
+
startedAt = multiplayerState.startedAt,
|
|
6024
|
+
completedAt = multiplayerState.completedAt,
|
|
6025
|
+
ok = multiplayerState.ok,
|
|
6026
|
+
result = multiplayerState.result,
|
|
6027
|
+
error = multiplayerState.error,
|
|
6028
|
+
}
|
|
6029
|
+
end
|
|
6030
|
+
local function normalizeNumPlayers(value)
|
|
6031
|
+
local _value = value
|
|
6032
|
+
if not (type(_value) == "number") then
|
|
6033
|
+
return nil
|
|
6034
|
+
end
|
|
6035
|
+
local n = math.floor(value)
|
|
6036
|
+
if n ~= value or n < 1 or n > 8 then
|
|
6037
|
+
return nil
|
|
6038
|
+
end
|
|
6039
|
+
return n
|
|
6040
|
+
end
|
|
5703
6041
|
local function buildCommandListenerSource()
|
|
5704
6042
|
return `local LogService = game:GetService("LogService")\
|
|
5705
6043
|
local PathfindingService = game:GetService("PathfindingService")\
|
|
@@ -5796,6 +6134,11 @@ local function startPlaytest(requestData)
|
|
|
5796
6134
|
error = 'mode must be "play" or "run"',
|
|
5797
6135
|
}
|
|
5798
6136
|
end
|
|
6137
|
+
if numPlayers ~= nil then
|
|
6138
|
+
return {
|
|
6139
|
+
error = "start_playtest is single-player only. Use multiplayer_test_start for multi-client StudioTestService sessions.",
|
|
6140
|
+
}
|
|
6141
|
+
end
|
|
5799
6142
|
-- Self-heal: if testRunning is stuck true but Studio reports no active
|
|
5800
6143
|
-- playtest, the previous start_playtest's task.spawn was orphaned
|
|
5801
6144
|
-- (plugin reload mid-test, Studio entered some inconsistent state, etc).
|
|
@@ -5807,7 +6150,9 @@ local function startPlaytest(requestData)
|
|
|
5807
6150
|
logConnection = nil
|
|
5808
6151
|
end
|
|
5809
6152
|
cleanupStopListener()
|
|
5810
|
-
|
|
6153
|
+
-- Note: eval bridges are intentionally NOT cleaned up — they live
|
|
6154
|
+
-- permanently in the edit DM so manual playtests also get them. See
|
|
6155
|
+
-- EvalBridges.ts lifecycle comment.
|
|
5811
6156
|
end
|
|
5812
6157
|
if testRunning then
|
|
5813
6158
|
return {
|
|
@@ -5850,17 +6195,14 @@ local function startPlaytest(requestData)
|
|
|
5850
6195
|
if not injected then
|
|
5851
6196
|
warn(`[MCP] Failed to inject stop listener: {injErr}`)
|
|
5852
6197
|
end
|
|
5853
|
-
--
|
|
5854
|
-
-- so
|
|
5855
|
-
--
|
|
6198
|
+
-- Force-refresh the game-VM eval bridges (ServerEvalBridge + ClientEvalBridge)
|
|
6199
|
+
-- right before cloning so the play DMs get the current source. They also
|
|
6200
|
+
-- live permanently in the edit DM (installed on connect) so manually-started
|
|
6201
|
+
-- playtests get them too; here we just ensure they're fresh.
|
|
5856
6202
|
local bridgeInstall = installBridges()
|
|
5857
6203
|
if not bridgeInstall.installed then
|
|
5858
6204
|
warn(`[MCP] Eval bridge install failed: {bridgeInstall.error}`)
|
|
5859
6205
|
end
|
|
5860
|
-
if numPlayers ~= nil and mode == "run" then
|
|
5861
|
-
local TestService = game:GetService("TestService")
|
|
5862
|
-
TestService.NumberOfPlayers = math.clamp(numPlayers, 1, 8)
|
|
5863
|
-
end
|
|
5864
6206
|
task.spawn(function()
|
|
5865
6207
|
local ok, result = pcall(function()
|
|
5866
6208
|
if mode == "play" then
|
|
@@ -5879,12 +6221,13 @@ local function startPlaytest(requestData)
|
|
|
5879
6221
|
end
|
|
5880
6222
|
testRunning = false
|
|
5881
6223
|
cleanupStopListener()
|
|
5882
|
-
|
|
6224
|
+
-- Eval bridges persist in the edit DM (see EvalBridges.ts) — do not
|
|
6225
|
+
-- clean up here, so the next manual playtest still gets them.
|
|
6226
|
+
ensureBridgesInstalled()
|
|
5883
6227
|
end)
|
|
5884
|
-
local msg = if numPlayers ~= nil then `Playtest started in {mode} mode with {numPlayers} player(s).` else `Playtest started in {mode} mode.`
|
|
5885
6228
|
local response = {
|
|
5886
6229
|
success = true,
|
|
5887
|
-
message =
|
|
6230
|
+
message = `Playtest started in {mode} mode.`,
|
|
5888
6231
|
}
|
|
5889
6232
|
-- Only mention eval bridges when they failed — when they're fine, the
|
|
5890
6233
|
-- detail is noise. eval_server_runtime / eval_client_runtime will surface
|
|
@@ -5967,6 +6310,198 @@ local function getPlaytestOutput(_requestData)
|
|
|
5967
6310
|
_object.testError = testError
|
|
5968
6311
|
return _object
|
|
5969
6312
|
end
|
|
6313
|
+
local function multiplayerTestStart(requestData)
|
|
6314
|
+
if RunService:IsRunning() then
|
|
6315
|
+
return {
|
|
6316
|
+
error = "multiplayer_test_start must be called on the edit DataModel. Route with target=edit.",
|
|
6317
|
+
}
|
|
6318
|
+
end
|
|
6319
|
+
local numPlayers = normalizeNumPlayers(requestData.numPlayers)
|
|
6320
|
+
if numPlayers == nil then
|
|
6321
|
+
return {
|
|
6322
|
+
error = "numPlayers must be an integer from 1 to 8",
|
|
6323
|
+
}
|
|
6324
|
+
end
|
|
6325
|
+
if multiplayerState.phase == "starting" or multiplayerState.phase == "running" then
|
|
6326
|
+
return {
|
|
6327
|
+
error = "A multiplayer Studio test is already running",
|
|
6328
|
+
state = cloneMultiplayerState(),
|
|
6329
|
+
}
|
|
6330
|
+
end
|
|
6331
|
+
local testArgs = if requestData.testArgs ~= nil then requestData.testArgs else {}
|
|
6332
|
+
local testId = HttpService:GenerateGUID(false)
|
|
6333
|
+
local bridgeInstall = installBridges()
|
|
6334
|
+
if not bridgeInstall.installed then
|
|
6335
|
+
warn(`[MCP] Eval bridge install failed: {bridgeInstall.error}`)
|
|
6336
|
+
end
|
|
6337
|
+
multiplayerState = {
|
|
6338
|
+
phase = "starting",
|
|
6339
|
+
testId = testId,
|
|
6340
|
+
numPlayers = numPlayers,
|
|
6341
|
+
testArgs = testArgs,
|
|
6342
|
+
startedAt = tick(),
|
|
6343
|
+
}
|
|
6344
|
+
task.spawn(function()
|
|
6345
|
+
multiplayerState.phase = "running"
|
|
6346
|
+
local ok, result = pcall(function()
|
|
6347
|
+
return StudioTestService:ExecuteMultiplayerTestAsync(numPlayers, testArgs)
|
|
6348
|
+
end)
|
|
6349
|
+
multiplayerState.completedAt = tick()
|
|
6350
|
+
multiplayerState.ok = ok
|
|
6351
|
+
if ok then
|
|
6352
|
+
multiplayerState.phase = "completed"
|
|
6353
|
+
multiplayerState.result = result
|
|
6354
|
+
multiplayerState.error = nil
|
|
6355
|
+
else
|
|
6356
|
+
multiplayerState.phase = "failed"
|
|
6357
|
+
multiplayerState.result = nil
|
|
6358
|
+
multiplayerState.error = tostring(result)
|
|
6359
|
+
end
|
|
6360
|
+
ensureBridgesInstalled()
|
|
6361
|
+
end)
|
|
6362
|
+
local response = {
|
|
6363
|
+
success = true,
|
|
6364
|
+
message = `Multiplayer Studio test starting with {numPlayers} player(s).`,
|
|
6365
|
+
testId = testId,
|
|
6366
|
+
phase = multiplayerState.phase,
|
|
6367
|
+
numPlayers = numPlayers,
|
|
6368
|
+
testArgs = testArgs,
|
|
6369
|
+
}
|
|
6370
|
+
if not bridgeInstall.installed then
|
|
6371
|
+
response.evalBridgesError = bridgeInstall.error
|
|
6372
|
+
end
|
|
6373
|
+
return response
|
|
6374
|
+
end
|
|
6375
|
+
local function multiplayerTestState(_requestData)
|
|
6376
|
+
local peer = detectPeerRole()
|
|
6377
|
+
local response = {
|
|
6378
|
+
success = true,
|
|
6379
|
+
peer = peer,
|
|
6380
|
+
isRunning = RunService:IsRunning(),
|
|
6381
|
+
isRunMode = RunService:IsRunMode(),
|
|
6382
|
+
editModeActive = StudioTestService.EditModeActive,
|
|
6383
|
+
}
|
|
6384
|
+
if peer == "edit" then
|
|
6385
|
+
response.session = cloneMultiplayerState()
|
|
6386
|
+
return response
|
|
6387
|
+
end
|
|
6388
|
+
local argsOk, args = pcall(function()
|
|
6389
|
+
return StudioTestService:GetTestArgs()
|
|
6390
|
+
end)
|
|
6391
|
+
response.testArgsOk = argsOk
|
|
6392
|
+
response.testArgs = if argsOk then args else nil
|
|
6393
|
+
if not argsOk then
|
|
6394
|
+
response.testArgsError = tostring(args)
|
|
6395
|
+
end
|
|
6396
|
+
local players = getPlayersSnapshot()
|
|
6397
|
+
response.players = players
|
|
6398
|
+
response.playerCount = #players
|
|
6399
|
+
if peer == "client" then
|
|
6400
|
+
response.localPlayer = if Players.LocalPlayer then Players.LocalPlayer.Name else nil
|
|
6401
|
+
local canLeaveOk, canLeave = pcall(function()
|
|
6402
|
+
return StudioTestService:CanLeaveTest()
|
|
6403
|
+
end)
|
|
6404
|
+
response.canLeaveOk = canLeaveOk
|
|
6405
|
+
response.canLeave = if canLeaveOk then canLeave else false
|
|
6406
|
+
if not canLeaveOk then
|
|
6407
|
+
response.canLeaveError = tostring(canLeave)
|
|
6408
|
+
end
|
|
6409
|
+
end
|
|
6410
|
+
return response
|
|
6411
|
+
end
|
|
6412
|
+
local function multiplayerTestAddPlayers(requestData)
|
|
6413
|
+
if not RunService:IsRunning() or not RunService:IsServer() then
|
|
6414
|
+
return {
|
|
6415
|
+
error = "multiplayer_test_add_players must be called on the running server peer. Route with target=server.",
|
|
6416
|
+
}
|
|
6417
|
+
end
|
|
6418
|
+
local numPlayers = normalizeNumPlayers(requestData.numPlayers)
|
|
6419
|
+
if numPlayers == nil then
|
|
6420
|
+
return {
|
|
6421
|
+
error = "numPlayers must be an integer from 1 to 8",
|
|
6422
|
+
}
|
|
6423
|
+
end
|
|
6424
|
+
local before = #Players:GetPlayers()
|
|
6425
|
+
local ok, result = pcall(function()
|
|
6426
|
+
return StudioTestService:AddPlayers(numPlayers)
|
|
6427
|
+
end)
|
|
6428
|
+
if not ok then
|
|
6429
|
+
return {
|
|
6430
|
+
error = tostring(result),
|
|
6431
|
+
}
|
|
6432
|
+
end
|
|
6433
|
+
local _exp = tick()
|
|
6434
|
+
local _condition = (requestData.timeout)
|
|
6435
|
+
if _condition == nil then
|
|
6436
|
+
_condition = 10
|
|
6437
|
+
end
|
|
6438
|
+
local deadline = _exp + _condition
|
|
6439
|
+
while #Players:GetPlayers() < before + numPlayers and tick() < deadline do
|
|
6440
|
+
task.wait(0.1)
|
|
6441
|
+
end
|
|
6442
|
+
local players = getPlayersSnapshot()
|
|
6443
|
+
return {
|
|
6444
|
+
success = true,
|
|
6445
|
+
message = `Requested {numPlayers} additional player(s).`,
|
|
6446
|
+
playerCount = #players,
|
|
6447
|
+
players = players,
|
|
6448
|
+
}
|
|
6449
|
+
end
|
|
6450
|
+
local function multiplayerTestLeaveClient(_requestData)
|
|
6451
|
+
if not RunService:IsRunning() or RunService:IsServer() then
|
|
6452
|
+
return {
|
|
6453
|
+
error = "multiplayer_test_leave_client must be called on a running client peer. Route with target=client-N.",
|
|
6454
|
+
}
|
|
6455
|
+
end
|
|
6456
|
+
local canLeaveOk, canLeave = pcall(function()
|
|
6457
|
+
return StudioTestService:CanLeaveTest()
|
|
6458
|
+
end)
|
|
6459
|
+
if not canLeaveOk then
|
|
6460
|
+
return {
|
|
6461
|
+
error = tostring(canLeave),
|
|
6462
|
+
canLeaveOk = false,
|
|
6463
|
+
}
|
|
6464
|
+
end
|
|
6465
|
+
if not canLeave then
|
|
6466
|
+
return {
|
|
6467
|
+
error = "This client cannot leave the current test session.",
|
|
6468
|
+
canLeaveOk = true,
|
|
6469
|
+
canLeave = false,
|
|
6470
|
+
}
|
|
6471
|
+
end
|
|
6472
|
+
local localPlayer = if Players.LocalPlayer then Players.LocalPlayer.Name else nil
|
|
6473
|
+
task.defer(function()
|
|
6474
|
+
pcall(function()
|
|
6475
|
+
return StudioTestService:LeaveTest()
|
|
6476
|
+
end)
|
|
6477
|
+
end)
|
|
6478
|
+
return {
|
|
6479
|
+
success = true,
|
|
6480
|
+
message = "Client leave requested.",
|
|
6481
|
+
localPlayer = localPlayer,
|
|
6482
|
+
}
|
|
6483
|
+
end
|
|
6484
|
+
local function multiplayerTestEnd(requestData)
|
|
6485
|
+
if not RunService:IsRunning() or not RunService:IsServer() then
|
|
6486
|
+
return {
|
|
6487
|
+
error = "multiplayer_test_end must be called on the running server peer. Route with target=server.",
|
|
6488
|
+
}
|
|
6489
|
+
end
|
|
6490
|
+
local value = if requestData.value ~= nil then requestData.value else "ended_by_mcp"
|
|
6491
|
+
local ok, result = pcall(function()
|
|
6492
|
+
return StudioTestService:EndTest(value)
|
|
6493
|
+
end)
|
|
6494
|
+
if not ok then
|
|
6495
|
+
return {
|
|
6496
|
+
error = tostring(result),
|
|
6497
|
+
}
|
|
6498
|
+
end
|
|
6499
|
+
return {
|
|
6500
|
+
success = true,
|
|
6501
|
+
message = "Multiplayer Studio test end requested.",
|
|
6502
|
+
value = value,
|
|
6503
|
+
}
|
|
6504
|
+
end
|
|
5970
6505
|
local function characterNavigation(requestData)
|
|
5971
6506
|
if not testRunning then
|
|
5972
6507
|
return {
|
|
@@ -6038,6 +6573,11 @@ return {
|
|
|
6038
6573
|
startPlaytest = startPlaytest,
|
|
6039
6574
|
stopPlaytest = stopPlaytest,
|
|
6040
6575
|
getPlaytestOutput = getPlaytestOutput,
|
|
6576
|
+
multiplayerTestStart = multiplayerTestStart,
|
|
6577
|
+
multiplayerTestState = multiplayerTestState,
|
|
6578
|
+
multiplayerTestAddPlayers = multiplayerTestAddPlayers,
|
|
6579
|
+
multiplayerTestLeaveClient = multiplayerTestLeaveClient,
|
|
6580
|
+
multiplayerTestEnd = multiplayerTestEnd,
|
|
6041
6581
|
characterNavigation = characterNavigation,
|
|
6042
6582
|
}
|
|
6043
6583
|
]]></string>
|
|
@@ -6393,6 +6933,74 @@ return {
|
|
|
6393
6933
|
</Properties>
|
|
6394
6934
|
</Item>
|
|
6395
6935
|
<Item class="ModuleScript" referent="21">
|
|
6936
|
+
<Properties>
|
|
6937
|
+
<string name="Name">RenderMonitor</string>
|
|
6938
|
+
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
6939
|
+
local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
6940
|
+
-- Detects whether the Studio window is actually rendering, so virtual input
|
|
6941
|
+
-- and screenshot tools can surface a clear reason instead of silently failing.
|
|
6942
|
+
--
|
|
6943
|
+
-- When a Studio window is MINIMIZED, the engine suspends the render loop AND
|
|
6944
|
+
-- input processing, but keeps running scripts (Heartbeat keeps firing). That's
|
|
6945
|
+
-- why simulate_*_input would return success while having zero effect, and
|
|
6946
|
+
-- CaptureService:CaptureScreenshot would time out. Validated live: during a 3s
|
|
6947
|
+
-- minimize, RenderStepped's max inter-frame gap was 5.08s while Heartbeat's was
|
|
6948
|
+
-- 0.10s. So RenderStepped freshness is the reliable "is this window rendering?"
|
|
6949
|
+
-- signal; Heartbeat is not.
|
|
6950
|
+
local RunService = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services").RunService
|
|
6951
|
+
local lastFrame = 0
|
|
6952
|
+
local connected = false
|
|
6953
|
+
-- Above this many seconds since the last rendered frame, we treat the window
|
|
6954
|
+
-- as not rendering. RenderStepped normally fires every ~16ms; a multi-second
|
|
6955
|
+
-- gap only happens when minimized/suspended, so 1s cleanly avoids false
|
|
6956
|
+
-- positives from ordinary frame hitches while still catching the real case.
|
|
6957
|
+
local STALE_THRESHOLD = 1.0
|
|
6958
|
+
local function start()
|
|
6959
|
+
if connected then
|
|
6960
|
+
return nil
|
|
6961
|
+
end
|
|
6962
|
+
-- RenderStepped can only be connected from a client/edit render loop; it
|
|
6963
|
+
-- throws in the play-server DM. pcall so a server-DM call is a safe no-op
|
|
6964
|
+
-- (connected stays false → notRenderingReason() returns undefined there).
|
|
6965
|
+
local ok = pcall(function()
|
|
6966
|
+
RunService.RenderStepped:Connect(function()
|
|
6967
|
+
lastFrame = tick()
|
|
6968
|
+
end)
|
|
6969
|
+
end)
|
|
6970
|
+
if ok then
|
|
6971
|
+
connected = true
|
|
6972
|
+
lastFrame = tick()
|
|
6973
|
+
end
|
|
6974
|
+
end
|
|
6975
|
+
local function secondsSinceFrame()
|
|
6976
|
+
if not connected then
|
|
6977
|
+
return 0
|
|
6978
|
+
end
|
|
6979
|
+
return tick() - lastFrame
|
|
6980
|
+
end
|
|
6981
|
+
-- Returns a human-readable reason if the window appears minimized / not
|
|
6982
|
+
-- rendering (so input + screenshots won't work), else undefined. Fail-open:
|
|
6983
|
+
-- when the monitor isn't active in this DM (server peer, or connect failed) it
|
|
6984
|
+
-- returns undefined so we never block on a false signal.
|
|
6985
|
+
local function notRenderingReason()
|
|
6986
|
+
if not connected then
|
|
6987
|
+
return nil
|
|
6988
|
+
end
|
|
6989
|
+
local gap = secondsSinceFrame()
|
|
6990
|
+
if gap > STALE_THRESHOLD then
|
|
6991
|
+
return string.format("Studio window appears minimized or not rendering (no frame in %.1fs). " .. "Virtual input and screenshots only work while the window is visible — " .. "restore/un-minimize the Studio window and retry.", gap)
|
|
6992
|
+
end
|
|
6993
|
+
return nil
|
|
6994
|
+
end
|
|
6995
|
+
return {
|
|
6996
|
+
start = start,
|
|
6997
|
+
secondsSinceFrame = secondsSinceFrame,
|
|
6998
|
+
notRenderingReason = notRenderingReason,
|
|
6999
|
+
}
|
|
7000
|
+
]]></string>
|
|
7001
|
+
</Properties>
|
|
7002
|
+
</Item>
|
|
7003
|
+
<Item class="ModuleScript" referent="22">
|
|
6396
7004
|
<Properties>
|
|
6397
7005
|
<string name="Name">RuntimeLogBuffer</string>
|
|
6398
7006
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -6573,11 +7181,11 @@ return {
|
|
|
6573
7181
|
]]></string>
|
|
6574
7182
|
</Properties>
|
|
6575
7183
|
</Item>
|
|
6576
|
-
<Item class="ModuleScript" referent="
|
|
7184
|
+
<Item class="ModuleScript" referent="23">
|
|
6577
7185
|
<Properties>
|
|
6578
7186
|
<string name="Name">State</string>
|
|
6579
7187
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
6580
|
-
local CURRENT_VERSION = "2.
|
|
7188
|
+
local CURRENT_VERSION = "2.14.0"
|
|
6581
7189
|
local MAX_CONNECTIONS = 5
|
|
6582
7190
|
local BASE_PORT = 58741
|
|
6583
7191
|
local activeTabIndex = 0
|
|
@@ -6669,7 +7277,7 @@ return {
|
|
|
6669
7277
|
]]></string>
|
|
6670
7278
|
</Properties>
|
|
6671
7279
|
</Item>
|
|
6672
|
-
<Item class="ModuleScript" referent="
|
|
7280
|
+
<Item class="ModuleScript" referent="24">
|
|
6673
7281
|
<Properties>
|
|
6674
7282
|
<string name="Name">StopPlayMonitor</string>
|
|
6675
7283
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -6814,7 +7422,7 @@ return {
|
|
|
6814
7422
|
]]></string>
|
|
6815
7423
|
</Properties>
|
|
6816
7424
|
</Item>
|
|
6817
|
-
<Item class="ModuleScript" referent="
|
|
7425
|
+
<Item class="ModuleScript" referent="25">
|
|
6818
7426
|
<Properties>
|
|
6819
7427
|
<string name="Name">UI</string>
|
|
6820
7428
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -7565,7 +8173,7 @@ return {
|
|
|
7565
8173
|
]]></string>
|
|
7566
8174
|
</Properties>
|
|
7567
8175
|
</Item>
|
|
7568
|
-
<Item class="ModuleScript" referent="
|
|
8176
|
+
<Item class="ModuleScript" referent="26">
|
|
7569
8177
|
<Properties>
|
|
7570
8178
|
<string name="Name">Utils</string>
|
|
7571
8179
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -8095,11 +8703,11 @@ return {
|
|
|
8095
8703
|
</Properties>
|
|
8096
8704
|
</Item>
|
|
8097
8705
|
</Item>
|
|
8098
|
-
<Item class="Folder" referent="
|
|
8706
|
+
<Item class="Folder" referent="30">
|
|
8099
8707
|
<Properties>
|
|
8100
8708
|
<string name="Name">include</string>
|
|
8101
8709
|
</Properties>
|
|
8102
|
-
<Item class="ModuleScript" referent="
|
|
8710
|
+
<Item class="ModuleScript" referent="27">
|
|
8103
8711
|
<Properties>
|
|
8104
8712
|
<string name="Name">Promise</string>
|
|
8105
8713
|
<string name="Source"><![CDATA[--[[
|
|
@@ -10173,7 +10781,7 @@ return Promise
|
|
|
10173
10781
|
]]></string>
|
|
10174
10782
|
</Properties>
|
|
10175
10783
|
</Item>
|
|
10176
|
-
<Item class="ModuleScript" referent="
|
|
10784
|
+
<Item class="ModuleScript" referent="28">
|
|
10177
10785
|
<Properties>
|
|
10178
10786
|
<string name="Name">RuntimeLib</string>
|
|
10179
10787
|
<string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
|
|
@@ -10440,15 +11048,15 @@ return TS
|
|
|
10440
11048
|
</Properties>
|
|
10441
11049
|
</Item>
|
|
10442
11050
|
</Item>
|
|
10443
|
-
<Item class="Folder" referent="
|
|
11051
|
+
<Item class="Folder" referent="31">
|
|
10444
11052
|
<Properties>
|
|
10445
11053
|
<string name="Name">node_modules</string>
|
|
10446
11054
|
</Properties>
|
|
10447
|
-
<Item class="Folder" referent="
|
|
11055
|
+
<Item class="Folder" referent="32">
|
|
10448
11056
|
<Properties>
|
|
10449
11057
|
<string name="Name">@rbxts</string>
|
|
10450
11058
|
</Properties>
|
|
10451
|
-
<Item class="ModuleScript" referent="
|
|
11059
|
+
<Item class="ModuleScript" referent="29">
|
|
10452
11060
|
<Properties>
|
|
10453
11061
|
<string name="Name">services</string>
|
|
10454
11062
|
<string name="Source"><![CDATA[return setmetatable({}, {
|