@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.
- package/assets/Baseplate.rbxl +0 -0
- package/dist/index.js +19391 -0
- package/package.json +62 -0
- package/studio-plugin/INSTALLATION.md +161 -0
- package/studio-plugin/MCPInspectorPlugin.rbxmx +171699 -0
- package/studio-plugin/MCPPlugin.rbxmx +171699 -0
- package/studio-plugin/default.project.json +19 -0
- package/studio-plugin/dev.project.json +23 -0
- package/studio-plugin/include/LibMP.lua +156378 -0
- package/studio-plugin/inspector-icon.png +0 -0
- package/studio-plugin/package-lock.json +692 -0
- package/studio-plugin/package.json +19 -0
- package/studio-plugin/plugin.json +10 -0
- package/studio-plugin/src/modules/ClientBroker.ts +447 -0
- package/studio-plugin/src/modules/Communication.ts +697 -0
- package/studio-plugin/src/modules/EvalBridges.ts +255 -0
- package/studio-plugin/src/modules/HttpDiagnostics.ts +50 -0
- package/studio-plugin/src/modules/JobRegistry.ts +77 -0
- package/studio-plugin/src/modules/LuauExec.ts +465 -0
- package/studio-plugin/src/modules/Recording.ts +28 -0
- package/studio-plugin/src/modules/RenderMonitor.ts +60 -0
- package/studio-plugin/src/modules/RuntimeLogBuffer.ts +152 -0
- package/studio-plugin/src/modules/ServerUrlSettings.ts +114 -0
- package/studio-plugin/src/modules/State.ts +101 -0
- package/studio-plugin/src/modules/StopPlayMonitor.ts +276 -0
- package/studio-plugin/src/modules/UI.ts +790 -0
- package/studio-plugin/src/modules/Utils.ts +318 -0
- package/studio-plugin/src/modules/handlers/AssetHandlers.ts +241 -0
- package/studio-plugin/src/modules/handlers/BreakpointHandlers.ts +460 -0
- package/studio-plugin/src/modules/handlers/BuildHandlers.ts +481 -0
- package/studio-plugin/src/modules/handlers/CaptureHandlers.ts +203 -0
- package/studio-plugin/src/modules/handlers/EvalRuntimeHandlers.ts +149 -0
- package/studio-plugin/src/modules/handlers/InputHandlers.ts +160 -0
- package/studio-plugin/src/modules/handlers/InstanceHandlers.ts +380 -0
- package/studio-plugin/src/modules/handlers/JobHandlers.ts +111 -0
- package/studio-plugin/src/modules/handlers/LogHandlers.ts +14 -0
- package/studio-plugin/src/modules/handlers/MemoryHandlers.ts +44 -0
- package/studio-plugin/src/modules/handlers/MetadataHandlers.ts +354 -0
- package/studio-plugin/src/modules/handlers/MicroProfilerHandlers.ts +1263 -0
- package/studio-plugin/src/modules/handlers/PropertyHandlers.ts +191 -0
- package/studio-plugin/src/modules/handlers/QueryHandlers.ts +809 -0
- package/studio-plugin/src/modules/handlers/SceneAnalysisHandlers.ts +216 -0
- package/studio-plugin/src/modules/handlers/ScriptHandlers.ts +530 -0
- package/studio-plugin/src/modules/handlers/ScriptProfilerHandlers.ts +386 -0
- package/studio-plugin/src/modules/handlers/SerializationHandlers.ts +172 -0
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +535 -0
- package/studio-plugin/src/server/index.server.ts +134 -0
- package/studio-plugin/src/types/index.d.ts +83 -0
- 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
|
+
};
|