@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.
@@ -1,8 +1,16 @@
1
- import { HttpService, LogService, RunService } from "@rbxts/services";
1
+ import { HttpService, LogService, Players, RunService } from "@rbxts/services";
2
2
  import { installBridges, ensureBridgesInstalled } from "../EvalBridges";
3
3
  import StopPlayMonitor from "../StopPlayMonitor";
4
4
 
5
- const StudioTestService = game.GetService("StudioTestService");
5
+ interface StudioTestServiceMultiplayer extends StudioTestService {
6
+ ExecuteMultiplayerTestAsync(numPlayers: number, testArgs: unknown): unknown;
7
+ AddPlayers(numPlayers: number): void;
8
+ CanLeaveTest(): boolean;
9
+ LeaveTest(): void;
10
+ EditModeActive: boolean;
11
+ }
12
+
13
+ const StudioTestService = game.GetService("StudioTestService") as StudioTestServiceMultiplayer;
6
14
  const ServerScriptService = game.GetService("ServerScriptService");
7
15
  const ScriptEditorService = game.GetService("ScriptEditorService");
8
16
 
@@ -27,6 +35,59 @@ let testError: string | undefined;
27
35
  let stopListenerScript: Script | undefined;
28
36
  let navResultCallback: ((json: string) => void) | undefined;
29
37
 
38
+ type MultiplayerPhase = "idle" | "starting" | "running" | "completed" | "failed";
39
+
40
+ interface MultiplayerSessionState {
41
+ phase: MultiplayerPhase;
42
+ testId?: string;
43
+ numPlayers?: number;
44
+ testArgs?: unknown;
45
+ startedAt?: number;
46
+ completedAt?: number;
47
+ ok?: boolean;
48
+ result?: unknown;
49
+ error?: string;
50
+ }
51
+
52
+ let multiplayerState: MultiplayerSessionState = { phase: "idle" };
53
+
54
+ function detectPeerRole(): string {
55
+ if (!RunService.IsRunning()) return "edit";
56
+ if (RunService.IsServer()) return "server";
57
+ return "client";
58
+ }
59
+
60
+ function getPlayersSnapshot() {
61
+ const players = Players.GetPlayers().map((player) => ({
62
+ name: player.Name,
63
+ userId: player.UserId,
64
+ displayName: player.DisplayName,
65
+ }));
66
+ players.sort((a, b) => a.name < b.name);
67
+ return players;
68
+ }
69
+
70
+ function cloneMultiplayerState(): MultiplayerSessionState {
71
+ return {
72
+ phase: multiplayerState.phase,
73
+ testId: multiplayerState.testId,
74
+ numPlayers: multiplayerState.numPlayers,
75
+ testArgs: multiplayerState.testArgs,
76
+ startedAt: multiplayerState.startedAt,
77
+ completedAt: multiplayerState.completedAt,
78
+ ok: multiplayerState.ok,
79
+ result: multiplayerState.result,
80
+ error: multiplayerState.error,
81
+ };
82
+ }
83
+
84
+ function normalizeNumPlayers(value: unknown): number | undefined {
85
+ if (!typeIs(value, "number")) return undefined;
86
+ const n = math.floor(value);
87
+ if (n !== value || n < 1 || n > 8) return undefined;
88
+ return n;
89
+ }
90
+
30
91
  function buildCommandListenerSource(): string {
31
92
  return `local LogService = game:GetService("LogService")
32
93
  local PathfindingService = game:GetService("PathfindingService")
@@ -124,6 +185,10 @@ function startPlaytest(requestData: Record<string, unknown>) {
124
185
  return { error: 'mode must be "play" or "run"' };
125
186
  }
126
187
 
188
+ if (numPlayers !== undefined) {
189
+ return { error: "start_playtest is single-player only. Use multiplayer_test_start for multi-client StudioTestService sessions." };
190
+ }
191
+
127
192
  // Self-heal: if testRunning is stuck true but Studio reports no active
128
193
  // playtest, the previous start_playtest's task.spawn was orphaned
129
194
  // (plugin reload mid-test, Studio entered some inconsistent state, etc).
@@ -180,11 +245,6 @@ function startPlaytest(requestData: Record<string, unknown>) {
180
245
  warn(`[MCP] Eval bridge install failed: ${bridgeInstall.error}`);
181
246
  }
182
247
 
183
- if (numPlayers !== undefined && mode === "run") {
184
- const TestService = game.GetService("TestService") as TestService & { NumberOfPlayers: number };
185
- TestService.NumberOfPlayers = math.clamp(numPlayers, 1, 8);
186
- }
187
-
188
248
  task.spawn(() => {
189
249
  const [ok, result] = pcall(() => {
190
250
  if (mode === "play") {
@@ -211,13 +271,9 @@ function startPlaytest(requestData: Record<string, unknown>) {
211
271
  ensureBridgesInstalled();
212
272
  });
213
273
 
214
- const msg = numPlayers !== undefined
215
- ? `Playtest started in ${mode} mode with ${numPlayers} player(s).`
216
- : `Playtest started in ${mode} mode.`;
217
-
218
274
  const response: Record<string, unknown> = {
219
275
  success: true,
220
- message: msg,
276
+ message: `Playtest started in ${mode} mode.`,
221
277
  };
222
278
  // Only mention eval bridges when they failed — when they're fine, the
223
279
  // detail is noise. eval_server_runtime / eval_client_runtime will surface
@@ -294,6 +350,179 @@ function getPlaytestOutput(_requestData: Record<string, unknown>) {
294
350
  };
295
351
  }
296
352
 
353
+ function multiplayerTestStart(requestData: Record<string, unknown>) {
354
+ if (RunService.IsRunning()) {
355
+ return { error: "multiplayer_test_start must be called on the edit DataModel. Route with target=edit." };
356
+ }
357
+
358
+ const numPlayers = normalizeNumPlayers(requestData.numPlayers);
359
+ if (numPlayers === undefined) {
360
+ return { error: "numPlayers must be an integer from 1 to 8" };
361
+ }
362
+
363
+ if (multiplayerState.phase === "starting" || multiplayerState.phase === "running") {
364
+ return {
365
+ error: "A multiplayer Studio test is already running",
366
+ state: cloneMultiplayerState(),
367
+ };
368
+ }
369
+
370
+ const testArgs = requestData.testArgs !== undefined ? requestData.testArgs : {};
371
+ const testId = HttpService.GenerateGUID(false);
372
+
373
+ const bridgeInstall = installBridges();
374
+ if (!bridgeInstall.installed) {
375
+ warn(`[MCP] Eval bridge install failed: ${bridgeInstall.error}`);
376
+ }
377
+
378
+ multiplayerState = {
379
+ phase: "starting",
380
+ testId,
381
+ numPlayers,
382
+ testArgs,
383
+ startedAt: tick(),
384
+ };
385
+
386
+ task.spawn(() => {
387
+ multiplayerState.phase = "running";
388
+ const [ok, result] = pcall(() => {
389
+ return StudioTestService.ExecuteMultiplayerTestAsync(numPlayers, testArgs);
390
+ });
391
+
392
+ multiplayerState.completedAt = tick();
393
+ multiplayerState.ok = ok;
394
+ if (ok) {
395
+ multiplayerState.phase = "completed";
396
+ multiplayerState.result = result;
397
+ multiplayerState.error = undefined;
398
+ } else {
399
+ multiplayerState.phase = "failed";
400
+ multiplayerState.result = undefined;
401
+ multiplayerState.error = tostring(result);
402
+ }
403
+
404
+ ensureBridgesInstalled();
405
+ });
406
+
407
+ const response: Record<string, unknown> = {
408
+ success: true,
409
+ message: `Multiplayer Studio test starting with ${numPlayers} player(s).`,
410
+ testId,
411
+ phase: multiplayerState.phase,
412
+ numPlayers,
413
+ testArgs,
414
+ };
415
+ if (!bridgeInstall.installed) {
416
+ response.evalBridgesError = bridgeInstall.error;
417
+ }
418
+ return response;
419
+ }
420
+
421
+ function multiplayerTestState(_requestData: Record<string, unknown>) {
422
+ const peer = detectPeerRole();
423
+ const response: Record<string, unknown> = {
424
+ success: true,
425
+ peer,
426
+ isRunning: RunService.IsRunning(),
427
+ isRunMode: RunService.IsRunMode(),
428
+ editModeActive: StudioTestService.EditModeActive,
429
+ };
430
+
431
+ if (peer === "edit") {
432
+ response.session = cloneMultiplayerState();
433
+ return response;
434
+ }
435
+
436
+ const [argsOk, args] = pcall(() => StudioTestService.GetTestArgs());
437
+ response.testArgsOk = argsOk;
438
+ response.testArgs = argsOk ? args : undefined;
439
+ if (!argsOk) response.testArgsError = tostring(args);
440
+
441
+ const players = getPlayersSnapshot();
442
+ response.players = players;
443
+ response.playerCount = players.size();
444
+
445
+ if (peer === "client") {
446
+ response.localPlayer = Players.LocalPlayer ? Players.LocalPlayer.Name : undefined;
447
+ const [canLeaveOk, canLeave] = pcall(() => StudioTestService.CanLeaveTest());
448
+ response.canLeaveOk = canLeaveOk;
449
+ response.canLeave = canLeaveOk ? canLeave : false;
450
+ if (!canLeaveOk) response.canLeaveError = tostring(canLeave);
451
+ }
452
+
453
+ return response;
454
+ }
455
+
456
+ function multiplayerTestAddPlayers(requestData: Record<string, unknown>) {
457
+ if (!RunService.IsRunning() || !RunService.IsServer()) {
458
+ return { error: "multiplayer_test_add_players must be called on the running server peer. Route with target=server." };
459
+ }
460
+ const numPlayers = normalizeNumPlayers(requestData.numPlayers);
461
+ if (numPlayers === undefined) {
462
+ return { error: "numPlayers must be an integer from 1 to 8" };
463
+ }
464
+
465
+ const before = Players.GetPlayers().size();
466
+ const [ok, result] = pcall(() => StudioTestService.AddPlayers(numPlayers));
467
+ if (!ok) {
468
+ return { error: tostring(result) };
469
+ }
470
+
471
+ const deadline = tick() + ((requestData.timeout as number | undefined) ?? 10);
472
+ while (Players.GetPlayers().size() < before + numPlayers && tick() < deadline) {
473
+ task.wait(0.1);
474
+ }
475
+
476
+ const players = getPlayersSnapshot();
477
+ return {
478
+ success: true,
479
+ message: `Requested ${numPlayers} additional player(s).`,
480
+ playerCount: players.size(),
481
+ players,
482
+ };
483
+ }
484
+
485
+ function multiplayerTestLeaveClient(_requestData: Record<string, unknown>) {
486
+ if (!RunService.IsRunning() || RunService.IsServer()) {
487
+ return { error: "multiplayer_test_leave_client must be called on a running client peer. Route with target=client-N." };
488
+ }
489
+
490
+ const [canLeaveOk, canLeave] = pcall(() => StudioTestService.CanLeaveTest());
491
+ if (!canLeaveOk) {
492
+ return { error: tostring(canLeave), canLeaveOk: false };
493
+ }
494
+ if (!canLeave) {
495
+ return { error: "This client cannot leave the current test session.", canLeaveOk: true, canLeave: false };
496
+ }
497
+
498
+ const localPlayer = Players.LocalPlayer ? Players.LocalPlayer.Name : undefined;
499
+ task.defer(() => {
500
+ pcall(() => StudioTestService.LeaveTest());
501
+ });
502
+ return {
503
+ success: true,
504
+ message: "Client leave requested.",
505
+ localPlayer,
506
+ };
507
+ }
508
+
509
+ function multiplayerTestEnd(requestData: Record<string, unknown>) {
510
+ if (!RunService.IsRunning() || !RunService.IsServer()) {
511
+ return { error: "multiplayer_test_end must be called on the running server peer. Route with target=server." };
512
+ }
513
+
514
+ const value = requestData.value !== undefined ? requestData.value : "ended_by_mcp";
515
+ const [ok, result] = pcall(() => StudioTestService.EndTest(value));
516
+ if (!ok) {
517
+ return { error: tostring(result) };
518
+ }
519
+ return {
520
+ success: true,
521
+ message: "Multiplayer Studio test end requested.",
522
+ value,
523
+ };
524
+ }
525
+
297
526
  function characterNavigation(requestData: Record<string, unknown>) {
298
527
  if (!testRunning) {
299
528
  return { error: "Playtest must be running. Start a playtest in 'play' mode first." };
@@ -344,5 +573,10 @@ export = {
344
573
  startPlaytest,
345
574
  stopPlaytest,
346
575
  getPlaytestOutput,
576
+ multiplayerTestStart,
577
+ multiplayerTestState,
578
+ multiplayerTestAddPlayers,
579
+ multiplayerTestLeaveClient,
580
+ multiplayerTestEnd,
347
581
  characterNavigation,
348
582
  };