@chrrxs/robloxstudio-mcp 2.13.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 +439 -27
- package/package.json +2 -2
- package/studio-plugin/INSTALLATION.md +1 -0
- package/studio-plugin/MCPInspectorPlugin.rbxmx +1312 -325
- package/studio-plugin/MCPPlugin.rbxmx +359 -9
- package/studio-plugin/src/modules/ClientBroker.ts +70 -0
- package/studio-plugin/src/modules/Communication.ts +5 -0
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +246 -12
|
@@ -105,6 +105,7 @@ local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandl
|
|
|
105
105
|
local CaptureHandlers = TS.import(script, script.Parent, "handlers", "CaptureHandlers")
|
|
106
106
|
local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandlers")
|
|
107
107
|
local LuauExec = TS.import(script, script.Parent, "LuauExec")
|
|
108
|
+
local StudioTestService = game:GetService("StudioTestService")
|
|
108
109
|
-- Mirror of Communication.computeInstanceId() — duplicated here because the
|
|
109
110
|
-- client broker runs in the play-server DM where it can't easily import from
|
|
110
111
|
-- the edit-side module, and the place identifier must match what the edit-DM
|
|
@@ -158,6 +159,7 @@ end
|
|
|
158
159
|
-- signaling, which works regardless of MCP server state.)
|
|
159
160
|
local MCP_URL = "http://localhost:58741"
|
|
160
161
|
local BROKER_NAME = "__MCPClientBroker"
|
|
162
|
+
local BROKER_OWNER_ATTRIBUTE = "__MCPBrokerOwner"
|
|
161
163
|
-- Endpoints the server-peer broker is allowed to forward to the client peer.
|
|
162
164
|
-- Each requires the client peer's plugin VM (because the buffer / require
|
|
163
165
|
-- cache / etc. lives there) so the server peer alone can't satisfy them.
|
|
@@ -165,6 +167,8 @@ local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
|
|
|
165
167
|
["/api/execute-luau"] = true,
|
|
166
168
|
["/api/get-runtime-logs"] = true,
|
|
167
169
|
["/api/get-memory-breakdown"] = true,
|
|
170
|
+
["/api/multiplayer-test-state"] = true,
|
|
171
|
+
["/api/multiplayer-test-leave-client"] = true,
|
|
168
172
|
["/api/capture-begin"] = true,
|
|
169
173
|
["/api/simulate-mouse-input"] = true,
|
|
170
174
|
["/api/simulate-keyboard-input"] = true,
|
|
@@ -246,6 +250,77 @@ local function handleGetRuntimeLogs(data)
|
|
|
246
250
|
filter = filter,
|
|
247
251
|
}, "client")
|
|
248
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
|
|
249
324
|
local function setupClientBroker()
|
|
250
325
|
local rf = ReplicatedStorage:WaitForChild(BROKER_NAME, 10)
|
|
251
326
|
if not rf or not rf:IsA("RemoteFunction") then
|
|
@@ -265,6 +340,12 @@ local function setupClientBroker()
|
|
|
265
340
|
if payload and payload.endpoint == "/api/get-memory-breakdown" then
|
|
266
341
|
return MemoryHandlers.getMemoryBreakdown(payload.data or {})
|
|
267
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
|
|
268
349
|
if payload and payload.endpoint == "/api/capture-begin" then
|
|
269
350
|
return CaptureHandlers.captureBegin()
|
|
270
351
|
end
|
|
@@ -282,6 +363,7 @@ local function setupClientBroker()
|
|
|
282
363
|
end
|
|
283
364
|
end
|
|
284
365
|
local proxyByPlayer = {}
|
|
366
|
+
local serverBrokerStarted = false
|
|
285
367
|
local function pollProxy(proxyId, player, rf)
|
|
286
368
|
while true do
|
|
287
369
|
local _condition = player.Parent ~= nil
|
|
@@ -393,12 +475,20 @@ end
|
|
|
393
475
|
-- plugin:SetSetting flag consumed by StopPlayMonitor in the play-server DM,
|
|
394
476
|
-- which doesn't depend on MCP server state or peer registration at all.)
|
|
395
477
|
local function setupServerBroker()
|
|
478
|
+
if serverBrokerStarted then
|
|
479
|
+
return nil
|
|
480
|
+
end
|
|
396
481
|
local rf = ReplicatedStorage:FindFirstChild(BROKER_NAME)
|
|
397
482
|
if not rf then
|
|
398
483
|
rf = Instance.new("RemoteFunction")
|
|
399
484
|
rf.Name = BROKER_NAME
|
|
400
485
|
rf.Parent = ReplicatedStorage
|
|
401
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
|
|
402
492
|
local broker = rf
|
|
403
493
|
Players.PlayerAdded:Connect(function(p)
|
|
404
494
|
return registerProxy(p, broker)
|
|
@@ -571,6 +661,11 @@ local routeMap = {
|
|
|
571
661
|
["/api/start-playtest"] = TestHandlers.startPlaytest,
|
|
572
662
|
["/api/stop-playtest"] = TestHandlers.stopPlaytest,
|
|
573
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,
|
|
574
669
|
["/api/character-navigation"] = TestHandlers.characterNavigation,
|
|
575
670
|
["/api/export-build"] = BuildHandlers.exportBuild,
|
|
576
671
|
["/api/import-build"] = BuildHandlers.importBuild,
|
|
@@ -1161,9 +1256,9 @@ local function computeBridgeStamp()
|
|
|
1161
1256
|
for i = 1, #combined do
|
|
1162
1257
|
h = (h * 33 + (string.byte(combined, i))) % 2147483647
|
|
1163
1258
|
end
|
|
1164
|
-
-- "2.
|
|
1259
|
+
-- "2.14.0" is replaced with the package version at package time
|
|
1165
1260
|
-- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
|
|
1166
|
-
return `{tostring(h)}-2.
|
|
1261
|
+
return `{tostring(h)}-2.14.0`
|
|
1167
1262
|
end
|
|
1168
1263
|
local BRIDGE_STAMP = computeBridgeStamp()
|
|
1169
1264
|
local function setSource(scriptInst, source)
|
|
@@ -5864,6 +5959,7 @@ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
|
|
|
5864
5959
|
local _services = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services")
|
|
5865
5960
|
local HttpService = _services.HttpService
|
|
5866
5961
|
local LogService = _services.LogService
|
|
5962
|
+
local Players = _services.Players
|
|
5867
5963
|
local RunService = _services.RunService
|
|
5868
5964
|
local _EvalBridges = TS.import(script, script.Parent.Parent, "EvalBridges")
|
|
5869
5965
|
local installBridges = _EvalBridges.installBridges
|
|
@@ -5885,6 +5981,63 @@ local testResult
|
|
|
5885
5981
|
local testError
|
|
5886
5982
|
local stopListenerScript
|
|
5887
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
|
|
5888
6041
|
local function buildCommandListenerSource()
|
|
5889
6042
|
return `local LogService = game:GetService("LogService")\
|
|
5890
6043
|
local PathfindingService = game:GetService("PathfindingService")\
|
|
@@ -5981,6 +6134,11 @@ local function startPlaytest(requestData)
|
|
|
5981
6134
|
error = 'mode must be "play" or "run"',
|
|
5982
6135
|
}
|
|
5983
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
|
|
5984
6142
|
-- Self-heal: if testRunning is stuck true but Studio reports no active
|
|
5985
6143
|
-- playtest, the previous start_playtest's task.spawn was orphaned
|
|
5986
6144
|
-- (plugin reload mid-test, Studio entered some inconsistent state, etc).
|
|
@@ -6045,10 +6203,6 @@ local function startPlaytest(requestData)
|
|
|
6045
6203
|
if not bridgeInstall.installed then
|
|
6046
6204
|
warn(`[MCP] Eval bridge install failed: {bridgeInstall.error}`)
|
|
6047
6205
|
end
|
|
6048
|
-
if numPlayers ~= nil and mode == "run" then
|
|
6049
|
-
local TestService = game:GetService("TestService")
|
|
6050
|
-
TestService.NumberOfPlayers = math.clamp(numPlayers, 1, 8)
|
|
6051
|
-
end
|
|
6052
6206
|
task.spawn(function()
|
|
6053
6207
|
local ok, result = pcall(function()
|
|
6054
6208
|
if mode == "play" then
|
|
@@ -6071,10 +6225,9 @@ local function startPlaytest(requestData)
|
|
|
6071
6225
|
-- clean up here, so the next manual playtest still gets them.
|
|
6072
6226
|
ensureBridgesInstalled()
|
|
6073
6227
|
end)
|
|
6074
|
-
local msg = if numPlayers ~= nil then `Playtest started in {mode} mode with {numPlayers} player(s).` else `Playtest started in {mode} mode.`
|
|
6075
6228
|
local response = {
|
|
6076
6229
|
success = true,
|
|
6077
|
-
message =
|
|
6230
|
+
message = `Playtest started in {mode} mode.`,
|
|
6078
6231
|
}
|
|
6079
6232
|
-- Only mention eval bridges when they failed — when they're fine, the
|
|
6080
6233
|
-- detail is noise. eval_server_runtime / eval_client_runtime will surface
|
|
@@ -6157,6 +6310,198 @@ local function getPlaytestOutput(_requestData)
|
|
|
6157
6310
|
_object.testError = testError
|
|
6158
6311
|
return _object
|
|
6159
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
|
|
6160
6505
|
local function characterNavigation(requestData)
|
|
6161
6506
|
if not testRunning then
|
|
6162
6507
|
return {
|
|
@@ -6228,6 +6573,11 @@ return {
|
|
|
6228
6573
|
startPlaytest = startPlaytest,
|
|
6229
6574
|
stopPlaytest = stopPlaytest,
|
|
6230
6575
|
getPlaytestOutput = getPlaytestOutput,
|
|
6576
|
+
multiplayerTestStart = multiplayerTestStart,
|
|
6577
|
+
multiplayerTestState = multiplayerTestState,
|
|
6578
|
+
multiplayerTestAddPlayers = multiplayerTestAddPlayers,
|
|
6579
|
+
multiplayerTestLeaveClient = multiplayerTestLeaveClient,
|
|
6580
|
+
multiplayerTestEnd = multiplayerTestEnd,
|
|
6231
6581
|
characterNavigation = characterNavigation,
|
|
6232
6582
|
}
|
|
6233
6583
|
]]></string>
|
|
@@ -6835,7 +7185,7 @@ return {
|
|
|
6835
7185
|
<Properties>
|
|
6836
7186
|
<string name="Name">State</string>
|
|
6837
7187
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
6838
|
-
local CURRENT_VERSION = "2.
|
|
7188
|
+
local CURRENT_VERSION = "2.14.0"
|
|
6839
7189
|
local MAX_CONNECTIONS = 5
|
|
6840
7190
|
local BASE_PORT = 58741
|
|
6841
7191
|
local activeTabIndex = 0
|
|
@@ -5,6 +5,14 @@ import CaptureHandlers from "./handlers/CaptureHandlers";
|
|
|
5
5
|
import InputHandlers from "./handlers/InputHandlers";
|
|
6
6
|
import LuauExec from "./LuauExec";
|
|
7
7
|
|
|
8
|
+
interface StudioTestServiceMultiplayer extends StudioTestService {
|
|
9
|
+
CanLeaveTest(): boolean;
|
|
10
|
+
LeaveTest(): void;
|
|
11
|
+
EditModeActive: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const StudioTestService = game.GetService("StudioTestService") as StudioTestServiceMultiplayer;
|
|
15
|
+
|
|
8
16
|
// Mirror of Communication.computeInstanceId() — duplicated here because the
|
|
9
17
|
// client broker runs in the play-server DM where it can't easily import from
|
|
10
18
|
// the edit-side module, and the place identifier must match what the edit-DM
|
|
@@ -55,6 +63,7 @@ function resolvePlaceName(): string {
|
|
|
55
63
|
|
|
56
64
|
const MCP_URL = "http://localhost:58741";
|
|
57
65
|
const BROKER_NAME = "__MCPClientBroker";
|
|
66
|
+
const BROKER_OWNER_ATTRIBUTE = "__MCPBrokerOwner";
|
|
58
67
|
|
|
59
68
|
interface ProxyEntry {
|
|
60
69
|
pluginSessionId: string;
|
|
@@ -78,6 +87,8 @@ const CLIENT_BROKER_ALLOWED_ENDPOINTS = new Set<string>([
|
|
|
78
87
|
"/api/execute-luau",
|
|
79
88
|
"/api/get-runtime-logs",
|
|
80
89
|
"/api/get-memory-breakdown",
|
|
90
|
+
"/api/multiplayer-test-state",
|
|
91
|
+
"/api/multiplayer-test-leave-client",
|
|
81
92
|
// Screenshot capture must run in the client peer (CaptureService captures
|
|
82
93
|
// the play viewport there); the edit DM reads the temp id back separately.
|
|
83
94
|
"/api/capture-begin",
|
|
@@ -164,6 +175,52 @@ function handleGetRuntimeLogs(data: Record<string, unknown> | undefined): unknow
|
|
|
164
175
|
return RuntimeLogBuffer.query({ since, tail, filter }, "client");
|
|
165
176
|
}
|
|
166
177
|
|
|
178
|
+
function handleMultiplayerTestState(): unknown {
|
|
179
|
+
const [argsOk, args] = pcall(() => StudioTestService.GetTestArgs());
|
|
180
|
+
const [canLeaveOk, canLeave] = pcall(() => StudioTestService.CanLeaveTest());
|
|
181
|
+
const players = Players.GetPlayers().map((player) => ({
|
|
182
|
+
name: player.Name,
|
|
183
|
+
userId: player.UserId,
|
|
184
|
+
displayName: player.DisplayName,
|
|
185
|
+
}));
|
|
186
|
+
players.sort((a, b) => a.name < b.name);
|
|
187
|
+
return {
|
|
188
|
+
success: true,
|
|
189
|
+
peer: "client",
|
|
190
|
+
isRunning: RunService.IsRunning(),
|
|
191
|
+
isRunMode: RunService.IsRunMode(),
|
|
192
|
+
editModeActive: StudioTestService.EditModeActive,
|
|
193
|
+
testArgsOk: argsOk,
|
|
194
|
+
testArgs: argsOk ? args : undefined,
|
|
195
|
+
testArgsError: argsOk ? undefined : tostring(args),
|
|
196
|
+
players,
|
|
197
|
+
playerCount: players.size(),
|
|
198
|
+
localPlayer: Players.LocalPlayer ? Players.LocalPlayer.Name : undefined,
|
|
199
|
+
canLeaveOk,
|
|
200
|
+
canLeave: canLeaveOk ? canLeave : false,
|
|
201
|
+
canLeaveError: canLeaveOk ? undefined : tostring(canLeave),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function handleMultiplayerTestLeaveClient(): unknown {
|
|
206
|
+
const [canLeaveOk, canLeave] = pcall(() => StudioTestService.CanLeaveTest());
|
|
207
|
+
if (!canLeaveOk) {
|
|
208
|
+
return { error: tostring(canLeave), canLeaveOk: false };
|
|
209
|
+
}
|
|
210
|
+
if (!canLeave) {
|
|
211
|
+
return { error: "This client cannot leave the current test session.", canLeaveOk: true, canLeave: false };
|
|
212
|
+
}
|
|
213
|
+
const localPlayer = Players.LocalPlayer ? Players.LocalPlayer.Name : undefined;
|
|
214
|
+
task.defer(() => {
|
|
215
|
+
pcall(() => StudioTestService.LeaveTest());
|
|
216
|
+
});
|
|
217
|
+
return {
|
|
218
|
+
success: true,
|
|
219
|
+
message: "Client leave requested.",
|
|
220
|
+
localPlayer,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
167
224
|
function setupClientBroker() {
|
|
168
225
|
const rf = ReplicatedStorage.WaitForChild(BROKER_NAME, 10);
|
|
169
226
|
if (!rf || !rf.IsA("RemoteFunction")) {
|
|
@@ -183,6 +240,12 @@ function setupClientBroker() {
|
|
|
183
240
|
if (payload && payload.endpoint === "/api/get-memory-breakdown") {
|
|
184
241
|
return MemoryHandlers.getMemoryBreakdown(payload.data ?? {});
|
|
185
242
|
}
|
|
243
|
+
if (payload && payload.endpoint === "/api/multiplayer-test-state") {
|
|
244
|
+
return handleMultiplayerTestState();
|
|
245
|
+
}
|
|
246
|
+
if (payload && payload.endpoint === "/api/multiplayer-test-leave-client") {
|
|
247
|
+
return handleMultiplayerTestLeaveClient();
|
|
248
|
+
}
|
|
186
249
|
if (payload && payload.endpoint === "/api/capture-begin") {
|
|
187
250
|
return CaptureHandlers.captureBegin();
|
|
188
251
|
}
|
|
@@ -201,6 +264,7 @@ function setupClientBroker() {
|
|
|
201
264
|
}
|
|
202
265
|
|
|
203
266
|
const proxyByPlayer = new Map<Player, ProxyEntry>();
|
|
267
|
+
let serverBrokerStarted = false;
|
|
204
268
|
|
|
205
269
|
function pollProxy(proxyId: string, player: Player, rf: RemoteFunction) {
|
|
206
270
|
while (player.Parent !== undefined && proxyByPlayer.has(player)) {
|
|
@@ -277,12 +341,18 @@ function registerProxy(player: Player, rf: RemoteFunction) {
|
|
|
277
341
|
// which doesn't depend on MCP server state or peer registration at all.)
|
|
278
342
|
|
|
279
343
|
function setupServerBroker() {
|
|
344
|
+
if (serverBrokerStarted) return;
|
|
280
345
|
let rf = ReplicatedStorage.FindFirstChild(BROKER_NAME) as RemoteFunction | undefined;
|
|
281
346
|
if (!rf) {
|
|
282
347
|
rf = new Instance("RemoteFunction");
|
|
283
348
|
rf.Name = BROKER_NAME;
|
|
284
349
|
rf.Parent = ReplicatedStorage;
|
|
285
350
|
}
|
|
351
|
+
if (rf.GetAttribute(BROKER_OWNER_ATTRIBUTE) !== undefined) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
rf.SetAttribute(BROKER_OWNER_ATTRIBUTE, HttpService.GenerateGUID(false));
|
|
355
|
+
serverBrokerStarted = true;
|
|
286
356
|
const broker = rf;
|
|
287
357
|
Players.PlayerAdded.Connect((p) => registerProxy(p, broker));
|
|
288
358
|
for (const p of Players.GetPlayers()) {
|
|
@@ -133,6 +133,11 @@ const routeMap: Record<string, Handler> = {
|
|
|
133
133
|
"/api/start-playtest": TestHandlers.startPlaytest,
|
|
134
134
|
"/api/stop-playtest": TestHandlers.stopPlaytest,
|
|
135
135
|
"/api/get-playtest-output": TestHandlers.getPlaytestOutput,
|
|
136
|
+
"/api/multiplayer-test-start": TestHandlers.multiplayerTestStart,
|
|
137
|
+
"/api/multiplayer-test-state": TestHandlers.multiplayerTestState,
|
|
138
|
+
"/api/multiplayer-test-add-players": TestHandlers.multiplayerTestAddPlayers,
|
|
139
|
+
"/api/multiplayer-test-leave-client": TestHandlers.multiplayerTestLeaveClient,
|
|
140
|
+
"/api/multiplayer-test-end": TestHandlers.multiplayerTestEnd,
|
|
136
141
|
"/api/character-navigation": TestHandlers.characterNavigation,
|
|
137
142
|
|
|
138
143
|
"/api/export-build": BuildHandlers.exportBuild,
|