@chrrxs/robloxstudio-mcp 2.13.0 → 2.15.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 +546 -29
- package/package.json +2 -2
- package/studio-plugin/INSTALLATION.md +1 -0
- package/studio-plugin/MCPInspectorPlugin.rbxmx +1573 -330
- package/studio-plugin/MCPPlugin.rbxmx +633 -27
- package/studio-plugin/src/modules/ClientBroker.ts +77 -2
- package/studio-plugin/src/modules/Communication.ts +7 -0
- package/studio-plugin/src/modules/handlers/SceneAnalysisHandlers.ts +216 -0
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +246 -12
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { HttpService, Players, ReplicatedStorage, RunService, ServerStorage } from "@rbxts/services";
|
|
2
2
|
import RuntimeLogBuffer from "./RuntimeLogBuffer";
|
|
3
3
|
import MemoryHandlers from "./handlers/MemoryHandlers";
|
|
4
|
+
import SceneAnalysisHandlers from "./handlers/SceneAnalysisHandlers";
|
|
4
5
|
import CaptureHandlers from "./handlers/CaptureHandlers";
|
|
5
6
|
import InputHandlers from "./handlers/InputHandlers";
|
|
6
7
|
import LuauExec from "./LuauExec";
|
|
7
8
|
|
|
9
|
+
interface StudioTestServiceMultiplayer extends StudioTestService {
|
|
10
|
+
CanLeaveTest(): boolean;
|
|
11
|
+
LeaveTest(): void;
|
|
12
|
+
EditModeActive: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const StudioTestService = game.GetService("StudioTestService") as StudioTestServiceMultiplayer;
|
|
16
|
+
|
|
8
17
|
// Mirror of Communication.computeInstanceId() — duplicated here because the
|
|
9
18
|
// client broker runs in the play-server DM where it can't easily import from
|
|
10
19
|
// the edit-side module, and the place identifier must match what the edit-DM
|
|
@@ -55,6 +64,7 @@ function resolvePlaceName(): string {
|
|
|
55
64
|
|
|
56
65
|
const MCP_URL = "http://localhost:58741";
|
|
57
66
|
const BROKER_NAME = "__MCPClientBroker";
|
|
67
|
+
const BROKER_OWNER_ATTRIBUTE = "__MCPBrokerOwner";
|
|
58
68
|
|
|
59
69
|
interface ProxyEntry {
|
|
60
70
|
pluginSessionId: string;
|
|
@@ -78,6 +88,9 @@ const CLIENT_BROKER_ALLOWED_ENDPOINTS = new Set<string>([
|
|
|
78
88
|
"/api/execute-luau",
|
|
79
89
|
"/api/get-runtime-logs",
|
|
80
90
|
"/api/get-memory-breakdown",
|
|
91
|
+
"/api/get-scene-analysis",
|
|
92
|
+
"/api/multiplayer-test-state",
|
|
93
|
+
"/api/multiplayer-test-leave-client",
|
|
81
94
|
// Screenshot capture must run in the client peer (CaptureService captures
|
|
82
95
|
// the play viewport there); the edit DM reads the temp id back separately.
|
|
83
96
|
"/api/capture-begin",
|
|
@@ -164,10 +177,56 @@ function handleGetRuntimeLogs(data: Record<string, unknown> | undefined): unknow
|
|
|
164
177
|
return RuntimeLogBuffer.query({ since, tail, filter }, "client");
|
|
165
178
|
}
|
|
166
179
|
|
|
180
|
+
function handleMultiplayerTestState(): unknown {
|
|
181
|
+
const [argsOk, args] = pcall(() => StudioTestService.GetTestArgs());
|
|
182
|
+
const [canLeaveOk, canLeave] = pcall(() => StudioTestService.CanLeaveTest());
|
|
183
|
+
const players = Players.GetPlayers().map((player) => ({
|
|
184
|
+
name: player.Name,
|
|
185
|
+
userId: player.UserId,
|
|
186
|
+
displayName: player.DisplayName,
|
|
187
|
+
}));
|
|
188
|
+
players.sort((a, b) => a.name < b.name);
|
|
189
|
+
return {
|
|
190
|
+
success: true,
|
|
191
|
+
peer: "client",
|
|
192
|
+
isRunning: RunService.IsRunning(),
|
|
193
|
+
isRunMode: RunService.IsRunMode(),
|
|
194
|
+
editModeActive: StudioTestService.EditModeActive,
|
|
195
|
+
testArgsOk: argsOk,
|
|
196
|
+
testArgs: argsOk ? args : undefined,
|
|
197
|
+
testArgsError: argsOk ? undefined : tostring(args),
|
|
198
|
+
players,
|
|
199
|
+
playerCount: players.size(),
|
|
200
|
+
localPlayer: Players.LocalPlayer ? Players.LocalPlayer.Name : undefined,
|
|
201
|
+
canLeaveOk,
|
|
202
|
+
canLeave: canLeaveOk ? canLeave : false,
|
|
203
|
+
canLeaveError: canLeaveOk ? undefined : tostring(canLeave),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function handleMultiplayerTestLeaveClient(): unknown {
|
|
208
|
+
const [canLeaveOk, canLeave] = pcall(() => StudioTestService.CanLeaveTest());
|
|
209
|
+
if (!canLeaveOk) {
|
|
210
|
+
return { error: tostring(canLeave), canLeaveOk: false };
|
|
211
|
+
}
|
|
212
|
+
if (!canLeave) {
|
|
213
|
+
return { error: "This client cannot leave the current test session.", canLeaveOk: true, canLeave: false };
|
|
214
|
+
}
|
|
215
|
+
const localPlayer = Players.LocalPlayer ? Players.LocalPlayer.Name : undefined;
|
|
216
|
+
task.defer(() => {
|
|
217
|
+
pcall(() => StudioTestService.LeaveTest());
|
|
218
|
+
});
|
|
219
|
+
return {
|
|
220
|
+
success: true,
|
|
221
|
+
message: "Client leave requested.",
|
|
222
|
+
localPlayer,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
167
226
|
function setupClientBroker() {
|
|
168
227
|
const rf = ReplicatedStorage.WaitForChild(BROKER_NAME, 10);
|
|
169
228
|
if (!rf || !rf.IsA("RemoteFunction")) {
|
|
170
|
-
warn(`[
|
|
229
|
+
warn(`[robloxstudio-mcp] client: ${BROKER_NAME} not found`);
|
|
171
230
|
return;
|
|
172
231
|
}
|
|
173
232
|
rf.OnClientInvoke = (payload: BrokerEnvelope | undefined) => {
|
|
@@ -183,6 +242,15 @@ function setupClientBroker() {
|
|
|
183
242
|
if (payload && payload.endpoint === "/api/get-memory-breakdown") {
|
|
184
243
|
return MemoryHandlers.getMemoryBreakdown(payload.data ?? {});
|
|
185
244
|
}
|
|
245
|
+
if (payload && payload.endpoint === "/api/get-scene-analysis") {
|
|
246
|
+
return SceneAnalysisHandlers.getSceneAnalysis(payload.data ?? {});
|
|
247
|
+
}
|
|
248
|
+
if (payload && payload.endpoint === "/api/multiplayer-test-state") {
|
|
249
|
+
return handleMultiplayerTestState();
|
|
250
|
+
}
|
|
251
|
+
if (payload && payload.endpoint === "/api/multiplayer-test-leave-client") {
|
|
252
|
+
return handleMultiplayerTestLeaveClient();
|
|
253
|
+
}
|
|
186
254
|
if (payload && payload.endpoint === "/api/capture-begin") {
|
|
187
255
|
return CaptureHandlers.captureBegin();
|
|
188
256
|
}
|
|
@@ -201,6 +269,7 @@ function setupClientBroker() {
|
|
|
201
269
|
}
|
|
202
270
|
|
|
203
271
|
const proxyByPlayer = new Map<Player, ProxyEntry>();
|
|
272
|
+
let serverBrokerStarted = false;
|
|
204
273
|
|
|
205
274
|
function pollProxy(proxyId: string, player: Player, rf: RemoteFunction) {
|
|
206
275
|
while (player.Parent !== undefined && proxyByPlayer.has(player)) {
|
|
@@ -262,7 +331,7 @@ function registerProxy(player: Player, rf: RemoteFunction) {
|
|
|
262
331
|
isRunning: RunService.IsRunning(),
|
|
263
332
|
});
|
|
264
333
|
if (!ok || !res || !res.Success) {
|
|
265
|
-
warn(`[
|
|
334
|
+
warn(`[robloxstudio-mcp] proxy register failed for ${player.Name}`);
|
|
266
335
|
return;
|
|
267
336
|
}
|
|
268
337
|
const body = HttpService.JSONDecode(res.Body) as ReadyResponseBody;
|
|
@@ -277,12 +346,18 @@ function registerProxy(player: Player, rf: RemoteFunction) {
|
|
|
277
346
|
// which doesn't depend on MCP server state or peer registration at all.)
|
|
278
347
|
|
|
279
348
|
function setupServerBroker() {
|
|
349
|
+
if (serverBrokerStarted) return;
|
|
280
350
|
let rf = ReplicatedStorage.FindFirstChild(BROKER_NAME) as RemoteFunction | undefined;
|
|
281
351
|
if (!rf) {
|
|
282
352
|
rf = new Instance("RemoteFunction");
|
|
283
353
|
rf.Name = BROKER_NAME;
|
|
284
354
|
rf.Parent = ReplicatedStorage;
|
|
285
355
|
}
|
|
356
|
+
if (rf.GetAttribute(BROKER_OWNER_ATTRIBUTE) !== undefined) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
rf.SetAttribute(BROKER_OWNER_ATTRIBUTE, HttpService.GenerateGUID(false));
|
|
360
|
+
serverBrokerStarted = true;
|
|
286
361
|
const broker = rf;
|
|
287
362
|
Players.PlayerAdded.Connect((p) => registerProxy(p, broker));
|
|
288
363
|
for (const p of Players.GetPlayers()) {
|
|
@@ -16,6 +16,7 @@ import InputHandlers from "./handlers/InputHandlers";
|
|
|
16
16
|
import LogHandlers from "./handlers/LogHandlers";
|
|
17
17
|
import SerializationHandlers from "./handlers/SerializationHandlers";
|
|
18
18
|
import MemoryHandlers from "./handlers/MemoryHandlers";
|
|
19
|
+
import SceneAnalysisHandlers from "./handlers/SceneAnalysisHandlers";
|
|
19
20
|
import { Connection, RequestPayload, PollResponse, ReadyResponse } from "../types";
|
|
20
21
|
|
|
21
22
|
// Per-plugin-load random GUID. Used as the /poll URL param so the server
|
|
@@ -133,6 +134,11 @@ const routeMap: Record<string, Handler> = {
|
|
|
133
134
|
"/api/start-playtest": TestHandlers.startPlaytest,
|
|
134
135
|
"/api/stop-playtest": TestHandlers.stopPlaytest,
|
|
135
136
|
"/api/get-playtest-output": TestHandlers.getPlaytestOutput,
|
|
137
|
+
"/api/multiplayer-test-start": TestHandlers.multiplayerTestStart,
|
|
138
|
+
"/api/multiplayer-test-state": TestHandlers.multiplayerTestState,
|
|
139
|
+
"/api/multiplayer-test-add-players": TestHandlers.multiplayerTestAddPlayers,
|
|
140
|
+
"/api/multiplayer-test-leave-client": TestHandlers.multiplayerTestLeaveClient,
|
|
141
|
+
"/api/multiplayer-test-end": TestHandlers.multiplayerTestEnd,
|
|
136
142
|
"/api/character-navigation": TestHandlers.characterNavigation,
|
|
137
143
|
|
|
138
144
|
"/api/export-build": BuildHandlers.exportBuild,
|
|
@@ -157,6 +163,7 @@ const routeMap: Record<string, Handler> = {
|
|
|
157
163
|
"/api/import-rbxm": SerializationHandlers.importRbxm,
|
|
158
164
|
|
|
159
165
|
"/api/get-memory-breakdown": MemoryHandlers.getMemoryBreakdown,
|
|
166
|
+
"/api/get-scene-analysis": SceneAnalysisHandlers.getSceneAnalysis,
|
|
160
167
|
};
|
|
161
168
|
|
|
162
169
|
function processRequest(request: RequestPayload): unknown {
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
interface SceneAnalysisServiceLike extends Instance {
|
|
2
|
+
GetInstanceCompositionAsync(this: SceneAnalysisServiceLike): unknown;
|
|
3
|
+
GetScriptMemoryAsync(this: SceneAnalysisServiceLike): unknown;
|
|
4
|
+
GetUnparentedInstancesAsync(this: SceneAnalysisServiceLike): unknown;
|
|
5
|
+
GetTriangleCompositionAsync(this: SceneAnalysisServiceLike): unknown;
|
|
6
|
+
GetAnimationMemoryAsync(this: SceneAnalysisServiceLike): unknown;
|
|
7
|
+
GetAudioMemoryAsync(this: SceneAnalysisServiceLike): unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface SceneAnalysisNode {
|
|
11
|
+
Name?: string;
|
|
12
|
+
Size?: number;
|
|
13
|
+
Sizes?: Record<string, number>;
|
|
14
|
+
Children?: SceneAnalysisNode[];
|
|
15
|
+
AssetId?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ModeConfig {
|
|
19
|
+
method: string;
|
|
20
|
+
query: (service: SceneAnalysisServiceLike) => unknown;
|
|
21
|
+
sortByTriangles?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MODE_CONFIGS: Record<string, ModeConfig> = {
|
|
25
|
+
instance_composition: {
|
|
26
|
+
method: "GetInstanceCompositionAsync",
|
|
27
|
+
query: (service) => service.GetInstanceCompositionAsync(),
|
|
28
|
+
},
|
|
29
|
+
script_memory: {
|
|
30
|
+
method: "GetScriptMemoryAsync",
|
|
31
|
+
query: (service) => service.GetScriptMemoryAsync(),
|
|
32
|
+
},
|
|
33
|
+
unparented_instances: {
|
|
34
|
+
method: "GetUnparentedInstancesAsync",
|
|
35
|
+
query: (service) => service.GetUnparentedInstancesAsync(),
|
|
36
|
+
},
|
|
37
|
+
triangle_composition: {
|
|
38
|
+
method: "GetTriangleCompositionAsync",
|
|
39
|
+
query: (service) => service.GetTriangleCompositionAsync(),
|
|
40
|
+
sortByTriangles: true,
|
|
41
|
+
},
|
|
42
|
+
animation_memory: {
|
|
43
|
+
method: "GetAnimationMemoryAsync",
|
|
44
|
+
query: (service) => service.GetAnimationMemoryAsync(),
|
|
45
|
+
},
|
|
46
|
+
audio_memory: {
|
|
47
|
+
method: "GetAudioMemoryAsync",
|
|
48
|
+
query: (service) => service.GetAudioMemoryAsync(),
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const ALL_MODES = [
|
|
53
|
+
"instance_composition",
|
|
54
|
+
"script_memory",
|
|
55
|
+
"unparented_instances",
|
|
56
|
+
"triangle_composition",
|
|
57
|
+
"animation_memory",
|
|
58
|
+
"audio_memory",
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
function betaDisabledError(): Record<string, unknown> {
|
|
62
|
+
return {
|
|
63
|
+
error: "scene_analysis_not_enabled",
|
|
64
|
+
message: "SceneAnalysisService is not enabled. Enable Scene Analysis in Studio Beta Features and restart Studio.",
|
|
65
|
+
betaFeatureRequired: true,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isBetaDisabledError(value: unknown): boolean {
|
|
70
|
+
return typeIs(value, "string") && string.find(value, "SceneAnalysisService is not enabled", 1, true)[0] !== undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getSceneAnalysisService(): SceneAnalysisServiceLike | Record<string, unknown> {
|
|
74
|
+
const provider = game as unknown as { GetService(serviceName: string): Instance };
|
|
75
|
+
const [ok, service] = pcall(() => provider.GetService("SceneAnalysisService") as SceneAnalysisServiceLike);
|
|
76
|
+
if (!ok || !service) {
|
|
77
|
+
return {
|
|
78
|
+
error: "scene_analysis_unavailable",
|
|
79
|
+
message: `SceneAnalysisService is unavailable: ${tostring(service)}`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return service;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeMode(mode: unknown): string | Record<string, unknown> {
|
|
86
|
+
if (mode === undefined || mode === "all") return "all";
|
|
87
|
+
if (!typeIs(mode, "string") || MODE_CONFIGS[mode] === undefined) {
|
|
88
|
+
return {
|
|
89
|
+
error: "invalid_mode",
|
|
90
|
+
message: `mode must be one of: all, ${ALL_MODES.join(", ")}`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return mode;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function normalizeTopN(topN: unknown): number {
|
|
97
|
+
if (!typeIs(topN, "number")) return 10;
|
|
98
|
+
return math.clamp(math.floor(topN), 1, 100);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function countLeaves(node: SceneAnalysisNode): number {
|
|
102
|
+
const children = node.Children;
|
|
103
|
+
if (children && children.size() > 0) {
|
|
104
|
+
let total = 0;
|
|
105
|
+
for (const child of children) total += countLeaves(child);
|
|
106
|
+
return total;
|
|
107
|
+
}
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function flattenLeaves(node: SceneAnalysisNode, out: SceneAnalysisNode[]): void {
|
|
112
|
+
const children = node.Children;
|
|
113
|
+
if (children && children.size() > 0) {
|
|
114
|
+
for (const child of children) flattenLeaves(child, out);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
out.push(node);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function compactEntry(node: SceneAnalysisNode): Record<string, unknown> {
|
|
121
|
+
const entry: Record<string, unknown> = {
|
|
122
|
+
name: node.Name,
|
|
123
|
+
};
|
|
124
|
+
if (node.Size !== undefined) entry.size = node.Size;
|
|
125
|
+
if (node.Sizes !== undefined) entry.sizes = node.Sizes;
|
|
126
|
+
if (node.AssetId !== undefined) entry.asset_id = node.AssetId;
|
|
127
|
+
return entry;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function compactRoot(node: SceneAnalysisNode, leafCount: number): Record<string, unknown> {
|
|
131
|
+
const children = node.Children;
|
|
132
|
+
const root: Record<string, unknown> = {
|
|
133
|
+
name: node.Name,
|
|
134
|
+
child_count: children ? children.size() : 0,
|
|
135
|
+
leaf_count: leafCount,
|
|
136
|
+
};
|
|
137
|
+
if (node.Size !== undefined) root.size = node.Size;
|
|
138
|
+
if (node.Sizes !== undefined) root.sizes = node.Sizes;
|
|
139
|
+
return root;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function metric(node: SceneAnalysisNode, sortByTriangles: boolean): number {
|
|
143
|
+
if (sortByTriangles) {
|
|
144
|
+
const sizes = node.Sizes;
|
|
145
|
+
const triangles = sizes ? sizes.Triangles : undefined;
|
|
146
|
+
return triangles ?? 0;
|
|
147
|
+
}
|
|
148
|
+
return node.Size ?? 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function summarizeMode(
|
|
152
|
+
mode: string,
|
|
153
|
+
config: ModeConfig,
|
|
154
|
+
service: SceneAnalysisServiceLike,
|
|
155
|
+
topN: number,
|
|
156
|
+
raw: boolean,
|
|
157
|
+
): Record<string, unknown> {
|
|
158
|
+
const started = os.clock();
|
|
159
|
+
const [ok, result] = pcall(() => config.query(service) as SceneAnalysisNode);
|
|
160
|
+
const elapsedMs = math.floor((os.clock() - started) * 1000);
|
|
161
|
+
|
|
162
|
+
if (!ok) {
|
|
163
|
+
if (isBetaDisabledError(result)) return betaDisabledError();
|
|
164
|
+
return {
|
|
165
|
+
error: "scene_analysis_query_failed",
|
|
166
|
+
mode,
|
|
167
|
+
method: config.method,
|
|
168
|
+
message: tostring(result),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const tree = result as SceneAnalysisNode;
|
|
173
|
+
const leaves: SceneAnalysisNode[] = [];
|
|
174
|
+
flattenLeaves(tree, leaves);
|
|
175
|
+
leaves.sort((a, b) => metric(a, config.sortByTriangles === true) > metric(b, config.sortByTriangles === true));
|
|
176
|
+
|
|
177
|
+
const top: Record<string, unknown>[] = [];
|
|
178
|
+
for (let i = 0; i < math.min(topN, leaves.size()); i++) {
|
|
179
|
+
top.push(compactEntry(leaves[i]));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const body: Record<string, unknown> = {
|
|
183
|
+
mode,
|
|
184
|
+
method: config.method,
|
|
185
|
+
elapsed_ms: elapsedMs,
|
|
186
|
+
root: compactRoot(tree, leaves.size()),
|
|
187
|
+
top,
|
|
188
|
+
};
|
|
189
|
+
if (raw) body.tree = tree;
|
|
190
|
+
return body;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function getSceneAnalysis(requestData: Record<string, unknown>): unknown {
|
|
194
|
+
const mode = normalizeMode(requestData.mode);
|
|
195
|
+
if (!typeIs(mode, "string")) return mode;
|
|
196
|
+
|
|
197
|
+
const serviceOrError = getSceneAnalysisService();
|
|
198
|
+
if (!serviceOrError.IsA) return serviceOrError;
|
|
199
|
+
const service = serviceOrError as SceneAnalysisServiceLike;
|
|
200
|
+
const topN = normalizeTopN(requestData.topN);
|
|
201
|
+
const raw = requestData.raw === true;
|
|
202
|
+
|
|
203
|
+
if (mode !== "all") {
|
|
204
|
+
return summarizeMode(mode, MODE_CONFIGS[mode], service, topN, raw);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const body: Record<string, unknown> = {};
|
|
208
|
+
for (const m of ALL_MODES) {
|
|
209
|
+
const result = summarizeMode(m, MODE_CONFIGS[m], service, topN, raw);
|
|
210
|
+
if (result.error === "scene_analysis_not_enabled") return result;
|
|
211
|
+
body[m] = result;
|
|
212
|
+
}
|
|
213
|
+
return body;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export = { getSceneAnalysis };
|
|
@@ -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
|
-
|
|
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:
|
|
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
|
};
|