@princeofscale/bloxforge 2.20.2

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.
Files changed (49) hide show
  1. package/assets/Baseplate.rbxl +0 -0
  2. package/dist/index.js +19391 -0
  3. package/package.json +62 -0
  4. package/studio-plugin/INSTALLATION.md +161 -0
  5. package/studio-plugin/MCPInspectorPlugin.rbxmx +171699 -0
  6. package/studio-plugin/MCPPlugin.rbxmx +171699 -0
  7. package/studio-plugin/default.project.json +19 -0
  8. package/studio-plugin/dev.project.json +23 -0
  9. package/studio-plugin/include/LibMP.lua +156378 -0
  10. package/studio-plugin/inspector-icon.png +0 -0
  11. package/studio-plugin/package-lock.json +692 -0
  12. package/studio-plugin/package.json +19 -0
  13. package/studio-plugin/plugin.json +10 -0
  14. package/studio-plugin/src/modules/ClientBroker.ts +447 -0
  15. package/studio-plugin/src/modules/Communication.ts +697 -0
  16. package/studio-plugin/src/modules/EvalBridges.ts +255 -0
  17. package/studio-plugin/src/modules/HttpDiagnostics.ts +50 -0
  18. package/studio-plugin/src/modules/JobRegistry.ts +77 -0
  19. package/studio-plugin/src/modules/LuauExec.ts +465 -0
  20. package/studio-plugin/src/modules/Recording.ts +28 -0
  21. package/studio-plugin/src/modules/RenderMonitor.ts +60 -0
  22. package/studio-plugin/src/modules/RuntimeLogBuffer.ts +152 -0
  23. package/studio-plugin/src/modules/ServerUrlSettings.ts +114 -0
  24. package/studio-plugin/src/modules/State.ts +101 -0
  25. package/studio-plugin/src/modules/StopPlayMonitor.ts +276 -0
  26. package/studio-plugin/src/modules/UI.ts +790 -0
  27. package/studio-plugin/src/modules/Utils.ts +318 -0
  28. package/studio-plugin/src/modules/handlers/AssetHandlers.ts +241 -0
  29. package/studio-plugin/src/modules/handlers/BreakpointHandlers.ts +460 -0
  30. package/studio-plugin/src/modules/handlers/BuildHandlers.ts +481 -0
  31. package/studio-plugin/src/modules/handlers/CaptureHandlers.ts +203 -0
  32. package/studio-plugin/src/modules/handlers/EvalRuntimeHandlers.ts +149 -0
  33. package/studio-plugin/src/modules/handlers/InputHandlers.ts +160 -0
  34. package/studio-plugin/src/modules/handlers/InstanceHandlers.ts +380 -0
  35. package/studio-plugin/src/modules/handlers/JobHandlers.ts +111 -0
  36. package/studio-plugin/src/modules/handlers/LogHandlers.ts +14 -0
  37. package/studio-plugin/src/modules/handlers/MemoryHandlers.ts +44 -0
  38. package/studio-plugin/src/modules/handlers/MetadataHandlers.ts +354 -0
  39. package/studio-plugin/src/modules/handlers/MicroProfilerHandlers.ts +1263 -0
  40. package/studio-plugin/src/modules/handlers/PropertyHandlers.ts +191 -0
  41. package/studio-plugin/src/modules/handlers/QueryHandlers.ts +809 -0
  42. package/studio-plugin/src/modules/handlers/SceneAnalysisHandlers.ts +216 -0
  43. package/studio-plugin/src/modules/handlers/ScriptHandlers.ts +530 -0
  44. package/studio-plugin/src/modules/handlers/ScriptProfilerHandlers.ts +386 -0
  45. package/studio-plugin/src/modules/handlers/SerializationHandlers.ts +172 -0
  46. package/studio-plugin/src/modules/handlers/TestHandlers.ts +535 -0
  47. package/studio-plugin/src/server/index.server.ts +134 -0
  48. package/studio-plugin/src/types/index.d.ts +83 -0
  49. package/studio-plugin/tsconfig.json +21 -0
@@ -0,0 +1,152 @@
1
+ // Per-capture in-memory ring buffer for LogService.MessageOut events.
2
+ // Powers the get_runtime_logs MCP tool. Replaces the out-of-tree LogBuffer
3
+ // primitives + StringValue approach from chrrxs/roblox-mcp-primitives.
4
+ //
5
+ // Each peer's plugin attaches a MessageOut listener at plugin load (edit DM,
6
+ // play-server DM, play-client DM all run their own copy of this module).
7
+ // Captured entries live in plugin module-state; nothing is parented to the
8
+ // DataModel. The buffer is bounded by a message-byte budget; oldest entries
9
+ // drop when over budget.
10
+ //
11
+ // Capture caveat: returned entries reflect which plugin buffer CAPTURED the
12
+ // entry, NOT which peer's script originated the print. LogService reflects
13
+ // prints across peers in ordinary Studio Play (a server print can appear in
14
+ // server and client LogService:GetLogHistory()). The MCP-side aggregator
15
+ // exposes that as capturedBy, and only promotes it to origin peer in
16
+ // StudioTestService multiplayer sessions where peer attribution is reliable.
17
+
18
+ import { LogService, RunService } from "@rbxts/services";
19
+
20
+ type LogLevel = "OUT" | "WARN" | "ERR" | "INFO";
21
+
22
+ interface RuntimeLogEntry {
23
+ seq: number;
24
+ ts: number; // wall-clock seconds via DateTime, coherent across peers
25
+ level: LogLevel;
26
+ message: string;
27
+ }
28
+
29
+ const MAX_BYTES = 64 * 1024;
30
+ const HARD_ENTRY_CAP = 50_000;
31
+
32
+ const entries: RuntimeLogEntry[] = [];
33
+ let totalBytes = 0;
34
+ let totalDropped = 0;
35
+ let nextSeq = 1;
36
+ let installed = false;
37
+
38
+ function levelTag(t: Enum.MessageType): LogLevel {
39
+ if (t === Enum.MessageType.MessageWarning) return "WARN";
40
+ if (t === Enum.MessageType.MessageError) return "ERR";
41
+ if (t === Enum.MessageType.MessageInfo) return "INFO";
42
+ return "OUT";
43
+ }
44
+
45
+ function nowSec(): number {
46
+ return DateTime.now().UnixTimestampMillis / 1000;
47
+ }
48
+
49
+ function dropOldestUntilFits(incomingBytes: number): void {
50
+ while (
51
+ entries.size() > 0 &&
52
+ (totalBytes + incomingBytes > MAX_BYTES || entries.size() >= HARD_ENTRY_CAP)
53
+ ) {
54
+ const dropped = entries.shift()!;
55
+ totalBytes -= dropped.message.size();
56
+ totalDropped += 1;
57
+ }
58
+ }
59
+
60
+ function pushEntry(message: string, level: LogLevel, ts: number): void {
61
+ const bytes = message.size();
62
+ dropOldestUntilFits(bytes);
63
+ entries.push({ seq: nextSeq, ts, level, message });
64
+ nextSeq += 1;
65
+ totalBytes += bytes;
66
+ }
67
+
68
+ // Backfill the buffer with logs already emitted before this listener attached.
69
+ // In ordinary Studio Play the server/client DMs start (and print, e.g. a game's
70
+ // startup banner) before the plugin peer's MessageOut listener connects, so those
71
+ // entries were lost (bug B8). GetLogHistory recovers them on install.
72
+ function seedFromHistory(): void {
73
+ const [ok, history] = pcall(() => LogService.GetLogHistory());
74
+ if (!ok || !history) return;
75
+ for (const info of history as Array<{ message: string; messageType: Enum.MessageType; timestamp: number }>) {
76
+ if (info && typeIs(info.message, "string")) {
77
+ pushEntry(info.message, levelTag(info.messageType), info.timestamp ?? nowSec());
78
+ }
79
+ }
80
+ }
81
+
82
+ function install(): void {
83
+ if (installed) return;
84
+ if (!RunService.IsStudio()) return;
85
+ installed = true;
86
+ seedFromHistory();
87
+ LogService.MessageOut.Connect((msg, t) => {
88
+ pushEntry(msg, levelTag(t), nowSec());
89
+ });
90
+ }
91
+
92
+ function detectPeer(): "edit" | "server" | "client" {
93
+ if (!RunService.IsRunning()) return "edit";
94
+ if (RunService.IsServer()) return "server";
95
+ return "client";
96
+ }
97
+
98
+ interface QueryOptions {
99
+ since?: number;
100
+ tail?: number;
101
+ filter?: string; // Plain substring match, applied to message
102
+ }
103
+
104
+ interface QueryResult {
105
+ capturedBy: string;
106
+ entries: RuntimeLogEntry[];
107
+ totalDropped: number;
108
+ nextSince: number;
109
+ }
110
+
111
+ function query(opts: QueryOptions, capturedBy: string): QueryResult {
112
+ let result = opts.since !== undefined
113
+ ? entries.filter((e) => e.seq > (opts.since as number))
114
+ : [...entries];
115
+
116
+ if (opts.filter !== undefined) {
117
+ // Plain substring search (4th arg = true). Pattern matching here was
118
+ // surprising in practice - Lua magic chars in messages would silently
119
+ // not match (e.g. filter="MARK-EDIT" against "MARK-EDIT-001" fails
120
+ // because '-' means "0+" in Lua patterns). Substring search matches
121
+ // most users' mental model of "filter messages containing this text".
122
+ const needle = opts.filter;
123
+ result = result.filter((e) => {
124
+ const [start] = string.find(e.message, needle, 1, true);
125
+ return start !== undefined;
126
+ });
127
+ }
128
+
129
+ if (opts.tail !== undefined && result.size() > opts.tail) {
130
+ // roblox-ts arrays don't expose .slice; manual tail copy.
131
+ const tailed: RuntimeLogEntry[] = [];
132
+ const start = result.size() - opts.tail;
133
+ for (let i = start; i < result.size(); i++) {
134
+ tailed.push(result[i]);
135
+ }
136
+ result = tailed;
137
+ }
138
+
139
+ const last = entries.size() > 0 ? entries[entries.size() - 1] : undefined;
140
+ return {
141
+ capturedBy,
142
+ entries: result,
143
+ totalDropped,
144
+ nextSince: last ? last.seq : (opts.since ?? 0),
145
+ };
146
+ }
147
+
148
+ export = {
149
+ install,
150
+ detectPeer,
151
+ query,
152
+ };
@@ -0,0 +1,114 @@
1
+ import { HttpService, ServerStorage } from "@rbxts/services";
2
+
3
+ const LEGACY_SETTING_KEY_PREFIX = "MCP_SERVER_URL_";
4
+ const SETTING_KEY_PREFIX = "MCP_LAST_SUCCESSFUL_SERVER_URL_";
5
+ const GLOBAL_SETTING_KEY = "MCP_LAST_SUCCESSFUL_SERVER_URL_GLOBAL_V1";
6
+
7
+ let pluginRef: Plugin | undefined;
8
+
9
+ function init(p: Plugin): void {
10
+ pluginRef = p;
11
+ }
12
+
13
+ function normalizeServerUrl(serverUrl: string | undefined): string {
14
+ let normalized = (serverUrl ?? "").gsub("^%s+", "")[0].gsub("%s+$", "")[0];
15
+ if (normalized === "") return "";
16
+
17
+ if (normalized.match("^%a[%w+.-]*://")[0] === undefined) {
18
+ normalized = `http://${normalized}`;
19
+ }
20
+
21
+ while (
22
+ normalized.size() > 0 &&
23
+ normalized.sub(-1) === "/" &&
24
+ normalized.match("^%a[%w+.-]*://$")[0] === undefined
25
+ ) {
26
+ normalized = normalized.sub(1, -2);
27
+ }
28
+
29
+ return normalized;
30
+ }
31
+
32
+ function extractPort(serverUrl: string): number | undefined {
33
+ const [portStr] = serverUrl.match(":(%d+)$");
34
+ if (portStr === undefined) return undefined;
35
+ return tonumber(portStr);
36
+ }
37
+
38
+ function addUnique(values: string[], value: string): void {
39
+ if (!values.includes(value)) {
40
+ values.push(value);
41
+ }
42
+ }
43
+
44
+ function computeInstanceIds(): string[] {
45
+ const ids: string[] = [];
46
+ if (game.PlaceId !== 0) {
47
+ addUnique(ids, `place:${tostring(game.PlaceId)}`);
48
+ }
49
+ const existing = ServerStorage.GetAttribute("__MCPPlaceId");
50
+ if (typeIs(existing, "string") && existing !== "") {
51
+ addUnique(ids, `anon:${existing as string}`);
52
+ } else if (game.PlaceId === 0) {
53
+ const fresh = HttpService.GenerateGUID(false);
54
+ pcall(() => ServerStorage.SetAttribute("__MCPPlaceId", fresh));
55
+ addUnique(ids, `anon:${fresh}`);
56
+ }
57
+ return ids;
58
+ }
59
+
60
+ function settingKey(instanceId: string): string {
61
+ return SETTING_KEY_PREFIX + instanceId;
62
+ }
63
+
64
+ function legacySettingKey(instanceId: string): string {
65
+ return LEGACY_SETTING_KEY_PREFIX + instanceId;
66
+ }
67
+
68
+ function readSettingString(key: string): string | undefined {
69
+ if (!pluginRef) return undefined;
70
+ const [ok, value] = pcall(() => pluginRef!.GetSetting(key));
71
+ if (!ok || !typeIs(value, "string")) return undefined;
72
+
73
+ const normalized = normalizeServerUrl(value as string);
74
+ return normalized !== "" ? normalized : undefined;
75
+ }
76
+
77
+ function writeSettingString(key: string, serverUrl: string): void {
78
+ if (!pluginRef) return;
79
+ pcall(() => pluginRef!.SetSetting(key, serverUrl));
80
+ }
81
+
82
+ function rememberServerUrl(serverUrl: string): void {
83
+ const normalized = normalizeServerUrl(serverUrl);
84
+ if (!pluginRef || normalized === "") return;
85
+ writeSettingString(GLOBAL_SETTING_KEY, normalized);
86
+ for (const instanceId of computeInstanceIds()) {
87
+ writeSettingString(settingKey(instanceId), normalized);
88
+ writeSettingString(legacySettingKey(instanceId), normalized);
89
+ }
90
+ }
91
+
92
+ function readServerUrl(): string | undefined {
93
+ if (!pluginRef) return undefined;
94
+ for (const instanceId of computeInstanceIds()) {
95
+ const remembered = readSettingString(settingKey(instanceId));
96
+ if (remembered !== undefined) return remembered;
97
+ }
98
+ const globalRemembered = readSettingString(GLOBAL_SETTING_KEY);
99
+ if (globalRemembered !== undefined) return globalRemembered;
100
+ for (const instanceId of computeInstanceIds()) {
101
+ const legacyRemembered = readSettingString(legacySettingKey(instanceId));
102
+ if (legacyRemembered !== undefined) return legacyRemembered;
103
+ }
104
+
105
+ return undefined;
106
+ }
107
+
108
+ export = {
109
+ init,
110
+ normalizeServerUrl,
111
+ extractPort,
112
+ rememberServerUrl,
113
+ readServerUrl,
114
+ };
@@ -0,0 +1,101 @@
1
+ import { Connection } from "../types";
2
+
3
+ const CURRENT_VERSION = "__VERSION__";
4
+ const PLUGIN_VARIANT = "__PLUGIN_VARIANT__";
5
+ const PROTOCOL_VERSION = 1;
6
+ const MAX_CONNECTIONS = 5;
7
+ const BASE_PORT = 58741;
8
+ let activeTabIndex = 0;
9
+
10
+ function createConnection(port: number): Connection {
11
+ return {
12
+ port,
13
+ serverUrl: `http://localhost:${port}`,
14
+ isActive: false,
15
+ pollInterval: 0.5,
16
+ lastPoll: 0,
17
+ consecutiveFailures: 0,
18
+ maxFailuresBeforeError: 50,
19
+ lastSuccessfulConnection: 0,
20
+ currentRetryDelay: 0.5,
21
+ maxRetryDelay: 5,
22
+ retryBackoffMultiplier: 1.2,
23
+ lastHttpOk: false,
24
+ lastMcpOk: false,
25
+ mcpWaitStartTime: undefined,
26
+ isPolling: false,
27
+ streamOpen: false,
28
+ lastStreamHeartbeat: 0,
29
+ nextStreamRetryAt: 0,
30
+ heartbeatConnection: undefined,
31
+ };
32
+ }
33
+
34
+ const connections: Connection[] = [createConnection(BASE_PORT)];
35
+
36
+ function addConnection(port?: number): number | undefined {
37
+ if (connections.size() >= MAX_CONNECTIONS) {
38
+ return undefined;
39
+ }
40
+ if (port === undefined) {
41
+ let maxPort = BASE_PORT - 1;
42
+ for (const conn of connections) {
43
+ if (conn.port > maxPort) maxPort = conn.port;
44
+ }
45
+ port = maxPort + 1;
46
+ }
47
+ const conn = createConnection(port);
48
+ connections.push(conn);
49
+ return connections.size() - 1;
50
+ }
51
+
52
+ function removeConnection(index: number): boolean {
53
+ if (connections.size() <= 1) return false;
54
+ if (index < 0 || index >= connections.size()) return false;
55
+ if (connections[index].isActive) return false;
56
+
57
+ connections.remove(index);
58
+
59
+ if (activeTabIndex >= connections.size()) {
60
+ activeTabIndex = connections.size() - 1;
61
+ } else if (activeTabIndex > index) {
62
+ activeTabIndex -= 1;
63
+ }
64
+ return true;
65
+ }
66
+
67
+ function getActiveConnection(): Connection {
68
+ return connections[activeTabIndex];
69
+ }
70
+
71
+ function getConnection(index: number): Connection | undefined {
72
+ return connections[index];
73
+ }
74
+
75
+ function getActiveTabIndex(): number {
76
+ return activeTabIndex;
77
+ }
78
+
79
+ function setActiveTabIndex(index: number): void {
80
+ activeTabIndex = index;
81
+ }
82
+
83
+ function getConnections(): Connection[] {
84
+ return connections;
85
+ }
86
+
87
+ export = {
88
+ CURRENT_VERSION,
89
+ PLUGIN_VARIANT,
90
+ PROTOCOL_VERSION,
91
+ MAX_CONNECTIONS,
92
+ BASE_PORT,
93
+ connections,
94
+ addConnection,
95
+ removeConnection,
96
+ getActiveConnection,
97
+ getConnection,
98
+ getActiveTabIndex,
99
+ setActiveTabIndex,
100
+ getConnections,
101
+ };
@@ -0,0 +1,276 @@
1
+ // Cross-DM stop_playtest signaling via plugin:SetSetting, scoped by
2
+ // per-instance setting key so the same Studio process can host playtests
3
+ // for multiple places without one place's stop_playtest yanking another's.
4
+ // During publish-after-connect, both "anon:<uuid>" and "place:<PlaceId>"
5
+ // can refer to the same Studio place, so stop requests are mirrored across
6
+ // both keys while the monitor waits for a matching result on either key.
7
+ //
8
+ // `plugin:SetSetting` / `plugin:GetSetting` is a per-plugin persistent store
9
+ // shared across every DataModel the plugin runs in (edit DMs, play-server
10
+ // DMs, play-client DMs). For each connected place we use a dedicated key
11
+ // "MCP_STOP_PLAY_<instanceId>" as a tiny request/result mailbox:
12
+ //
13
+ // * The edit DM's handler writes a tokenized stop request into its own key
14
+ // (computed from its placeId / ServerStorage anon UUID).
15
+ // * Each play-server DM's monitor loop polls the key matching its own
16
+ // instanceId at 1Hz. On a fresh token, it calls StudioTestService:EndTest
17
+ // and writes a matching result token. Play-server DMs for other places
18
+ // never touch this key.
19
+ // * The edit DM waits up to ~8s for its result token, confirming a matching
20
+ // play-server actually consumed the request.
21
+ //
22
+ // Earlier versions used a single shared boolean flag, which let any
23
+ // play-server DM in the same Studio process consume any place's stop
24
+ // request — silently yanking teammates' playtests. The per-key scoping
25
+ // below is the fix.
26
+
27
+ import { HttpService, RunService, ServerStorage } from "@rbxts/services";
28
+
29
+ const StudioTestService = game.GetService("StudioTestService");
30
+
31
+ const SETTING_KEY_PREFIX = "MCP_STOP_PLAY_";
32
+ // Keep this conservative. plugin:GetSetting is backed by Studio's plugin
33
+ // settings store, and this monitor runs during every play session, including
34
+ // manually-started Play. The official reference implementation polls at 1s.
35
+ const POLL_INTERVAL_SEC = 1;
36
+ // Total time we wait for the matching play-server DM to consume the
37
+ // signal. Must cover: monitor detection (<= POLL_INTERVAL_SEC) +
38
+ // StudioTestService:EndTest teardown (several seconds on heavier places).
39
+ // 8s is intentionally shorter than the MCP request timeout but long enough
40
+ // for the 1s monitor cadence plus ordinary Studio teardown latency.
41
+ const WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 8.0;
42
+ const WAIT_POLL_SEC = 0.1;
43
+ const REQUEST_TTL_SEC = 12.0;
44
+
45
+ let pluginRef: Plugin | undefined;
46
+ let endTestIssued = false;
47
+
48
+ interface StopPayload {
49
+ kind?: string;
50
+ id?: string;
51
+ requestedAt?: number;
52
+ consumedAt?: number;
53
+ ok?: boolean;
54
+ error?: string;
55
+ alreadyEnded?: boolean;
56
+ }
57
+
58
+ interface StopRequestResult {
59
+ ok: boolean;
60
+ requestId?: string;
61
+ }
62
+
63
+ interface StopConsumptionResult {
64
+ ok: boolean;
65
+ consumed: boolean;
66
+ error?: string;
67
+ alreadyEnded?: boolean;
68
+ }
69
+
70
+ function init(p: Plugin): void {
71
+ pluginRef = p;
72
+ }
73
+
74
+ // Mirror of Communication.computeInstanceId(). Duplicated here because
75
+ // StopPlayMonitor runs in both edit and play-server DMs, and both must
76
+ // agree on the place identifier (published places: placeId; unpublished:
77
+ // UUID on ServerStorage's __MCPPlaceId attribute, travels with the .rbxl
78
+ // into the play DM).
79
+ function addUnique(values: string[], value: string): void {
80
+ if (!values.includes(value)) {
81
+ values.push(value);
82
+ }
83
+ }
84
+
85
+ function computeInstanceIds(): string[] {
86
+ const ids: string[] = [];
87
+ if (game.PlaceId !== 0) {
88
+ addUnique(ids, `place:${tostring(game.PlaceId)}`);
89
+ }
90
+ const existing = ServerStorage.GetAttribute("__MCPPlaceId");
91
+ if (typeIs(existing, "string") && existing !== "") {
92
+ addUnique(ids, `anon:${existing as string}`);
93
+ } else if (game.PlaceId === 0) {
94
+ const fresh = HttpService.GenerateGUID(false);
95
+ pcall(() => ServerStorage.SetAttribute("__MCPPlaceId", fresh));
96
+ addUnique(ids, `anon:${fresh}`);
97
+ }
98
+ return ids;
99
+ }
100
+
101
+ function settingKey(instanceId: string): string {
102
+ return SETTING_KEY_PREFIX + instanceId;
103
+ }
104
+
105
+ function settingKeys(): string[] {
106
+ return computeInstanceIds().map((instanceId) => settingKey(instanceId));
107
+ }
108
+
109
+ function readSetting(key: string): unknown {
110
+ if (!pluginRef) return undefined;
111
+ const [ok, value] = pcall(() => pluginRef!.GetSetting(key));
112
+ return ok ? value : undefined;
113
+ }
114
+
115
+ function writeSetting(key: string, value: unknown): boolean {
116
+ if (!pluginRef) return false;
117
+ const [ok] = pcall(() => pluginRef!.SetSetting(key, value));
118
+ return ok;
119
+ }
120
+
121
+ function decodePayload(value: unknown): StopPayload | undefined {
122
+ let decoded = value;
123
+ if (typeIs(value, "string")) {
124
+ const [ok, result] = pcall(() => HttpService.JSONDecode(value as string));
125
+ if (!ok) return undefined;
126
+ decoded = result;
127
+ }
128
+ if (!typeIs(decoded, "table")) return undefined;
129
+ const payload = decoded as StopPayload;
130
+ if (!typeIs(payload.kind, "string") || !typeIs(payload.id, "string")) {
131
+ return undefined;
132
+ }
133
+ return payload;
134
+ }
135
+
136
+ function writePayload(key: string, payload: StopPayload): boolean {
137
+ const [encodedOk, encoded] = pcall(() => HttpService.JSONEncode(payload));
138
+ if (!encodedOk || !typeIs(encoded, "string")) return false;
139
+ return writeSetting(key, encoded);
140
+ }
141
+
142
+ function writeResult(key: string, request: StopPayload, ok: boolean, errText?: string, alreadyEnded?: boolean): void {
143
+ writePayload(key, {
144
+ kind: "result",
145
+ id: request.id,
146
+ requestedAt: request.requestedAt,
147
+ consumedAt: tick(),
148
+ ok,
149
+ error: errText,
150
+ alreadyEnded,
151
+ });
152
+ }
153
+
154
+ function handleStopRequest(key: string, request: StopPayload): void {
155
+ if (request.kind !== "request" || !typeIs(request.id, "string")) return;
156
+ if (!typeIs(request.requestedAt, "number")) {
157
+ writeSetting(key, false);
158
+ return;
159
+ }
160
+
161
+ const age = tick() - request.requestedAt;
162
+ if (age < -5 || age > REQUEST_TTL_SEC) {
163
+ writeSetting(key, false);
164
+ return;
165
+ }
166
+
167
+ if (endTestIssued) {
168
+ writeResult(
169
+ key,
170
+ request,
171
+ true,
172
+ undefined,
173
+ true,
174
+ );
175
+ return;
176
+ }
177
+
178
+ if (!RunService.IsRunning() || !RunService.IsServer()) {
179
+ writeResult(key, request, false, "StopPlayMonitor is not running in the server DataModel.");
180
+ return;
181
+ }
182
+
183
+ endTestIssued = true;
184
+ const [endOk, endErr] = pcall(() => StudioTestService.EndTest("stopped_by_mcp"));
185
+ if (!endOk && (tostring(endErr).find("EndTest can only be called once")[0] !== undefined || tostring(endErr).find("can only be called once")[0] !== undefined)) {
186
+ writeResult(key, request, true, undefined, true);
187
+ return;
188
+ }
189
+ writeResult(key, request, endOk, endOk ? undefined : tostring(endErr));
190
+ if (!endOk) {
191
+ endTestIssued = false;
192
+ }
193
+ }
194
+
195
+ function startMonitor(): void {
196
+ if (!pluginRef) {
197
+ warn("[BloxForge] StopPlayMonitor.startMonitor called before init; skipping");
198
+ return;
199
+ }
200
+ task.spawn(() => {
201
+ while (true) {
202
+ for (const myKey of settingKeys()) {
203
+ const value = readSetting(myKey);
204
+ if (value === true) {
205
+ // Legacy boolean requests are ambiguous and may be stale from
206
+ // a prior crashed session. New stop requests use token payloads.
207
+ writeSetting(myKey, false);
208
+ } else {
209
+ const payload = decodePayload(value);
210
+ if (payload) {
211
+ handleStopRequest(myKey, payload);
212
+ }
213
+ }
214
+ }
215
+ task.wait(POLL_INTERVAL_SEC);
216
+ }
217
+ });
218
+ }
219
+
220
+ function requestStop(): StopRequestResult {
221
+ if (!pluginRef) return { ok: false };
222
+ const requestId = HttpService.GenerateGUID(false);
223
+ const payload: StopPayload = {
224
+ kind: "request",
225
+ id: requestId,
226
+ requestedAt: tick(),
227
+ };
228
+ let ok = false;
229
+ for (const myKey of settingKeys()) {
230
+ ok = writePayload(myKey, payload) || ok;
231
+ }
232
+ return { ok, requestId: ok ? requestId : undefined };
233
+ }
234
+
235
+ function waitForConsumption(requestId: string): StopConsumptionResult {
236
+ if (!pluginRef) return { ok: false, consumed: false, error: "Plugin reference is not initialized." };
237
+ const start = tick();
238
+ while (tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC) {
239
+ for (const myKey of settingKeys()) {
240
+ const payload = decodePayload(readSetting(myKey));
241
+ if (payload && payload.kind === "result" && payload.id === requestId) {
242
+ return {
243
+ ok: payload.ok === true,
244
+ consumed: true,
245
+ error: payload.error,
246
+ alreadyEnded: payload.alreadyEnded,
247
+ };
248
+ }
249
+ }
250
+ task.wait(WAIT_POLL_SEC);
251
+ }
252
+ return {
253
+ ok: false,
254
+ consumed: false,
255
+ error: "Timed out waiting for the play-server DataModel to acknowledge stop_playtest.",
256
+ };
257
+ }
258
+
259
+ function clearPending(requestId?: string): void {
260
+ if (!pluginRef) return;
261
+ for (const myKey of settingKeys()) {
262
+ if (requestId !== undefined) {
263
+ const payload = decodePayload(readSetting(myKey));
264
+ if (payload && payload.id !== requestId) continue;
265
+ }
266
+ writeSetting(myKey, false);
267
+ }
268
+ }
269
+
270
+ export = {
271
+ init,
272
+ startMonitor,
273
+ requestStop,
274
+ waitForConsumption,
275
+ clearPending,
276
+ };