@chrrxs/robloxstudio-mcp 2.11.4 → 2.13.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 +2066 -435
- package/package.json +1 -1
- package/studio-plugin/MCPPlugin.rbxmx +935 -298
- package/studio-plugin/src/modules/ClientBroker.ts +90 -36
- package/studio-plugin/src/modules/Communication.ts +118 -5
- package/studio-plugin/src/modules/EvalBridges.ts +60 -11
- package/studio-plugin/src/modules/LuauExec.ts +305 -0
- package/studio-plugin/src/modules/RenderMonitor.ts +60 -0
- package/studio-plugin/src/modules/StopPlayMonitor.ts +67 -31
- package/studio-plugin/src/modules/handlers/CaptureHandlers.ts +45 -3
- package/studio-plugin/src/modules/handlers/InputHandlers.ts +100 -39
- package/studio-plugin/src/modules/handlers/MetadataHandlers.ts +7 -146
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +31 -8
- package/studio-plugin/src/server/index.server.ts +6 -0
- package/studio-plugin/src/types/index.d.ts +5 -2
|
@@ -1,55 +1,101 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
// Virtual input via UserInputService:CreateVirtualInput().
|
|
2
|
+
//
|
|
3
|
+
// We deliberately do NOT use VirtualInputManager:Send*Event — those methods
|
|
4
|
+
// are gated behind RobloxScriptSecurity ("lacking capability RobloxScript")
|
|
5
|
+
// in every context a plugin can reach (edit DM, play server/client DMs), so
|
|
6
|
+
// they silently never worked. CreateVirtualInput() is callable without that
|
|
7
|
+
// capability and drives the REAL input pipeline: SendKey feeds
|
|
8
|
+
// UserInputService.InputBegan/Ended and the control modules (so WASD walks the
|
|
9
|
+
// character at full WalkSpeed with controls intact, no Humanoid hijack),
|
|
10
|
+
// SendMouseButton feeds UIS and activates GUI buttons (and hit-tests against
|
|
11
|
+
// CoreGui), and SendTextInput types into the focused TextBox.
|
|
12
|
+
//
|
|
13
|
+
// Method set on the VirtualInput object (verified live):
|
|
14
|
+
// SendKey(isDown: boolean, keyCode: Enum.KeyCode)
|
|
15
|
+
// SendMouseButton(position: Vector2, inputType: Enum.UserInputType, isDown: boolean)
|
|
16
|
+
// SendTextInput(text: string)
|
|
17
|
+
// There is NO SendMouseMove / SendMouseWheel / SendKeyEvent — so "move" and
|
|
18
|
+
// "scroll" mouse actions are not supported.
|
|
19
|
+
//
|
|
20
|
+
// Coordinate space: SendMouseButton coordinates are viewport pixels matching
|
|
21
|
+
// what capture_screenshot returns (window space, origin at the top-left of the
|
|
22
|
+
// rendered viewport). Pass screenshot pixel coordinates straight through. Note
|
|
23
|
+
// that UserInputService reports input positions in GUI space, which is offset
|
|
24
|
+
// from this by GuiService:GetGuiInset() (~58px on the Y axis) — irrelevant for
|
|
25
|
+
// callers who pick coordinates off a screenshot, which is why we do not
|
|
26
|
+
// translate here.
|
|
27
|
+
|
|
28
|
+
import * as RenderMonitor from "../RenderMonitor";
|
|
29
|
+
|
|
30
|
+
const UserInputService = game.GetService("UserInputService");
|
|
31
|
+
|
|
32
|
+
interface VirtualInput {
|
|
33
|
+
SendKey(isDown: boolean, keyCode: Enum.KeyCode): void;
|
|
34
|
+
SendMouseButton(position: Vector2, inputType: Enum.UserInputType, isDown: boolean): void;
|
|
35
|
+
SendTextInput(text: string): void;
|
|
6
36
|
}
|
|
7
37
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
38
|
+
// One VirtualInput per plugin VM, reused across calls so that a key held down
|
|
39
|
+
// in one call (action="press") and released in a later call (action="release")
|
|
40
|
+
// share the same input source.
|
|
41
|
+
let cachedVI: VirtualInput | undefined;
|
|
42
|
+
|
|
43
|
+
function getVI(): VirtualInput | undefined {
|
|
44
|
+
if (cachedVI) return cachedVI;
|
|
45
|
+
const [ok, vi] = pcall(() => {
|
|
46
|
+
return (UserInputService as unknown as { CreateVirtualInput(): unknown }).CreateVirtualInput();
|
|
11
47
|
});
|
|
12
|
-
if (ok &&
|
|
48
|
+
if (ok && vi !== undefined) {
|
|
49
|
+
cachedVI = vi as VirtualInput;
|
|
50
|
+
return cachedVI;
|
|
51
|
+
}
|
|
13
52
|
return undefined;
|
|
14
53
|
}
|
|
15
54
|
|
|
16
|
-
const
|
|
55
|
+
const MOUSE_TYPE_MAP: Record<string, Enum.UserInputType> = {
|
|
56
|
+
Left: Enum.UserInputType.MouseButton1,
|
|
57
|
+
Right: Enum.UserInputType.MouseButton2,
|
|
58
|
+
Middle: Enum.UserInputType.MouseButton3,
|
|
59
|
+
};
|
|
17
60
|
|
|
18
61
|
function simulateMouseInput(requestData: Record<string, unknown>) {
|
|
19
62
|
const action = requestData.action as string;
|
|
20
63
|
const x = requestData.x as number | undefined;
|
|
21
64
|
const y = requestData.y as number | undefined;
|
|
22
65
|
const button = (requestData.button as string) ?? "Left";
|
|
23
|
-
const scrollDirection = requestData.scrollDirection as string | undefined;
|
|
24
66
|
|
|
25
67
|
if (!action) return { error: "action is required" };
|
|
68
|
+
if (x === undefined || y === undefined) {
|
|
69
|
+
return { error: "x and y are required" };
|
|
70
|
+
}
|
|
26
71
|
|
|
27
|
-
|
|
28
|
-
|
|
72
|
+
// Input is silently dropped by the engine when the window isn't rendering
|
|
73
|
+
// (e.g. minimized). Surface that instead of returning a false success.
|
|
74
|
+
const notRendering = RenderMonitor.notRenderingReason();
|
|
75
|
+
if (notRendering !== undefined) return { error: notRendering };
|
|
76
|
+
|
|
77
|
+
const vi = getVI();
|
|
78
|
+
if (!vi) {
|
|
79
|
+
return { error: "UserInputService:CreateVirtualInput() is not available in this context" };
|
|
80
|
+
}
|
|
29
81
|
|
|
30
|
-
const
|
|
82
|
+
const inputType = MOUSE_TYPE_MAP[button] ?? Enum.UserInputType.MouseButton1;
|
|
83
|
+
const pos = new Vector2(x, y);
|
|
31
84
|
|
|
32
85
|
const [success, err] = pcall(() => {
|
|
33
86
|
if (action === "click") {
|
|
34
|
-
|
|
35
|
-
vim.SendMouseButtonEvent(x, y, buttonNum, true);
|
|
87
|
+
vi.SendMouseButton(pos, inputType, true);
|
|
36
88
|
task.wait(0.05);
|
|
37
|
-
|
|
89
|
+
vi.SendMouseButton(pos, inputType, false);
|
|
38
90
|
} else if (action === "mouseDown") {
|
|
39
|
-
|
|
40
|
-
vim.SendMouseButtonEvent(x, y, buttonNum, true);
|
|
91
|
+
vi.SendMouseButton(pos, inputType, true);
|
|
41
92
|
} else if (action === "mouseUp") {
|
|
42
|
-
|
|
43
|
-
vim.SendMouseButtonEvent(x, y, buttonNum, false);
|
|
44
|
-
} else if (action === "move") {
|
|
45
|
-
if (x === undefined || y === undefined) error("x and y are required for move");
|
|
46
|
-
vim.SendMouseMoveEvent(x, y);
|
|
47
|
-
} else if (action === "scroll") {
|
|
48
|
-
if (x === undefined || y === undefined) error("x and y are required for scroll");
|
|
49
|
-
if (!scrollDirection) error("scrollDirection is required for scroll");
|
|
50
|
-
vim.SendMouseWheelEvent(x, y, scrollDirection === "up");
|
|
93
|
+
vi.SendMouseButton(pos, inputType, false);
|
|
51
94
|
} else {
|
|
52
|
-
error(
|
|
95
|
+
error(
|
|
96
|
+
`Unsupported action "${action}". CreateVirtualInput supports click, mouseDown, mouseUp ` +
|
|
97
|
+
`(no move/scroll — those methods don't exist on VirtualInput).`,
|
|
98
|
+
);
|
|
53
99
|
}
|
|
54
100
|
});
|
|
55
101
|
|
|
@@ -60,31 +106,46 @@ function simulateMouseInput(requestData: Record<string, unknown>) {
|
|
|
60
106
|
}
|
|
61
107
|
|
|
62
108
|
function simulateKeyboardInput(requestData: Record<string, unknown>) {
|
|
109
|
+
const notRendering = RenderMonitor.notRenderingReason();
|
|
110
|
+
if (notRendering !== undefined) return { error: notRendering };
|
|
111
|
+
|
|
112
|
+
const vi = getVI();
|
|
113
|
+
if (!vi) {
|
|
114
|
+
return { error: "UserInputService:CreateVirtualInput() is not available in this context" };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Text mode: type a string into the focused TextBox.
|
|
118
|
+
const text = requestData.text as string | undefined;
|
|
119
|
+
if (text !== undefined) {
|
|
120
|
+
const [ok, err] = pcall(() => vi.SendTextInput(text));
|
|
121
|
+
if (ok) return { success: true, text };
|
|
122
|
+
return { error: `Failed to send text input: ${err}` };
|
|
123
|
+
}
|
|
124
|
+
|
|
63
125
|
const keyCodeName = requestData.keyCode as string;
|
|
126
|
+
if (!keyCodeName) return { error: "keyCode (or text) is required" };
|
|
127
|
+
|
|
64
128
|
const action = (requestData.action as string) ?? "tap";
|
|
65
129
|
const duration = (requestData.duration as number) ?? 0.1;
|
|
66
130
|
|
|
67
|
-
if (!keyCodeName) return { error: "keyCode is required" };
|
|
68
|
-
|
|
69
|
-
const vim = getVIM();
|
|
70
|
-
if (!vim) return { error: "VirtualInputManager is not available in this context" };
|
|
71
|
-
|
|
72
131
|
const [enumOk, keyCode] = pcall(() => {
|
|
73
132
|
return (Enum.KeyCode as unknown as Record<string, Enum.KeyCode>)[keyCodeName];
|
|
74
133
|
});
|
|
75
134
|
if (!enumOk || !keyCode) {
|
|
76
|
-
return {
|
|
135
|
+
return {
|
|
136
|
+
error: `Unknown keyCode: ${keyCodeName}. Use Enum.KeyCode names like "W", "Space", "E", "LeftShift", etc.`,
|
|
137
|
+
};
|
|
77
138
|
}
|
|
78
139
|
|
|
79
140
|
const [success, err] = pcall(() => {
|
|
80
141
|
if (action === "press") {
|
|
81
|
-
|
|
142
|
+
vi.SendKey(true, keyCode);
|
|
82
143
|
} else if (action === "release") {
|
|
83
|
-
|
|
144
|
+
vi.SendKey(false, keyCode);
|
|
84
145
|
} else if (action === "tap") {
|
|
85
|
-
|
|
146
|
+
vi.SendKey(true, keyCode);
|
|
86
147
|
task.wait(duration);
|
|
87
|
-
|
|
148
|
+
vi.SendKey(false, keyCode);
|
|
88
149
|
} else {
|
|
89
150
|
error(`Unknown action: ${action}`);
|
|
90
151
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { CollectionService
|
|
1
|
+
import { CollectionService } from "@rbxts/services";
|
|
2
2
|
import Utils from "../Utils";
|
|
3
3
|
import Recording from "../Recording";
|
|
4
|
+
import LuauExec from "../LuauExec";
|
|
4
5
|
|
|
5
6
|
const ChangeHistoryService = game.GetService("ChangeHistoryService");
|
|
6
7
|
const Selection = game.GetService("Selection");
|
|
@@ -262,151 +263,11 @@ function getSelection(_requestData: Record<string, unknown>) {
|
|
|
262
263
|
function executeLuau(requestData: Record<string, unknown>) {
|
|
263
264
|
const code = requestData.code as string;
|
|
264
265
|
if (!code || code === "") return { error: "Code is required" };
|
|
265
|
-
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
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
|
-
}
|
|
315
|
-
|
|
316
|
-
const runViaModuleScript = (): WrapperResult => {
|
|
317
|
-
const m = new Instance("ModuleScript");
|
|
318
|
-
m.Name = "__MCPExecLuauPayload";
|
|
319
|
-
const [okSet, setErr] = pcall(() => {
|
|
320
|
-
(m as unknown as { Source: string }).Source = wrapped;
|
|
321
|
-
});
|
|
322
|
-
if (!okSet) {
|
|
323
|
-
m.Destroy();
|
|
324
|
-
error(`ModuleScript Source set failed: ${tostring(setErr)}`);
|
|
325
|
-
}
|
|
326
|
-
m.Parent = game.GetService("Workspace");
|
|
327
|
-
const [okReq, reqResult] = pcall(() => require(m));
|
|
328
|
-
m.Destroy();
|
|
329
|
-
if (!okReq) {
|
|
330
|
-
let errMsg = tostring(reqResult);
|
|
331
|
-
// pcall(require, m) collapses parse/compile failures into the
|
|
332
|
-
// canned engine string below. Walk LogService backward for the
|
|
333
|
-
// real diagnostic, which was emitted to MessageOut just before.
|
|
334
|
-
if (errMsg === "Requested module experienced an error while loading") {
|
|
335
|
-
// The parser diagnostic is emitted to LogService on the next
|
|
336
|
-
// engine frame, not synchronously with pcall(require). task.wait(0)
|
|
337
|
-
// yields too early; 50ms is enough to let the frame complete and
|
|
338
|
-
// the message land in GetLogHistory.
|
|
339
|
-
task.wait(0.05);
|
|
340
|
-
const hist = LogService.GetLogHistory();
|
|
341
|
-
for (let i = hist.size() - 1; i >= 0; i--) {
|
|
342
|
-
const e = hist[i];
|
|
343
|
-
if (
|
|
344
|
-
e.messageType === Enum.MessageType.MessageError &&
|
|
345
|
-
string.sub(e.message, 1, 31) === "Workspace.__MCPExecLuauPayload:"
|
|
346
|
-
) {
|
|
347
|
-
errMsg = e.message;
|
|
348
|
-
break;
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
error(errMsg);
|
|
353
|
-
}
|
|
354
|
-
return reqResult as unknown as WrapperResult;
|
|
355
|
-
};
|
|
356
|
-
|
|
357
|
-
const isLoadstringUnavailable = (err: unknown): boolean => {
|
|
358
|
-
const errStr = tostring(err);
|
|
359
|
-
const [matchStart] = string.find(errStr, "not available", 1, true);
|
|
360
|
-
return matchStart !== undefined;
|
|
361
|
-
};
|
|
362
|
-
|
|
363
|
-
let [success, result] = pcall(() => {
|
|
364
|
-
const [fn, compileError] = loadstring(wrapped);
|
|
365
|
-
if (!fn) {
|
|
366
|
-
if (isLoadstringUnavailable(compileError)) {
|
|
367
|
-
return runViaModuleScript();
|
|
368
|
-
}
|
|
369
|
-
error(`Compile error: ${compileError}`);
|
|
370
|
-
}
|
|
371
|
-
return fn() as unknown as WrapperResult;
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
// loadstring throws (not returns nil) in some plugin contexts when
|
|
375
|
-
// LoadStringEnabled=false. Catch that as a second-chance fallback.
|
|
376
|
-
if (!success && isLoadstringUnavailable(result)) {
|
|
377
|
-
[success, result] = pcall(runViaModuleScript);
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
if (!success) {
|
|
381
|
-
// Outer pcall failed - the wrapper itself didn't even run (e.g. compile
|
|
382
|
-
// error in the user code, or ModuleScript setup error). 'result' is the
|
|
383
|
-
// raw error string from pcall.
|
|
384
|
-
return {
|
|
385
|
-
success: false,
|
|
386
|
-
error: tostring(result),
|
|
387
|
-
output: [],
|
|
388
|
-
message: "Code execution failed",
|
|
389
|
-
};
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// Wrapper executed - unpack { ok, value, output }.
|
|
393
|
-
const r = result as unknown as WrapperResult;
|
|
394
|
-
const capturedOutput = r.output as unknown as string[] | undefined;
|
|
395
|
-
const output = capturedOutput !== undefined ? capturedOutput : ([] as string[]);
|
|
396
|
-
if (r.ok === true) {
|
|
397
|
-
return {
|
|
398
|
-
success: true,
|
|
399
|
-
returnValue: r.value !== undefined ? tostring(r.value) : undefined,
|
|
400
|
-
output,
|
|
401
|
-
message: "Code executed successfully",
|
|
402
|
-
};
|
|
403
|
-
}
|
|
404
|
-
return {
|
|
405
|
-
success: false,
|
|
406
|
-
error: r.value !== undefined ? tostring(r.value) : "(unknown error)",
|
|
407
|
-
output,
|
|
408
|
-
message: "Code execution failed",
|
|
409
|
-
};
|
|
266
|
+
// All wrapping, print/warn capture, loadstring fallback, JSON-encoding
|
|
267
|
+
// of table returns, and parse-error recovery live in LuauExec so the
|
|
268
|
+
// edit/server (this handler) and the play-client (ClientBroker) take
|
|
269
|
+
// the same code path and produce identical output shapes.
|
|
270
|
+
return LuauExec.execute(code);
|
|
410
271
|
}
|
|
411
272
|
|
|
412
273
|
function undo(_requestData: Record<string, unknown>) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { HttpService, LogService, RunService } from "@rbxts/services";
|
|
2
|
-
import { installBridges,
|
|
2
|
+
import { installBridges, ensureBridgesInstalled } from "../EvalBridges";
|
|
3
3
|
import StopPlayMonitor from "../StopPlayMonitor";
|
|
4
4
|
|
|
5
5
|
const StudioTestService = game.GetService("StudioTestService");
|
|
@@ -135,7 +135,9 @@ function startPlaytest(requestData: Record<string, unknown>) {
|
|
|
135
135
|
logConnection = undefined;
|
|
136
136
|
}
|
|
137
137
|
cleanupStopListener();
|
|
138
|
-
|
|
138
|
+
// Note: eval bridges are intentionally NOT cleaned up — they live
|
|
139
|
+
// permanently in the edit DM so manual playtests also get them. See
|
|
140
|
+
// EvalBridges.ts lifecycle comment.
|
|
139
141
|
}
|
|
140
142
|
|
|
141
143
|
if (testRunning) {
|
|
@@ -169,9 +171,10 @@ function startPlaytest(requestData: Record<string, unknown>) {
|
|
|
169
171
|
warn(`[MCP] Failed to inject stop listener: ${injErr}`);
|
|
170
172
|
}
|
|
171
173
|
|
|
172
|
-
//
|
|
173
|
-
// so
|
|
174
|
-
//
|
|
174
|
+
// Force-refresh the game-VM eval bridges (ServerEvalBridge + ClientEvalBridge)
|
|
175
|
+
// right before cloning so the play DMs get the current source. They also
|
|
176
|
+
// live permanently in the edit DM (installed on connect) so manually-started
|
|
177
|
+
// playtests get them too; here we just ensure they're fresh.
|
|
175
178
|
const bridgeInstall = installBridges();
|
|
176
179
|
if (!bridgeInstall.installed) {
|
|
177
180
|
warn(`[MCP] Eval bridge install failed: ${bridgeInstall.error}`);
|
|
@@ -203,7 +206,9 @@ function startPlaytest(requestData: Record<string, unknown>) {
|
|
|
203
206
|
testRunning = false;
|
|
204
207
|
|
|
205
208
|
cleanupStopListener();
|
|
206
|
-
|
|
209
|
+
// Eval bridges persist in the edit DM (see EvalBridges.ts) — do not
|
|
210
|
+
// clean up here, so the next manual playtest still gets them.
|
|
211
|
+
ensureBridgesInstalled();
|
|
207
212
|
});
|
|
208
213
|
|
|
209
214
|
const msg = numPlayers !== undefined
|
|
@@ -235,9 +240,27 @@ function stopPlaytest(_requestData: Record<string, unknown>) {
|
|
|
235
240
|
return { error: "Plugin not ready. Try again in a moment." };
|
|
236
241
|
}
|
|
237
242
|
if (!StopPlayMonitor.waitForConsumption()) {
|
|
238
|
-
//
|
|
239
|
-
//
|
|
243
|
+
// Two distinct failure modes collapse here, distinguished by whether
|
|
244
|
+
// THIS edit DM has a playtest tracked:
|
|
245
|
+
//
|
|
246
|
+
// - testRunning=false: no playtest was running from this edit DM
|
|
247
|
+
// (true negative). Return "no active playtest" — fine to retry only
|
|
248
|
+
// after actually starting a playtest.
|
|
249
|
+
// - testRunning=true: a playtest IS running but the cross-DM signal
|
|
250
|
+
// didn't propagate within the consumption timeout (false negative
|
|
251
|
+
// from the caller's perspective — playtest may actually have ended).
|
|
252
|
+
// Tell the caller it's a timing issue and they can retry.
|
|
253
|
+
//
|
|
254
|
+
// Either way clean up the pending flag so a future playtest's monitor
|
|
255
|
+
// doesn't fire EndTest on startup against a stale signal.
|
|
240
256
|
StopPlayMonitor.clearPending();
|
|
257
|
+
if (testRunning) {
|
|
258
|
+
return {
|
|
259
|
+
error:
|
|
260
|
+
"Playtest stop signal sent but consumption confirmation timed out. " +
|
|
261
|
+
"The playtest may have ended anyway; check get_connected_instances.",
|
|
262
|
+
};
|
|
263
|
+
}
|
|
241
264
|
return { error: "No active playtest to stop." };
|
|
242
265
|
}
|
|
243
266
|
// Flag was consumed (EndTest called). ExecutePlayModeAsync in our
|
|
@@ -4,6 +4,12 @@ import Communication from "../modules/Communication";
|
|
|
4
4
|
import ClientBroker from "../modules/ClientBroker";
|
|
5
5
|
import RuntimeLogBuffer from "../modules/RuntimeLogBuffer";
|
|
6
6
|
import StopPlayMonitor from "../modules/StopPlayMonitor";
|
|
7
|
+
import * as RenderMonitor from "../modules/RenderMonitor";
|
|
8
|
+
|
|
9
|
+
// Track render-loop liveness so input/screenshot tools can report "window
|
|
10
|
+
// minimized / not rendering" instead of silently no-op'ing. No-op in the
|
|
11
|
+
// server DM (RenderStepped can't connect there).
|
|
12
|
+
RenderMonitor.start();
|
|
7
13
|
|
|
8
14
|
// Attach the per-peer LogService.MessageOut listener as early as possible so
|
|
9
15
|
// boot-time prints from the user's place scripts are captured. Powers the
|
|
@@ -33,14 +33,17 @@ export interface PollResponse {
|
|
|
33
33
|
request?: RequestPayload;
|
|
34
34
|
requestId?: string;
|
|
35
35
|
// Server signals knownInstance=false when its in-memory instances map
|
|
36
|
-
// doesn't contain our
|
|
37
|
-
// The plugin re-issues /ready when it sees this.
|
|
36
|
+
// doesn't contain our pluginSessionId (typically after an MCP process
|
|
37
|
+
// restart). The plugin re-issues /ready when it sees this.
|
|
38
38
|
knownInstance?: boolean;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export interface ReadyResponse {
|
|
42
42
|
success: boolean;
|
|
43
43
|
assignedRole?: string;
|
|
44
|
+
instanceId?: string;
|
|
45
|
+
error?: string;
|
|
46
|
+
message?: string;
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
declare global {
|