@princeofscale/bloxforge-inspector 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 +19281 -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,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
+ });