@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,535 @@
|
|
|
1
|
+
import { HttpService, LogService, Players, RunService } from "@rbxts/services";
|
|
2
|
+
import StopPlayMonitor from "../StopPlayMonitor";
|
|
3
|
+
|
|
4
|
+
interface StudioTestServiceMultiplayer extends StudioTestService {
|
|
5
|
+
ExecuteMultiplayerTestAsync(numPlayers: number, testArgs: unknown): unknown;
|
|
6
|
+
AddPlayers(numPlayers: number): void;
|
|
7
|
+
CanLeaveTest(): boolean;
|
|
8
|
+
LeaveTest(): void;
|
|
9
|
+
EditModeActive: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const StudioTestService = game.GetService("StudioTestService") as StudioTestServiceMultiplayer;
|
|
13
|
+
const ServerScriptService = game.GetService("ServerScriptService");
|
|
14
|
+
const ScriptEditorService = game.GetService("ScriptEditorService");
|
|
15
|
+
|
|
16
|
+
// NAV_SIGNAL flows from the edit DM to the play-server DM via the injected
|
|
17
|
+
// __MCP_CommandListener Script + LogService.MessageOut. Stop signaling moved
|
|
18
|
+
// off this path entirely (see StopPlayMonitor) because cross-DM MessageOut
|
|
19
|
+
// reflection from edit -> play-server does not work in practice.
|
|
20
|
+
const NAV_SIGNAL = "__MCP_NAV__";
|
|
21
|
+
const NAV_RESULT = "__MCP_NAV_RESULT__";
|
|
22
|
+
|
|
23
|
+
let testRunning = false;
|
|
24
|
+
let navLogConnection: RBXScriptConnection | undefined;
|
|
25
|
+
let stopListenerScript: Script | undefined;
|
|
26
|
+
let navResultCallback: ((json: string) => void) | undefined;
|
|
27
|
+
|
|
28
|
+
type MultiplayerPhase = "idle" | "starting" | "running" | "completed" | "failed";
|
|
29
|
+
|
|
30
|
+
interface MultiplayerSessionState {
|
|
31
|
+
phase: MultiplayerPhase;
|
|
32
|
+
testId?: string;
|
|
33
|
+
numPlayers?: number;
|
|
34
|
+
testArgs?: unknown;
|
|
35
|
+
startedAt?: number;
|
|
36
|
+
completedAt?: number;
|
|
37
|
+
ok?: boolean;
|
|
38
|
+
result?: unknown;
|
|
39
|
+
error?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let multiplayerState: MultiplayerSessionState = { phase: "idle" };
|
|
43
|
+
|
|
44
|
+
function detectPeerRole(): string {
|
|
45
|
+
if (!RunService.IsRunning()) return "edit";
|
|
46
|
+
if (RunService.IsServer()) return "server";
|
|
47
|
+
return "client";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getPlayersSnapshot() {
|
|
51
|
+
const players = Players.GetPlayers().map((player) => ({
|
|
52
|
+
name: player.Name,
|
|
53
|
+
userId: player.UserId,
|
|
54
|
+
displayName: player.DisplayName,
|
|
55
|
+
}));
|
|
56
|
+
players.sort((a, b) => a.name < b.name);
|
|
57
|
+
return players;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function cloneMultiplayerState(): MultiplayerSessionState {
|
|
61
|
+
return {
|
|
62
|
+
phase: multiplayerState.phase,
|
|
63
|
+
testId: multiplayerState.testId,
|
|
64
|
+
numPlayers: multiplayerState.numPlayers,
|
|
65
|
+
testArgs: multiplayerState.testArgs,
|
|
66
|
+
startedAt: multiplayerState.startedAt,
|
|
67
|
+
completedAt: multiplayerState.completedAt,
|
|
68
|
+
ok: multiplayerState.ok,
|
|
69
|
+
result: multiplayerState.result,
|
|
70
|
+
error: multiplayerState.error,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeNumPlayers(value: unknown): number | undefined {
|
|
75
|
+
if (!typeIs(value, "number")) return undefined;
|
|
76
|
+
const n = math.floor(value);
|
|
77
|
+
if (n !== value || n < 1 || n > 8) return undefined;
|
|
78
|
+
return n;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function buildCommandListenerSource(): string {
|
|
82
|
+
return `local LogService = game:GetService("LogService")
|
|
83
|
+
local PathfindingService = game:GetService("PathfindingService")
|
|
84
|
+
local Players = game:GetService("Players")
|
|
85
|
+
local HttpService = game:GetService("HttpService")
|
|
86
|
+
local NAV_SIG = "${NAV_SIGNAL}"
|
|
87
|
+
local NAV_RES = "${NAV_RESULT}"
|
|
88
|
+
LogService.MessageOut:Connect(function(msg)
|
|
89
|
+
if string.sub(msg, 1, #NAV_SIG + 1) == NAV_SIG .. ":" then
|
|
90
|
+
local json = string.sub(msg, #NAV_SIG + 2)
|
|
91
|
+
task.spawn(function()
|
|
92
|
+
local ok, d = pcall(function() return HttpService:JSONDecode(json) end)
|
|
93
|
+
if not ok or not d then
|
|
94
|
+
print(NAV_RES .. ':{"success":false,"error":"parse_error"}')
|
|
95
|
+
return
|
|
96
|
+
end
|
|
97
|
+
local ps = Players:GetPlayers()
|
|
98
|
+
if #ps == 0 then
|
|
99
|
+
print(NAV_RES .. ':{"success":false,"error":"no_players"}')
|
|
100
|
+
return
|
|
101
|
+
end
|
|
102
|
+
local char = ps[1].Character or ps[1].CharacterAdded:Wait()
|
|
103
|
+
local hum = char:FindFirstChildOfClass("Humanoid")
|
|
104
|
+
local root = char:FindFirstChild("HumanoidRootPart")
|
|
105
|
+
if not hum or not root then
|
|
106
|
+
print(NAV_RES .. ':{"success":false,"error":"no_humanoid"}')
|
|
107
|
+
return
|
|
108
|
+
end
|
|
109
|
+
local target
|
|
110
|
+
if d.instancePath then
|
|
111
|
+
local parts = string.split(d.instancePath, ".")
|
|
112
|
+
local cur = game
|
|
113
|
+
for i = 2, #parts do
|
|
114
|
+
cur = cur:FindFirstChild(parts[i])
|
|
115
|
+
if not cur then
|
|
116
|
+
print(NAV_RES .. ':{"success":false,"error":"instance_not_found"}')
|
|
117
|
+
return
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
if cur:IsA("BasePart") then target = cur.Position
|
|
121
|
+
elseif cur:IsA("Model") and cur.PrimaryPart then target = cur.PrimaryPart.Position
|
|
122
|
+
else target = cur:GetPivot().Position end
|
|
123
|
+
else
|
|
124
|
+
target = Vector3.new(d.x or 0, d.y or 0, d.z or 0)
|
|
125
|
+
end
|
|
126
|
+
local path = PathfindingService:CreatePath({AgentRadius=2,AgentHeight=5,AgentCanJump=true})
|
|
127
|
+
local pok = pcall(function() path:ComputeAsync(root.Position, target) end)
|
|
128
|
+
local method = "direct"
|
|
129
|
+
if pok and path.Status == Enum.PathStatus.Success then
|
|
130
|
+
method = "pathfinding"
|
|
131
|
+
for _, wp in ipairs(path:GetWaypoints()) do
|
|
132
|
+
hum:MoveTo(wp.Position)
|
|
133
|
+
if wp.Action == Enum.PathWaypointAction.Jump then hum.Jump = true end
|
|
134
|
+
hum.MoveToFinished:Wait()
|
|
135
|
+
end
|
|
136
|
+
else
|
|
137
|
+
hum:MoveTo(target)
|
|
138
|
+
hum.MoveToFinished:Wait()
|
|
139
|
+
end
|
|
140
|
+
local fp = root.Position
|
|
141
|
+
print(NAV_RES .. ':{"success":true,"method":"' .. method .. '","position":[' .. fp.X .. ',' .. fp.Y .. ',' .. fp.Z .. ']}')
|
|
142
|
+
end)
|
|
143
|
+
end
|
|
144
|
+
end)`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function injectStopListener() {
|
|
148
|
+
const listener = new Instance("Script");
|
|
149
|
+
listener.Name = "__MCP_CommandListener";
|
|
150
|
+
listener.Parent = ServerScriptService;
|
|
151
|
+
|
|
152
|
+
const source = buildCommandListenerSource();
|
|
153
|
+
const [seOk] = pcall(() => {
|
|
154
|
+
ScriptEditorService.UpdateSourceAsync(listener, () => source);
|
|
155
|
+
});
|
|
156
|
+
if (!seOk) {
|
|
157
|
+
(listener as unknown as { Source: string }).Source = source;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
stopListenerScript = listener;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function cleanupStopListener() {
|
|
164
|
+
if (stopListenerScript) {
|
|
165
|
+
pcall(() => stopListenerScript!.Destroy());
|
|
166
|
+
stopListenerScript = undefined;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function disconnectNavLogListener() {
|
|
171
|
+
if (navLogConnection) {
|
|
172
|
+
navLogConnection.Disconnect();
|
|
173
|
+
navLogConnection = undefined;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function startPlaytest(requestData: Record<string, unknown>) {
|
|
178
|
+
const mode = requestData.mode as string | undefined;
|
|
179
|
+
const numPlayers = requestData.numPlayers as number | undefined;
|
|
180
|
+
|
|
181
|
+
if (mode !== "play" && mode !== "run") {
|
|
182
|
+
return { error: 'mode must be "play" or "run"' };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (numPlayers !== undefined) {
|
|
186
|
+
return { error: "start_playtest is single-player only. Use multiplayer_test_start for multi-client StudioTestService sessions." };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Self-heal: if testRunning is stuck true but Studio reports no active
|
|
190
|
+
// playtest, the previous start_playtest's task.spawn was orphaned
|
|
191
|
+
// (plugin reload mid-test, Studio entered some inconsistent state, etc).
|
|
192
|
+
// Reset it so subsequent starts don't hit a false "already running".
|
|
193
|
+
if (testRunning && !RunService.IsRunning()) {
|
|
194
|
+
testRunning = false;
|
|
195
|
+
disconnectNavLogListener();
|
|
196
|
+
cleanupStopListener();
|
|
197
|
+
// Runtime eval bridges are created by the play server/client plugin
|
|
198
|
+
// peers and disappear with the play DataModels.
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (testRunning) {
|
|
202
|
+
return { error: "A test is already running" };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
testRunning = true;
|
|
206
|
+
|
|
207
|
+
cleanupStopListener();
|
|
208
|
+
disconnectNavLogListener();
|
|
209
|
+
|
|
210
|
+
navLogConnection = LogService.MessageOut.Connect((message) => {
|
|
211
|
+
if (message.sub(1, NAV_RESULT.size() + 1) === `${NAV_RESULT}:`) {
|
|
212
|
+
if (navResultCallback) {
|
|
213
|
+
navResultCallback(message.sub(NAV_RESULT.size() + 2));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const [injected, injErr] = pcall(() => injectStopListener());
|
|
219
|
+
if (!injected) {
|
|
220
|
+
warn(`[BloxForge] Failed to inject stop listener: ${injErr}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
task.spawn(() => {
|
|
224
|
+
const [ok, result] = pcall(() => {
|
|
225
|
+
if (mode === "play") {
|
|
226
|
+
return StudioTestService.ExecutePlayModeAsync({});
|
|
227
|
+
}
|
|
228
|
+
return StudioTestService.ExecuteRunModeAsync({});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
if (!ok) {
|
|
232
|
+
warn(`[BloxForge] Playtest ended with error: ${result}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
disconnectNavLogListener();
|
|
236
|
+
testRunning = false;
|
|
237
|
+
|
|
238
|
+
cleanupStopListener();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const response: Record<string, unknown> = {
|
|
242
|
+
success: true,
|
|
243
|
+
message: `Playtest started in ${mode} mode.`,
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
return response;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function stopPlaytest(_requestData: Record<string, unknown>) {
|
|
250
|
+
// Signal the play-server DM's StopPlayMonitor via plugin:SetSetting.
|
|
251
|
+
// The monitor acknowledges with the matching request id only after its
|
|
252
|
+
// StudioTestService:EndTest call returns from pcall.
|
|
253
|
+
const stopRequest = StopPlayMonitor.requestStop();
|
|
254
|
+
if (!stopRequest.ok || stopRequest.requestId === undefined) {
|
|
255
|
+
return { error: "Plugin not ready. Try again in a moment." };
|
|
256
|
+
}
|
|
257
|
+
const consumption = StopPlayMonitor.waitForConsumption(stopRequest.requestId);
|
|
258
|
+
if (!consumption.ok) {
|
|
259
|
+
// Two distinct failure modes collapse here, distinguished by whether
|
|
260
|
+
// THIS edit DM has a playtest tracked:
|
|
261
|
+
//
|
|
262
|
+
// - testRunning=false: no playtest was running from this edit DM
|
|
263
|
+
// (true negative). Return "no active playtest" — fine to retry only
|
|
264
|
+
// after actually starting a playtest.
|
|
265
|
+
// - testRunning=true: a playtest IS running but the cross-DM signal
|
|
266
|
+
// didn't propagate within the consumption timeout (false negative
|
|
267
|
+
// from the caller's perspective — playtest may actually have ended).
|
|
268
|
+
// Tell the caller it's a timing issue and they can retry.
|
|
269
|
+
//
|
|
270
|
+
// Either way clean up the pending request so a future playtest's monitor
|
|
271
|
+
// doesn't fire EndTest on startup against a stale signal.
|
|
272
|
+
StopPlayMonitor.clearPending(stopRequest.requestId);
|
|
273
|
+
if (testRunning) {
|
|
274
|
+
return {
|
|
275
|
+
error:
|
|
276
|
+
"Playtest stop signal failed or was not acknowledged. " +
|
|
277
|
+
"The playtest may have ended anyway; check get_connected_instances.",
|
|
278
|
+
detail: consumption.error,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
if (consumption.consumed) {
|
|
282
|
+
return { error: "Playtest stop request reached the play server, but EndTest failed.", detail: consumption.error };
|
|
283
|
+
}
|
|
284
|
+
return { error: "No active playtest to stop.", detail: consumption.error };
|
|
285
|
+
}
|
|
286
|
+
StopPlayMonitor.clearPending(stopRequest.requestId);
|
|
287
|
+
// Request was consumed (EndTest called). ExecutePlayModeAsync in our
|
|
288
|
+
// startPlaytest task.spawn is still unwinding though — testRunning stays
|
|
289
|
+
// true until that yield completes and the post-block runs. Wait so
|
|
290
|
+
// back-to-back stop -> start sequences don't race against the prior
|
|
291
|
+
// teardown and get "A test is already running". 10s covers play-DM
|
|
292
|
+
// teardown on heavier places; if it still hasn't cleared we return
|
|
293
|
+
// anyway so users aren't stuck — but note that in the response so the
|
|
294
|
+
// caller knows a subsequent start may need a moment.
|
|
295
|
+
const deadline = tick() + 10;
|
|
296
|
+
while (testRunning && tick() < deadline) {
|
|
297
|
+
task.wait(0.1);
|
|
298
|
+
}
|
|
299
|
+
if (testRunning) {
|
|
300
|
+
return {
|
|
301
|
+
success: true,
|
|
302
|
+
alreadyEnded: consumption.alreadyEnded,
|
|
303
|
+
message: "Playtest stop signal sent; teardown still in progress.",
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
return { success: true, alreadyEnded: consumption.alreadyEnded, message: "Playtest stopped." };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function multiplayerTestStart(requestData: Record<string, unknown>) {
|
|
310
|
+
if (RunService.IsRunning()) {
|
|
311
|
+
return { error: "multiplayer_test_start must be called on the edit DataModel. Route with target=edit." };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const numPlayers = normalizeNumPlayers(requestData.numPlayers);
|
|
315
|
+
if (numPlayers === undefined) {
|
|
316
|
+
return { error: "numPlayers must be an integer from 1 to 8" };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (multiplayerState.phase === "starting" || multiplayerState.phase === "running") {
|
|
320
|
+
return {
|
|
321
|
+
error: "A multiplayer Studio test is already running",
|
|
322
|
+
state: cloneMultiplayerState(),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const testArgs = requestData.testArgs !== undefined ? requestData.testArgs : {};
|
|
327
|
+
const testId = HttpService.GenerateGUID(false);
|
|
328
|
+
|
|
329
|
+
multiplayerState = {
|
|
330
|
+
phase: "starting",
|
|
331
|
+
testId,
|
|
332
|
+
numPlayers,
|
|
333
|
+
testArgs,
|
|
334
|
+
startedAt: tick(),
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
task.spawn(() => {
|
|
338
|
+
multiplayerState.phase = "running";
|
|
339
|
+
const [ok, result] = pcall(() => {
|
|
340
|
+
return StudioTestService.ExecuteMultiplayerTestAsync(numPlayers, testArgs);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
multiplayerState.completedAt = tick();
|
|
344
|
+
multiplayerState.ok = ok;
|
|
345
|
+
if (ok) {
|
|
346
|
+
multiplayerState.phase = "completed";
|
|
347
|
+
multiplayerState.result = result;
|
|
348
|
+
multiplayerState.error = undefined;
|
|
349
|
+
} else {
|
|
350
|
+
multiplayerState.phase = "failed";
|
|
351
|
+
multiplayerState.result = undefined;
|
|
352
|
+
multiplayerState.error = tostring(result);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const response: Record<string, unknown> = {
|
|
357
|
+
success: true,
|
|
358
|
+
message: `Multiplayer Studio test starting with ${numPlayers} player(s).`,
|
|
359
|
+
testId,
|
|
360
|
+
phase: multiplayerState.phase,
|
|
361
|
+
numPlayers,
|
|
362
|
+
testArgs,
|
|
363
|
+
};
|
|
364
|
+
return response;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function multiplayerTestState(_requestData: Record<string, unknown>) {
|
|
368
|
+
const peer = detectPeerRole();
|
|
369
|
+
const response: Record<string, unknown> = {
|
|
370
|
+
success: true,
|
|
371
|
+
peer,
|
|
372
|
+
isRunning: RunService.IsRunning(),
|
|
373
|
+
isRunMode: RunService.IsRunMode(),
|
|
374
|
+
editModeActive: StudioTestService.EditModeActive,
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
if (peer === "edit") {
|
|
378
|
+
response.session = cloneMultiplayerState();
|
|
379
|
+
return response;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const [argsOk, args] = pcall(() => StudioTestService.GetTestArgs());
|
|
383
|
+
response.testArgsOk = argsOk;
|
|
384
|
+
response.testArgs = argsOk ? args : undefined;
|
|
385
|
+
if (!argsOk) response.testArgsError = tostring(args);
|
|
386
|
+
|
|
387
|
+
const players = getPlayersSnapshot();
|
|
388
|
+
response.players = players;
|
|
389
|
+
response.playerCount = players.size();
|
|
390
|
+
|
|
391
|
+
if (peer === "client") {
|
|
392
|
+
response.localPlayer = Players.LocalPlayer ? Players.LocalPlayer.Name : undefined;
|
|
393
|
+
const [canLeaveOk, canLeave] = pcall(() => StudioTestService.CanLeaveTest());
|
|
394
|
+
response.canLeaveOk = canLeaveOk;
|
|
395
|
+
response.canLeave = canLeaveOk ? canLeave : false;
|
|
396
|
+
if (!canLeaveOk) response.canLeaveError = tostring(canLeave);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return response;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function multiplayerTestAddPlayers(requestData: Record<string, unknown>) {
|
|
403
|
+
if (!RunService.IsRunning() || !RunService.IsServer()) {
|
|
404
|
+
return { error: "multiplayer_test_add_players must be called on the running server peer. Route with target=server." };
|
|
405
|
+
}
|
|
406
|
+
const numPlayers = normalizeNumPlayers(requestData.numPlayers);
|
|
407
|
+
if (numPlayers === undefined) {
|
|
408
|
+
return { error: "numPlayers must be an integer from 1 to 8" };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const before = Players.GetPlayers().size();
|
|
412
|
+
const [ok, result] = pcall(() => StudioTestService.AddPlayers(numPlayers));
|
|
413
|
+
if (!ok) {
|
|
414
|
+
return { error: tostring(result) };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const deadline = tick() + ((requestData.timeout as number | undefined) ?? 10);
|
|
418
|
+
while (Players.GetPlayers().size() < before + numPlayers && tick() < deadline) {
|
|
419
|
+
task.wait(0.1);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const players = getPlayersSnapshot();
|
|
423
|
+
return {
|
|
424
|
+
success: true,
|
|
425
|
+
message: `Requested ${numPlayers} additional player(s).`,
|
|
426
|
+
playerCount: players.size(),
|
|
427
|
+
players,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function multiplayerTestLeaveClient(_requestData: Record<string, unknown>) {
|
|
432
|
+
if (!RunService.IsRunning() || RunService.IsServer()) {
|
|
433
|
+
return { error: "multiplayer_test_leave_client must be called on a running client peer. Route with target=client-N." };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const [canLeaveOk, canLeave] = pcall(() => StudioTestService.CanLeaveTest());
|
|
437
|
+
if (!canLeaveOk) {
|
|
438
|
+
return { error: tostring(canLeave), canLeaveOk: false };
|
|
439
|
+
}
|
|
440
|
+
if (!canLeave) {
|
|
441
|
+
return { error: "This client cannot leave the current test session.", canLeaveOk: true, canLeave: false };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const localPlayer = Players.LocalPlayer ? Players.LocalPlayer.Name : undefined;
|
|
445
|
+
task.defer(() => {
|
|
446
|
+
pcall(() => StudioTestService.LeaveTest());
|
|
447
|
+
});
|
|
448
|
+
return {
|
|
449
|
+
success: true,
|
|
450
|
+
message: "Client leave requested.",
|
|
451
|
+
localPlayer,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function multiplayerTestEnd(requestData: Record<string, unknown>) {
|
|
456
|
+
if (!RunService.IsRunning() || !RunService.IsServer()) {
|
|
457
|
+
return { error: "multiplayer_test_end must be called on the running server peer. Route with target=server." };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const value = requestData.value !== undefined ? requestData.value : "ended_by_mcp";
|
|
461
|
+
const [ok, result] = pcall(() => StudioTestService.EndTest(value));
|
|
462
|
+
if (!ok) {
|
|
463
|
+
if (tostring(result).find("EndTest can only be called once")[0] !== undefined || tostring(result).find("can only be called once")[0] !== undefined) {
|
|
464
|
+
return {
|
|
465
|
+
success: true,
|
|
466
|
+
alreadyEnded: true,
|
|
467
|
+
message: "StudioTestService:EndTest was already called; teardown is in progress.",
|
|
468
|
+
value,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
return { error: tostring(result) };
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
success: true,
|
|
475
|
+
message: "Multiplayer Studio test end requested.",
|
|
476
|
+
value,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function characterNavigation(requestData: Record<string, unknown>) {
|
|
481
|
+
if (!testRunning) {
|
|
482
|
+
return { error: "Playtest must be running. Start a playtest in 'play' mode first." };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const position = requestData.position as number[] | undefined;
|
|
486
|
+
const instancePath = requestData.instancePath as string | undefined;
|
|
487
|
+
const waitForCompletion = (requestData.waitForCompletion as boolean) ?? true;
|
|
488
|
+
const timeout = (requestData.timeout as number) ?? 25;
|
|
489
|
+
|
|
490
|
+
if (!position && !instancePath) {
|
|
491
|
+
return { error: "Either position [x, y, z] or instancePath is required" };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
let navData: string;
|
|
495
|
+
if (position) {
|
|
496
|
+
navData = HttpService.JSONEncode({ x: position[0], y: position[1], z: position[2] });
|
|
497
|
+
} else {
|
|
498
|
+
navData = HttpService.JSONEncode({ instancePath });
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
warn(`${NAV_SIGNAL}:${navData}`);
|
|
502
|
+
|
|
503
|
+
if (!waitForCompletion) {
|
|
504
|
+
return { success: true, message: "Navigation command sent" };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
let result: string | undefined;
|
|
508
|
+
navResultCallback = (json: string) => {
|
|
509
|
+
result = json;
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const startTime = tick();
|
|
513
|
+
while (!result && tick() - startTime < timeout) {
|
|
514
|
+
task.wait(0.2);
|
|
515
|
+
}
|
|
516
|
+
navResultCallback = undefined;
|
|
517
|
+
|
|
518
|
+
if (result) {
|
|
519
|
+
const [ok, parsed] = pcall(() => HttpService.JSONDecode(result!));
|
|
520
|
+
if (ok) return parsed;
|
|
521
|
+
return { success: true, rawResult: result };
|
|
522
|
+
}
|
|
523
|
+
return { error: `Navigation timed out after ${timeout} seconds` };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export = {
|
|
527
|
+
startPlaytest,
|
|
528
|
+
stopPlaytest,
|
|
529
|
+
multiplayerTestStart,
|
|
530
|
+
multiplayerTestState,
|
|
531
|
+
multiplayerTestAddPlayers,
|
|
532
|
+
multiplayerTestLeaveClient,
|
|
533
|
+
multiplayerTestEnd,
|
|
534
|
+
characterNavigation,
|
|
535
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import State from "../modules/State";
|
|
2
|
+
import UI from "../modules/UI";
|
|
3
|
+
import Communication from "../modules/Communication";
|
|
4
|
+
import ClientBroker from "../modules/ClientBroker";
|
|
5
|
+
import ServerUrlSettings from "../modules/ServerUrlSettings";
|
|
6
|
+
import { cleanupLegacyEditBridges, ensureRuntimeBridgeInstalled } from "../modules/EvalBridges";
|
|
7
|
+
import RuntimeLogBuffer from "../modules/RuntimeLogBuffer";
|
|
8
|
+
import StopPlayMonitor from "../modules/StopPlayMonitor";
|
|
9
|
+
import BreakpointHandlers from "../modules/handlers/BreakpointHandlers";
|
|
10
|
+
import * as RenderMonitor from "../modules/RenderMonitor";
|
|
11
|
+
|
|
12
|
+
// Track render-loop liveness so input/screenshot tools can report "window
|
|
13
|
+
// minimized / not rendering" instead of silently no-op'ing. No-op in the
|
|
14
|
+
// server DM (RenderStepped can't connect there).
|
|
15
|
+
RenderMonitor.start();
|
|
16
|
+
|
|
17
|
+
// Attach the per-peer LogService.MessageOut listener as early as possible so
|
|
18
|
+
// boot-time prints from the user's place scripts are captured. Powers the
|
|
19
|
+
// get_runtime_logs MCP tool. Idempotent; safe to call before UI.init().
|
|
20
|
+
RuntimeLogBuffer.install();
|
|
21
|
+
|
|
22
|
+
// Share the plugin reference with the stop-play signaling module so both the
|
|
23
|
+
// edit DM (write the flag) and the play-server DM (read+act on the flag) can
|
|
24
|
+
// access plugin:SetSetting/GetSetting.
|
|
25
|
+
StopPlayMonitor.init(plugin);
|
|
26
|
+
BreakpointHandlers.init(plugin);
|
|
27
|
+
ServerUrlSettings.init(plugin);
|
|
28
|
+
|
|
29
|
+
function applyRememberedServerUrl(): void {
|
|
30
|
+
const rememberedServerUrl = ServerUrlSettings.readServerUrl();
|
|
31
|
+
if (rememberedServerUrl === undefined) return;
|
|
32
|
+
|
|
33
|
+
const conn = State.getActiveConnection();
|
|
34
|
+
conn.serverUrl = rememberedServerUrl;
|
|
35
|
+
const port = ServerUrlSettings.extractPort(rememberedServerUrl);
|
|
36
|
+
if (port !== undefined) conn.port = port;
|
|
37
|
+
ClientBroker.setServerUrl(rememberedServerUrl);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
applyRememberedServerUrl();
|
|
41
|
+
|
|
42
|
+
UI.init(plugin);
|
|
43
|
+
const elements = UI.getElements();
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
const ICON_DISCONNECTED = "rbxassetid://__BUTTON_ICON_DISCONNECTED__";
|
|
47
|
+
const ICON_CONNECTING = "rbxassetid://__BUTTON_ICON_CONNECTING__";
|
|
48
|
+
const ICON_CONNECTED = "rbxassetid://__BUTTON_ICON_CONNECTED__";
|
|
49
|
+
const TOOLBAR_REGISTRATION_DELAY_SECONDS = 1;
|
|
50
|
+
|
|
51
|
+
let toolbarButtonRegistered = false;
|
|
52
|
+
|
|
53
|
+
function registerToolbarButton() {
|
|
54
|
+
if (toolbarButtonRegistered) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
toolbarButtonRegistered = true;
|
|
58
|
+
|
|
59
|
+
const toolbar = plugin.CreateToolbar("__TOOLBAR_NAME__");
|
|
60
|
+
const button = toolbar.CreateButton("__BUTTON_TITLE__", "__BUTTON_TOOLTIP__", ICON_DISCONNECTED);
|
|
61
|
+
UI.setToolbarButton(button, { disconnected: ICON_DISCONNECTED, connecting: ICON_CONNECTING, connected: ICON_CONNECTED });
|
|
62
|
+
|
|
63
|
+
button.Click.Connect(() => {
|
|
64
|
+
elements.screenGui.Enabled = !elements.screenGui.Enabled;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
elements.connectButton.Activated.Connect(() => {
|
|
70
|
+
const conn = State.getActiveConnection();
|
|
71
|
+
if (conn && conn.isActive) {
|
|
72
|
+
Communication.deactivatePlugin(State.getActiveTabIndex());
|
|
73
|
+
} else {
|
|
74
|
+
Communication.activatePlugin(State.getActiveTabIndex());
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
plugin.Unloading.Connect(() => {
|
|
80
|
+
Communication.deactivateAll();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
UI.updateUIState();
|
|
85
|
+
Communication.checkForUpdates();
|
|
86
|
+
task.delay(TOOLBAR_REGISTRATION_DELAY_SECONDS, registerToolbarButton);
|
|
87
|
+
|
|
88
|
+
// Auto-activate per peer. The boshyxd plugin only registers with MCP when the
|
|
89
|
+
// user clicks Connect in its UI, but that UI is invisible in play DMs - so
|
|
90
|
+
// play peers' plugin instances load without ever registering. Run after a
|
|
91
|
+
// short delay so the UI/State have a chance to initialize first.
|
|
92
|
+
task.delay(2, () => {
|
|
93
|
+
const role = ClientBroker.forkRole();
|
|
94
|
+
if (role === "edit") {
|
|
95
|
+
cleanupLegacyEditBridges();
|
|
96
|
+
} else {
|
|
97
|
+
const result = ensureRuntimeBridgeInstalled();
|
|
98
|
+
if (!result.installed) {
|
|
99
|
+
warn(`[BloxForge] Runtime eval bridge install failed: ${result.error}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (role === "edit" || role === "server") {
|
|
103
|
+
pcall(() => {
|
|
104
|
+
const idx = State.getActiveTabIndex();
|
|
105
|
+
const conn = State.getConnection(idx);
|
|
106
|
+
if (conn && !conn.isActive) {
|
|
107
|
+
if (role === "server") {
|
|
108
|
+
const inheritedServerUrl = ServerUrlSettings.readServerUrl() ?? ClientBroker.DEFAULT_MCP_URL;
|
|
109
|
+
conn.serverUrl = ServerUrlSettings.normalizeServerUrl(inheritedServerUrl);
|
|
110
|
+
elements.urlInput.Text = conn.serverUrl;
|
|
111
|
+
const port = ServerUrlSettings.extractPort(conn.serverUrl);
|
|
112
|
+
if (port !== undefined) conn.port = port;
|
|
113
|
+
ClientBroker.setServerUrl(conn.serverUrl);
|
|
114
|
+
}
|
|
115
|
+
// Defensive default: in invisible play-DM UIs, the input field
|
|
116
|
+
// may not be populated by the time we activate.
|
|
117
|
+
if (conn.serverUrl === undefined || conn.serverUrl === "") {
|
|
118
|
+
conn.serverUrl = ClientBroker.DEFAULT_MCP_URL;
|
|
119
|
+
elements.urlInput.Text = conn.serverUrl;
|
|
120
|
+
}
|
|
121
|
+
Communication.activatePlugin(idx);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (role === "server") {
|
|
126
|
+
ClientBroker.setupServerBroker();
|
|
127
|
+
// The play-server DM is the only one where StudioTestService:EndTest is
|
|
128
|
+
// legal, so the stop-play monitor lives here. It consumes tokenized
|
|
129
|
+
// stop requests from plugin settings and acknowledges EndTest results.
|
|
130
|
+
StopPlayMonitor.startMonitor();
|
|
131
|
+
} else if (role === "client") {
|
|
132
|
+
ClientBroker.setupClientBroker();
|
|
133
|
+
}
|
|
134
|
+
});
|