@chrrxs/robloxstudio-mcp 2.11.1 → 2.11.3
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 +77 -21
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +266 -147
- package/studio-plugin/MCPPlugin.rbxmx +266 -147
- package/studio-plugin/src/modules/ClientBroker.ts +8 -50
- package/studio-plugin/src/modules/StopPlayMonitor.ts +87 -0
- package/studio-plugin/src/modules/handlers/MetadataHandlers.ts +77 -54
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +63 -23
- package/studio-plugin/src/server/index.server.ts +10 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Cross-DM stop_playtest signaling via plugin:SetSetting.
|
|
2
|
+
//
|
|
3
|
+
// `plugin:SetSetting` / `plugin:GetSetting` is a per-plugin persistent store
|
|
4
|
+
// that's shared across every DataModel the plugin runs in (edit, play-server,
|
|
5
|
+
// play-clients). We use it as a one-bit flag for "please call EndTest in the
|
|
6
|
+
// play-server DM":
|
|
7
|
+
//
|
|
8
|
+
// * The edit DM's stopPlaytest handler writes the flag (requestStop).
|
|
9
|
+
// * A monitor loop running inside the play-server DM polls the flag at 1Hz
|
|
10
|
+
// and calls StudioTestService:EndTest when it flips true, then resets it.
|
|
11
|
+
// * The edit DM then waits up to ~2.5s for the flag to be reset, which
|
|
12
|
+
// tells us a play-server actually consumed the request (no false-positive
|
|
13
|
+
// success when nothing was running).
|
|
14
|
+
//
|
|
15
|
+
// Why this is simpler than the previous edit-proxy registration:
|
|
16
|
+
// * Doesn't depend on the MCP server tracking peer roles at all.
|
|
17
|
+
// * Survives MCP server restarts: monitor loop is local to the play-server
|
|
18
|
+
// plugin lifetime, not to any HTTP/registration state.
|
|
19
|
+
// * No need for cross-DM LogService.MessageOut reflection (which we verified
|
|
20
|
+
// does not work edit -> play-server anyway).
|
|
21
|
+
//
|
|
22
|
+
// Pattern mirrors the official Roblox Studio MCP
|
|
23
|
+
// (Roblox/studio-rust-mcp-server, plugin/src/Utils/GameStopUtil.luau).
|
|
24
|
+
|
|
25
|
+
const StudioTestService = game.GetService("StudioTestService");
|
|
26
|
+
|
|
27
|
+
const SETTING_KEY = "MCP_STOP_PLAY_SIGNAL";
|
|
28
|
+
const POLL_INTERVAL_SEC = 1;
|
|
29
|
+
const WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 2.5;
|
|
30
|
+
const WAIT_POLL_SEC = 0.1;
|
|
31
|
+
|
|
32
|
+
let pluginRef: Plugin | undefined;
|
|
33
|
+
|
|
34
|
+
function init(p: Plugin): void {
|
|
35
|
+
pluginRef = p;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function startMonitor(): void {
|
|
39
|
+
if (!pluginRef) {
|
|
40
|
+
warn("[MCP] StopPlayMonitor.startMonitor called before init; skipping");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// Clear any stale value left from a prior session. If a real stop request
|
|
44
|
+
// is in-flight when this runs, the requesting edit DM will set it again
|
|
45
|
+
// within its 2.5s wait window.
|
|
46
|
+
pcall(() => pluginRef!.SetSetting(SETTING_KEY, false));
|
|
47
|
+
task.spawn(() => {
|
|
48
|
+
while (true) {
|
|
49
|
+
const [okGet, val] = pcall(() => pluginRef!.GetSetting(SETTING_KEY));
|
|
50
|
+
if (okGet && val === true) {
|
|
51
|
+
pcall(() => pluginRef!.SetSetting(SETTING_KEY, false));
|
|
52
|
+
pcall(() => StudioTestService.EndTest("stopped_by_mcp"));
|
|
53
|
+
}
|
|
54
|
+
task.wait(POLL_INTERVAL_SEC);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function requestStop(): boolean {
|
|
60
|
+
if (!pluginRef) return false;
|
|
61
|
+
const [ok] = pcall(() => pluginRef!.SetSetting(SETTING_KEY, true));
|
|
62
|
+
return ok;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function waitForConsumption(): boolean {
|
|
66
|
+
if (!pluginRef) return false;
|
|
67
|
+
const start = tick();
|
|
68
|
+
while (tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC) {
|
|
69
|
+
const [okGet, val] = pcall(() => pluginRef!.GetSetting(SETTING_KEY));
|
|
70
|
+
if (okGet && val !== true) return true;
|
|
71
|
+
task.wait(WAIT_POLL_SEC);
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function clearPending(): void {
|
|
77
|
+
if (!pluginRef) return;
|
|
78
|
+
pcall(() => pluginRef!.SetSetting(SETTING_KEY, false));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export = {
|
|
82
|
+
init,
|
|
83
|
+
startMonitor,
|
|
84
|
+
requestStop,
|
|
85
|
+
waitForConsumption,
|
|
86
|
+
clearPending,
|
|
87
|
+
};
|
|
@@ -263,48 +263,59 @@ function executeLuau(requestData: Record<string, unknown>) {
|
|
|
263
263
|
const code = requestData.code as string;
|
|
264
264
|
if (!code || code === "") return { error: "Code is required" };
|
|
265
265
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
266
|
+
// Both execution paths (loadstring + ModuleScript-require fallback) run
|
|
267
|
+
// the SAME wrapped source so they return a uniform { ok, value, output }
|
|
268
|
+
// shape. Two problems the wrapper solves at once:
|
|
269
|
+
//
|
|
270
|
+
// 1. pcall(require, m) swallows the real error and returns Roblox's
|
|
271
|
+
// generic "Requested module experienced an error while loading"
|
|
272
|
+
// message. Wrapping user code in xpcall INSIDE the IIFE keeps the
|
|
273
|
+
// ModuleScript itself returning successfully — the real error +
|
|
274
|
+
// traceback live in the returned table.
|
|
275
|
+
//
|
|
276
|
+
// 2. The ModuleScript path runs in its own environment, so a plugin-
|
|
277
|
+
// side getfenv print/warn override never reached user prints. A
|
|
278
|
+
// lexical local print/warn inside the IIFE captures user prints
|
|
279
|
+
// regardless of which path executes. We also call the real global
|
|
280
|
+
// print/warn so messages still flow to Studio's output and
|
|
281
|
+
// LogService.MessageOut (which powers get_runtime_logs).
|
|
282
|
+
//
|
|
283
|
+
// Prints from required sub-modules don't reach this capture (they have
|
|
284
|
+
// their own env) — those go through the runtime log buffer.
|
|
285
|
+
const wrapped = `return ((function()
|
|
286
|
+
\tlocal __mcp_output = {}
|
|
287
|
+
\tlocal __mcp_real_print = print
|
|
288
|
+
\tlocal __mcp_real_warn = warn
|
|
289
|
+
\tlocal print = function(...)
|
|
290
|
+
\t\t__mcp_real_print(...)
|
|
291
|
+
\t\tlocal args = {...}
|
|
292
|
+
\t\tlocal parts = table.create(#args)
|
|
293
|
+
\t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end
|
|
294
|
+
\t\ttable.insert(__mcp_output, table.concat(parts, "\\t"))
|
|
295
|
+
\tend
|
|
296
|
+
\tlocal warn = function(...)
|
|
297
|
+
\t\t__mcp_real_warn(...)
|
|
298
|
+
\t\tlocal args = {...}
|
|
299
|
+
\t\tlocal parts = table.create(#args)
|
|
300
|
+
\t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end
|
|
301
|
+
\t\ttable.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))
|
|
302
|
+
\tend
|
|
303
|
+
\tlocal function __mcp_run()
|
|
304
|
+
${code}
|
|
305
|
+
\tend
|
|
306
|
+
\tlocal ok, errOrValue = xpcall(__mcp_run, debug.traceback)
|
|
307
|
+
\treturn { ok = ok, value = errOrValue, output = __mcp_output }
|
|
308
|
+
end)())`;
|
|
309
|
+
|
|
310
|
+
interface WrapperResult {
|
|
311
|
+
ok?: boolean;
|
|
312
|
+
value?: unknown;
|
|
313
|
+
output?: defined;
|
|
314
|
+
}
|
|
283
315
|
|
|
284
|
-
|
|
285
|
-
// ServerScriptService.LoadStringEnabled=false AND the plugin runs in a
|
|
286
|
-
// peer where the engine respects that gate (notably the play-server DM
|
|
287
|
-
// in some Studio configurations), loadstring either returns nil with a
|
|
288
|
-
// "loadstring() is not available" message OR throws that same message
|
|
289
|
-
// directly. Both paths must trigger the ModuleScript + require
|
|
290
|
-
// fallback. The fallback can't intercept print/warn since the
|
|
291
|
-
// ModuleScript runs in its own environment, so the output array stays
|
|
292
|
-
// empty in that branch - the playtest log buffer already captures
|
|
293
|
-
// prints separately via LogService.MessageOut.
|
|
294
|
-
const runViaModuleScript = () => {
|
|
316
|
+
const runViaModuleScript = (): WrapperResult => {
|
|
295
317
|
const m = new Instance("ModuleScript");
|
|
296
318
|
m.Name = "__MCPExecLuauPayload";
|
|
297
|
-
// Wrap user code in an IIFE so require() always gets exactly one
|
|
298
|
-
// return value. Without this, code like `print("x")` errors with
|
|
299
|
-
// "Module code did not return exactly one value" because top-level
|
|
300
|
-
// ModuleScripts must return exactly one value.
|
|
301
|
-
//
|
|
302
|
-
// The DOUBLE parens around the call are load-bearing: in Luau,
|
|
303
|
-
// `return f()` propagates whatever multi-value tuple f returns,
|
|
304
|
-
// including zero values. Outer parens adjust the call to exactly
|
|
305
|
-
// one value (the first, or nil). So `return ((f)())` always
|
|
306
|
-
// returns exactly one value, regardless of what f does.
|
|
307
|
-
const wrapped = `return ((function()\n${code}\nend)())`;
|
|
308
319
|
const [okSet, setErr] = pcall(() => {
|
|
309
320
|
(m as unknown as { Source: string }).Source = wrapped;
|
|
310
321
|
});
|
|
@@ -316,7 +327,7 @@ function executeLuau(requestData: Record<string, unknown>) {
|
|
|
316
327
|
const [okReq, reqResult] = pcall(() => require(m));
|
|
317
328
|
m.Destroy();
|
|
318
329
|
if (!okReq) error(tostring(reqResult));
|
|
319
|
-
return reqResult;
|
|
330
|
+
return reqResult as unknown as WrapperResult;
|
|
320
331
|
};
|
|
321
332
|
|
|
322
333
|
const isLoadstringUnavailable = (err: unknown): boolean => {
|
|
@@ -326,40 +337,52 @@ function executeLuau(requestData: Record<string, unknown>) {
|
|
|
326
337
|
};
|
|
327
338
|
|
|
328
339
|
let [success, result] = pcall(() => {
|
|
329
|
-
const [fn, compileError] = loadstring(
|
|
340
|
+
const [fn, compileError] = loadstring(wrapped);
|
|
330
341
|
if (!fn) {
|
|
331
342
|
if (isLoadstringUnavailable(compileError)) {
|
|
332
343
|
return runViaModuleScript();
|
|
333
344
|
}
|
|
334
345
|
error(`Compile error: ${compileError}`);
|
|
335
346
|
}
|
|
336
|
-
return fn();
|
|
347
|
+
return fn() as unknown as WrapperResult;
|
|
337
348
|
});
|
|
338
349
|
|
|
339
350
|
// loadstring throws (not returns nil) in some plugin contexts when
|
|
340
|
-
// LoadStringEnabled=false. Catch that
|
|
351
|
+
// LoadStringEnabled=false. Catch that as a second-chance fallback.
|
|
341
352
|
if (!success && isLoadstringUnavailable(result)) {
|
|
342
353
|
[success, result] = pcall(runViaModuleScript);
|
|
343
354
|
}
|
|
344
355
|
|
|
345
|
-
|
|
346
|
-
|
|
356
|
+
if (!success) {
|
|
357
|
+
// Outer pcall failed - the wrapper itself didn't even run (e.g. compile
|
|
358
|
+
// error in the user code, or ModuleScript setup error). 'result' is the
|
|
359
|
+
// raw error string from pcall.
|
|
360
|
+
return {
|
|
361
|
+
success: false,
|
|
362
|
+
error: tostring(result),
|
|
363
|
+
output: [],
|
|
364
|
+
message: "Code execution failed",
|
|
365
|
+
};
|
|
366
|
+
}
|
|
347
367
|
|
|
348
|
-
|
|
368
|
+
// Wrapper executed - unpack { ok, value, output }.
|
|
369
|
+
const r = result as unknown as WrapperResult;
|
|
370
|
+
const capturedOutput = r.output as unknown as string[] | undefined;
|
|
371
|
+
const output = capturedOutput !== undefined ? capturedOutput : ([] as string[]);
|
|
372
|
+
if (r.ok === true) {
|
|
349
373
|
return {
|
|
350
374
|
success: true,
|
|
351
|
-
returnValue:
|
|
375
|
+
returnValue: r.value !== undefined ? tostring(r.value) : undefined,
|
|
352
376
|
output,
|
|
353
377
|
message: "Code executed successfully",
|
|
354
378
|
};
|
|
355
|
-
} else {
|
|
356
|
-
return {
|
|
357
|
-
success: false,
|
|
358
|
-
error: tostring(result),
|
|
359
|
-
output,
|
|
360
|
-
message: "Code execution failed",
|
|
361
|
-
};
|
|
362
379
|
}
|
|
380
|
+
return {
|
|
381
|
+
success: false,
|
|
382
|
+
error: r.value !== undefined ? tostring(r.value) : "(unknown error)",
|
|
383
|
+
output,
|
|
384
|
+
message: "Code execution failed",
|
|
385
|
+
};
|
|
363
386
|
}
|
|
364
387
|
|
|
365
388
|
function undo(_requestData: Record<string, unknown>) {
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
import { HttpService, LogService } from "@rbxts/services";
|
|
1
|
+
import { HttpService, LogService, RunService } from "@rbxts/services";
|
|
2
2
|
import { installBridges, cleanupBridges } from "../EvalBridges";
|
|
3
|
+
import StopPlayMonitor from "../StopPlayMonitor";
|
|
3
4
|
|
|
4
5
|
const StudioTestService = game.GetService("StudioTestService");
|
|
5
6
|
const ServerScriptService = game.GetService("ServerScriptService");
|
|
6
7
|
const ScriptEditorService = game.GetService("ScriptEditorService");
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
// NAV_SIGNAL flows from the edit DM to the play-server DM via the injected
|
|
10
|
+
// __MCP_CommandListener Script + LogService.MessageOut. Stop signaling moved
|
|
11
|
+
// off this path entirely (see StopPlayMonitor) because cross-DM MessageOut
|
|
12
|
+
// reflection from edit -> play-server does not work in practice.
|
|
9
13
|
const NAV_SIGNAL = "__MCP_NAV__";
|
|
10
14
|
const NAV_RESULT = "__MCP_NAV_RESULT__";
|
|
11
15
|
|
|
@@ -25,16 +29,13 @@ let navResultCallback: ((json: string) => void) | undefined;
|
|
|
25
29
|
|
|
26
30
|
function buildCommandListenerSource(): string {
|
|
27
31
|
return `local LogService = game:GetService("LogService")
|
|
28
|
-
local StudioTestService = game:GetService("StudioTestService")
|
|
29
32
|
local PathfindingService = game:GetService("PathfindingService")
|
|
30
33
|
local Players = game:GetService("Players")
|
|
31
34
|
local HttpService = game:GetService("HttpService")
|
|
32
35
|
local NAV_SIG = "${NAV_SIGNAL}"
|
|
33
36
|
local NAV_RES = "${NAV_RESULT}"
|
|
34
37
|
LogService.MessageOut:Connect(function(msg)
|
|
35
|
-
if msg == "
|
|
36
|
-
pcall(function() StudioTestService:EndTest("stopped_by_mcp") end)
|
|
37
|
-
elseif string.sub(msg, 1, #NAV_SIG + 1) == NAV_SIG .. ":" then
|
|
38
|
+
if string.sub(msg, 1, #NAV_SIG + 1) == NAV_SIG .. ":" then
|
|
38
39
|
local json = string.sub(msg, #NAV_SIG + 2)
|
|
39
40
|
task.spawn(function()
|
|
40
41
|
local ok, d = pcall(function() return HttpService:JSONDecode(json) end)
|
|
@@ -123,6 +124,20 @@ function startPlaytest(requestData: Record<string, unknown>) {
|
|
|
123
124
|
return { error: 'mode must be "play" or "run"' };
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
// Self-heal: if testRunning is stuck true but Studio reports no active
|
|
128
|
+
// playtest, the previous start_playtest's task.spawn was orphaned
|
|
129
|
+
// (plugin reload mid-test, Studio entered some inconsistent state, etc).
|
|
130
|
+
// Reset it so subsequent starts don't hit a false "already running".
|
|
131
|
+
if (testRunning && !RunService.IsRunning()) {
|
|
132
|
+
testRunning = false;
|
|
133
|
+
if (logConnection) {
|
|
134
|
+
logConnection.Disconnect();
|
|
135
|
+
logConnection = undefined;
|
|
136
|
+
}
|
|
137
|
+
cleanupStopListener();
|
|
138
|
+
cleanupBridges();
|
|
139
|
+
}
|
|
140
|
+
|
|
126
141
|
if (testRunning) {
|
|
127
142
|
return { error: "A test is already running" };
|
|
128
143
|
}
|
|
@@ -135,7 +150,6 @@ function startPlaytest(requestData: Record<string, unknown>) {
|
|
|
135
150
|
cleanupStopListener();
|
|
136
151
|
|
|
137
152
|
logConnection = LogService.MessageOut.Connect((message, messageType) => {
|
|
138
|
-
if (message === STOP_SIGNAL) return;
|
|
139
153
|
if (message.sub(1, NAV_SIGNAL.size()) === NAV_SIGNAL) return;
|
|
140
154
|
if (message.sub(1, NAV_RESULT.size() + 1) === `${NAV_RESULT}:`) {
|
|
141
155
|
if (navResultCallback) {
|
|
@@ -193,32 +207,58 @@ function startPlaytest(requestData: Record<string, unknown>) {
|
|
|
193
207
|
});
|
|
194
208
|
|
|
195
209
|
const msg = numPlayers !== undefined
|
|
196
|
-
? `Playtest started in ${mode} mode with ${numPlayers} player(s)
|
|
197
|
-
: `Playtest started in ${mode} mode
|
|
210
|
+
? `Playtest started in ${mode} mode with ${numPlayers} player(s).`
|
|
211
|
+
: `Playtest started in ${mode} mode.`;
|
|
198
212
|
|
|
199
213
|
const response: Record<string, unknown> = {
|
|
200
214
|
success: true,
|
|
201
215
|
message: msg,
|
|
202
|
-
evalBridges: bridgeInstall.installed ? "installed" : `failed: ${bridgeInstall.error}`,
|
|
203
216
|
};
|
|
217
|
+
// Only mention eval bridges when they failed — when they're fine, the
|
|
218
|
+
// detail is noise. eval_server_runtime / eval_client_runtime will surface
|
|
219
|
+
// their own clear errors if the caller tries to use them after a failed
|
|
220
|
+
// install.
|
|
221
|
+
if (!bridgeInstall.installed) {
|
|
222
|
+
response.evalBridgesError = bridgeInstall.error;
|
|
223
|
+
}
|
|
204
224
|
|
|
205
225
|
return response;
|
|
206
226
|
}
|
|
207
227
|
|
|
208
228
|
function stopPlaytest(_requestData: Record<string, unknown>) {
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
// the
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
229
|
+
// Signal the play-server DM's StopPlayMonitor via plugin:SetSetting (a
|
|
230
|
+
// cross-DM persistent store). The monitor polls at 1Hz, sees the flag,
|
|
231
|
+
// calls StudioTestService:EndTest, then resets the flag. We wait up to
|
|
232
|
+
// 2.5s for the reset to confirm a play DM actually consumed the request,
|
|
233
|
+
// which avoids returning success when nothing is running.
|
|
234
|
+
if (!StopPlayMonitor.requestStop()) {
|
|
235
|
+
return { error: "Plugin not ready. Try again in a moment." };
|
|
236
|
+
}
|
|
237
|
+
if (!StopPlayMonitor.waitForConsumption()) {
|
|
238
|
+
// Clean up the pending flag so a future playtest's monitor doesn't fire
|
|
239
|
+
// EndTest on its own startup against a stale signal.
|
|
240
|
+
StopPlayMonitor.clearPending();
|
|
241
|
+
return { error: "No active playtest to stop." };
|
|
242
|
+
}
|
|
243
|
+
// Flag was consumed (EndTest called). ExecutePlayModeAsync in our
|
|
244
|
+
// startPlaytest task.spawn is still unwinding though — testRunning stays
|
|
245
|
+
// true until that yield completes and the post-block runs. Wait so
|
|
246
|
+
// back-to-back stop -> start sequences don't race against the prior
|
|
247
|
+
// teardown and get "A test is already running". 10s covers play-DM
|
|
248
|
+
// teardown on heavier places; if it still hasn't cleared we return
|
|
249
|
+
// anyway so users aren't stuck — but note that in the response so the
|
|
250
|
+
// caller knows a subsequent start may need a moment.
|
|
251
|
+
const deadline = tick() + 10;
|
|
252
|
+
while (testRunning && tick() < deadline) {
|
|
253
|
+
task.wait(0.1);
|
|
254
|
+
}
|
|
255
|
+
if (testRunning) {
|
|
256
|
+
return {
|
|
257
|
+
success: true,
|
|
258
|
+
message: "Playtest stop signal sent; teardown still in progress.",
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
return { success: true, message: "Playtest stopped." };
|
|
222
262
|
}
|
|
223
263
|
|
|
224
264
|
function getPlaytestOutput(_requestData: Record<string, unknown>) {
|
|
@@ -3,12 +3,18 @@ import UI from "../modules/UI";
|
|
|
3
3
|
import Communication from "../modules/Communication";
|
|
4
4
|
import ClientBroker from "../modules/ClientBroker";
|
|
5
5
|
import RuntimeLogBuffer from "../modules/RuntimeLogBuffer";
|
|
6
|
+
import StopPlayMonitor from "../modules/StopPlayMonitor";
|
|
6
7
|
|
|
7
8
|
// Attach the per-peer LogService.MessageOut listener as early as possible so
|
|
8
9
|
// boot-time prints from the user's place scripts are captured. Powers the
|
|
9
10
|
// get_runtime_logs MCP tool. Idempotent; safe to call before UI.init().
|
|
10
11
|
RuntimeLogBuffer.install();
|
|
11
12
|
|
|
13
|
+
// Share the plugin reference with the stop-play signaling module so both the
|
|
14
|
+
// edit DM (write the flag) and the play-server DM (read+act on the flag) can
|
|
15
|
+
// access plugin:SetSetting/GetSetting.
|
|
16
|
+
StopPlayMonitor.init(plugin);
|
|
17
|
+
|
|
12
18
|
UI.init(plugin);
|
|
13
19
|
const elements = UI.getElements();
|
|
14
20
|
|
|
@@ -67,6 +73,10 @@ task.delay(2, () => {
|
|
|
67
73
|
}
|
|
68
74
|
if (role === "server") {
|
|
69
75
|
ClientBroker.setupServerBroker();
|
|
76
|
+
// The play-server DM is the only one where StudioTestService:EndTest is
|
|
77
|
+
// legal, so the stop-play monitor lives here. Reads MCP_STOP_PLAY_SIGNAL
|
|
78
|
+
// at 1Hz and calls EndTest when the edit DM sets it.
|
|
79
|
+
StopPlayMonitor.startMonitor();
|
|
70
80
|
} else if (role === "client") {
|
|
71
81
|
ClientBroker.setupClientBroker();
|
|
72
82
|
}
|