@volter/editor-game 0.5.66 → 0.5.68

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 (239) hide show
  1. package/contributions/audio-unlock.service.ts +2 -2
  2. package/contributions/autoplay.service.ts +1 -1
  3. package/contributions/bridge.command.ts +3 -3
  4. package/contributions/build-player.document.tsx +95 -0
  5. package/contributions/canvas/component-board.service.ts +16 -0
  6. package/contributions/canvas/design-time-mount.service.ts +45 -0
  7. package/contributions/canvas-story-capture.service.ts +12 -0
  8. package/contributions/edit-mode-audio.service.ts +2 -2
  9. package/contributions/edit-mode-networking.service.ts +1 -1
  10. package/contributions/gameplay.command.ts +4 -4
  11. package/contributions/generation.service.ts +1 -1
  12. package/contributions/godot.style.ts +26 -6
  13. package/contributions/godot.view.ts +6 -0
  14. package/contributions/ingest.service.ts +2 -2
  15. package/contributions/instances.command.ts +1 -1
  16. package/contributions/navmesh.menu.ts +1 -1
  17. package/contributions/network-observer.service.ts +14 -0
  18. package/contributions/play.command.ts +10 -4
  19. package/contributions/react/component-board.service.ts +1 -1
  20. package/contributions/react/design-time-mount.service.ts +1 -1
  21. package/contributions/scene-document.service.ts +3 -3
  22. package/contributions/state-watch.menu.ts +1 -1
  23. package/contributions/state-watch.utility.tsx +1 -1
  24. package/contributions/team-playtest.service.ts +4 -4
  25. package/contributions/three/component-board.service.ts +1 -1
  26. package/contributions/three/component-verbs.command.ts +6 -6
  27. package/contributions/three/three-authoring.service.ts +8 -5
  28. package/contributions/three-story-capture.service.ts +12 -0
  29. package/contributions/unity.style.ts +17 -7
  30. package/contributions/unity.view.ts +6 -0
  31. package/contributions/unreal.style.ts +10 -2
  32. package/contributions/unreal.view.ts +5 -0
  33. package/package.json +21 -10
  34. package/src/asset-budget/AssetBudgetPanel.tsx +1 -1
  35. package/src/asset-budget/asset-budget-model.ts +1 -1
  36. package/src/audio/AudioDebuggerPanel.tsx +1 -1
  37. package/src/bridge/dispatch.ts +14 -14
  38. package/src/bridge/live-frames.ts +1 -1
  39. package/src/bridge/screenshot.ts +3 -3
  40. package/src/build/BuildProfilesPanel.tsx +14 -3
  41. package/src/build/build-session.ts +35 -0
  42. package/src/canvas/canvas-board/CanvasBoardDocument.tsx +749 -0
  43. package/src/canvas/canvas-board/canvas-board-model.ts +407 -0
  44. package/src/canvas/canvas-board/canvas-component-board.ts +56 -0
  45. package/src/canvas/canvas-design-mount.ts +524 -0
  46. package/src/canvas/design-time-canvas-mount.ts +79 -0
  47. package/src/coverage/live-authoring-surface.ts +5 -5
  48. package/src/coverage/live-project-verbs.ts +1 -1
  49. package/src/coverage/native-system-coverage.ts +5 -5
  50. package/src/coverage/root-coverage.ts +1 -1
  51. package/src/coverage/session-coverage.ts +3 -3
  52. package/src/design-system-stories/ApplicationChrome.stories.tsx +5 -5
  53. package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +7 -7
  54. package/src/edit-mode/edit-mode-audio.ts +4 -4
  55. package/src/edit-mode/edit-mode-networking.ts +2 -2
  56. package/src/game-document/GameCaptureFrameButton.tsx +1 -1
  57. package/src/game-document/GameDocument.tsx +2 -2
  58. package/src/game-document/GamePanel.tsx +4 -4
  59. package/src/game-document/InstanceInspectorPicker.tsx +2 -2
  60. package/src/game-document/crowd-debug.ts +1 -1
  61. package/src/game-document/device-preview.ts +10 -11
  62. package/src/game-document/physics-debug.ts +1 -1
  63. package/src/generation/GenerationActivity.tsx +3 -3
  64. package/src/generation/generation-documents.tsx +5 -5
  65. package/src/generation/generation-jobs.ts +1 -1
  66. package/src/host/adapter-runtime-bindings.ts +96 -5
  67. package/src/host/authoring/babylon-authoring-adapter.ts +6 -6
  68. package/src/host/authoring/gesture-persist.ts +1 -1
  69. package/src/host/authoring/ingest-data-writer.ts +1 -1
  70. package/src/host/authoring/ingest-source-persistence.ts +4 -4
  71. package/src/host/authoring/mounted-authoring.ts +4 -4
  72. package/src/host/authoring/phaser-live-authoring-adapter.ts +4 -4
  73. package/src/host/authoring/pixi-authoring-adapter.ts +178 -31
  74. package/src/host/authoring/pixi-creatable-kinds.ts +62 -0
  75. package/src/host/authoring/pixi-creation-site-write-target.ts +2 -2
  76. package/src/host/authoring/pixi-live-write-target.ts +4 -4
  77. package/src/host/authoring/pixi-source-identity.ts +3 -3
  78. package/src/host/authoring/pixi-source-write-target.ts +1709 -0
  79. package/src/host/authoring/pixi-still-presentation.ts +1 -1
  80. package/src/host/authoring/pixi-structure-history.ts +2 -2
  81. package/src/host/authoring/pixi-transform-channels.ts +16 -14
  82. package/src/host/authoring/source-persistence-backend.ts +3 -3
  83. package/src/host/authoring/struct-write-pipe.ts +1 -1
  84. package/src/host/binding-resolver.ts +8 -9
  85. package/src/host/browser-transpile.ts +1 -1
  86. package/src/host/canvas-entry-runtime.ts +58 -47
  87. package/src/host/canvas-preview-frames.ts +482 -0
  88. package/src/host/components/CameraAuthoringOverlay.tsx +1 -1
  89. package/src/host/components/HeaderTelemetry.tsx +4 -4
  90. package/src/host/components/PixiIsolationSceneContent.tsx +11 -11
  91. package/src/host/components/ThreeIsolationSceneContent.tsx +3 -3
  92. package/src/host/components/frame-debugger-model.ts +2 -2
  93. package/src/host/components/header-telemetry-model.ts +2 -2
  94. package/src/host/components/scene-document.tsx +14 -14
  95. package/src/host/components/utility-view-state.ts +1 -1
  96. package/src/host/components/world-root-stage-binding.tsx +12 -12
  97. package/src/host/components/world-root-stage.ts +70 -49
  98. package/src/host/coverage/system-adapter-coverage.ts +3 -4
  99. package/src/host/design-system-stories/fixtures/editor-runtime.tsx +9 -11
  100. package/src/host/document-preview-three.ts +1 -1
  101. package/src/host/entry-adjudication.ts +6 -6
  102. package/src/host/game-css-scope-transform.ts +4 -0
  103. package/src/host/game-realm-page.ts +1 -1
  104. package/src/host/gameplay-export.ts +25 -14
  105. package/src/host/gameplay-recording.ts +5 -5
  106. package/src/host/gated-globals.ts +2 -2
  107. package/src/host/history/json-history-resource.ts +1 -1
  108. package/src/host/projection/pixi.ts +24 -2
  109. package/src/host/r3f-entry-runtime.ts +65 -34
  110. package/src/host/react-mount-runtime.ts +7 -48
  111. package/src/host/realm-services.ts +1 -1
  112. package/src/host/roots/canvas-root.tsx +373 -0
  113. package/src/host/roots/r3f-root.tsx +473 -0
  114. package/src/host/roots/react-root.ts +9 -43
  115. package/src/host/served-bundle-runtime-modules.ts +3 -19
  116. package/src/host/server-log-bridge.ts +2 -2
  117. package/src/host/stories/mounted-story-viewport-source.ts +1 -1
  118. package/src/host/stories/pixi-story-model.ts +30 -0
  119. package/src/host/stories/story-media-captures.ts +46 -0
  120. package/src/host/stories/story-media-presence.ts +3 -3
  121. package/src/host/stories/story-pixi-preview.ts +408 -0
  122. package/src/host/stories/story-three-preview.ts +806 -0
  123. package/src/host/stories/three-story-captures.ts +35 -0
  124. package/src/host/story-three-preview-runtime.ts +56 -0
  125. package/src/host/use-active-performance-source.ts +2 -2
  126. package/src/host/viewport-pose-memory.ts +1 -1
  127. package/src/host/viewport-root-presentation.ts +6 -5
  128. package/src/ingest/active-ingest.ts +1 -1
  129. package/src/ingest/authoring/ingest-dom-surface-authoring.ts +4 -4
  130. package/src/ingest/authoring/ingest-root-adapter.ts +8 -8
  131. package/src/ingest/deferred-ingest-play.ts +6 -6
  132. package/src/ingest/discovery-public-ingest.ts +2 -2
  133. package/src/ingest/ingest-boot-viewport.ts +2 -2
  134. package/src/ingest/ingest-canvas-scene-document.tsx +9 -9
  135. package/src/ingest/ingest-canvas-scene.ts +3 -3
  136. package/src/ingest/ingest-evidence-hook.ts +1 -1
  137. package/src/ingest/ingest-frame-snapshot.ts +1 -1
  138. package/src/ingest/ingest-play-control.ts +1 -1
  139. package/src/ingest/ingest-render-debug.ts +10 -10
  140. package/src/ingest/ingest-siblings.ts +13 -25
  141. package/src/ingest/module-mode.ts +14 -14
  142. package/src/ingest/mount-canvas-ingest-root.ts +21 -21
  143. package/src/ingest/mount-coverage.ts +2 -2
  144. package/src/ingest/mount-dom-ingest-root.ts +11 -11
  145. package/src/ingest/mount-ingest-root.ts +8 -8
  146. package/src/ingest/mount-three-ingest-root.ts +8 -8
  147. package/src/ingest/resolve-canvas.ts +1 -1
  148. package/src/ingest/served-html-boot.ts +1 -1
  149. package/src/ingest/surface-canvas.ts +1 -1
  150. package/src/ingest/unmount-ingest-root.ts +5 -5
  151. package/src/navmesh/navmesh-handler.ts +24 -16
  152. package/src/network/NetworkInspectorPanel.tsx +939 -37
  153. package/src/network/network-inspector-model.ts +20 -2
  154. package/src/play/play-log-events.ts +1 -1
  155. package/src/play/play-mode.ts +76 -133
  156. package/src/play/play-recording.ts +1 -1
  157. package/src/play/react-play-live-authoring.ts +3 -3
  158. package/src/play/run-selection.ts +93 -0
  159. package/src/play-bar/PlayBar.tsx +20 -39
  160. package/src/profiler/FrameDebuggerPanel.tsx +1 -1
  161. package/src/profiler/PerformancePanel.tsx +3 -3
  162. package/src/react/design-time-react-mount.ts +23 -65
  163. package/src/react/dom-authoring-adapter.ts +9 -9
  164. package/src/react/react-inspector-section.tsx +5 -5
  165. package/src/react/react-world-authoring-adapter.ts +11 -11
  166. package/src/react/story-documents/story-documents.tsx +9 -9
  167. package/src/react/ui-board-document.tsx +7 -7
  168. package/src/react/ui-component-board.ts +2 -2
  169. package/src/runtime/adapter/audio-meter.ts +21 -0
  170. package/src/runtime/adapter/first-party-audio-system.ts +230 -0
  171. package/src/runtime/adapter/ingest/contract-debug-adapter.ts +114 -0
  172. package/src/runtime/adapter/ingest/contract-system-adapters.ts +256 -0
  173. package/src/runtime/adapter/ingest/merge-debug-adapters.ts +197 -0
  174. package/src/runtime/adapter/ingest/observation-debug-adapter.ts +162 -0
  175. package/src/runtime/adapter/ingest/upstream-pin.ts +51 -0
  176. package/src/runtime/adapter/native-debug-module.ts +498 -0
  177. package/src/runtime/audio/bus-mixer.ts +161 -0
  178. package/src/runtime/audio/pose-guard.ts +80 -0
  179. package/src/runtime/core/frame-pacing.ts +126 -0
  180. package/src/runtime/core/game-loop.ts +225 -0
  181. package/src/runtime/core/game-scoped-slot.ts +28 -0
  182. package/src/runtime/core/seeded-random.ts +162 -0
  183. package/src/runtime/core/sim-clock.ts +391 -0
  184. package/src/runtime/core/system-runner.ts +269 -0
  185. package/src/runtime/core/types.ts +104 -0
  186. package/src/runtime/create-runtime.ts +1128 -0
  187. package/src/runtime/debug-bridge.ts +570 -0
  188. package/src/runtime/debug-registry.ts +899 -0
  189. package/src/runtime/dev/chrome-trace.ts +153 -0
  190. package/src/runtime/dev/instruments.ts +403 -0
  191. package/src/runtime/dev/logger.ts +119 -0
  192. package/src/runtime/dev/performance-profiler.ts +367 -0
  193. package/src/runtime/dev/register-render-vitals.ts +276 -0
  194. package/src/runtime/dev/render-census.ts +354 -0
  195. package/src/runtime/dev/render-debug-adapter.ts +218 -0
  196. package/src/runtime/dev/render-memory.ts +226 -0
  197. package/src/runtime/dev/render-vitals.ts +338 -0
  198. package/src/runtime/dev/static-batch-advisor.ts +188 -0
  199. package/src/runtime/dev/webgl-frame-capture.ts +366 -0
  200. package/src/runtime/dev/webgl-gpu-timer.ts +53 -0
  201. package/src/runtime/dev-build.ts +47 -0
  202. package/src/runtime/game.ts +1636 -0
  203. package/src/runtime/gameplay-rng-trap.ts +135 -0
  204. package/src/runtime/host-context.ts +64 -0
  205. package/src/runtime/input-router.ts +182 -0
  206. package/src/runtime/mount-manifest.ts +480 -0
  207. package/src/runtime/pixi/authoring.ts +706 -0
  208. package/src/runtime/pixi/ingest.ts +116 -0
  209. package/src/runtime/pixi/physics-registry.ts +49 -0
  210. package/src/runtime/pixi/render-pass-bracket.ts +117 -0
  211. package/src/runtime/pixi/scene-capture.ts +179 -0
  212. package/src/runtime/pixi/system-adapters.ts +69 -0
  213. package/src/runtime/playtest.ts +22 -0
  214. package/src/runtime/presentation.ts +141 -0
  215. package/src/runtime/render-control.ts +642 -0
  216. package/src/runtime/render-seed.ts +77 -0
  217. package/src/runtime/run-ticks-settled.ts +73 -0
  218. package/src/runtime/setup/setup-audio.ts +72 -0
  219. package/src/services/audio-pose-guard.ts +2 -2
  220. package/src/services/game-audio.ts +152 -0
  221. package/src/services/game-network.ts +767 -0
  222. package/src/services/game-physics.ts +334 -0
  223. package/src/state-watch/StateWatchPanel.tsx +1 -1
  224. package/src/three/authoring/camera-runtime-inspector-section.tsx +3 -2
  225. package/src/three/authoring/constraint-inspector-section.tsx +6 -5
  226. package/src/three/authoring/model-asset-inspector-section.tsx +7 -6
  227. package/src/three/authoring/oid-source-persistence.ts +7 -7
  228. package/src/three/authoring/r3f-design-session.ts +58 -54
  229. package/src/three/authoring/r3f-source-authoring-adapter.ts +95 -91
  230. package/src/three/authoring/reflection-probe-inspector-section.tsx +3 -2
  231. package/src/three/authoring/three-authoring-adapter.ts +29 -29
  232. package/src/three/component-verbs/extract-menu.ts +7 -6
  233. package/src/three/component-verbs/fork-menu.ts +7 -6
  234. package/src/three/component-verbs/internals-menu.ts +2 -2
  235. package/src/three/story-documents/three-story-documents.tsx +13 -13
  236. package/src/three/three-board/ThreeBoardDocument.tsx +13 -12
  237. package/src/three/three-board/board-scene.ts +6 -6
  238. package/src/three/three-board/three-component-board.ts +2 -2
  239. package/src/services/game-audio-unlock.ts +0 -48
@@ -0,0 +1,899 @@
1
+ /**
2
+ * The game-scoped debug/synthetic-player registry. ONE registry per `Game` root —
3
+ * every world's `ctx.debug` (see {@link DebugCtxSurface} below) feeds the
4
+ * SAME registry via {@link DebugRegistry.forRoot}, so a name a game registers is
5
+ * visible (and name-collision-checked) across every world, not just the one that
6
+ * registered it. `createGame` (`runtime/game.ts`) constructs the registry
7
+ * and files it in a non-enumerable game-scoped slot;
8
+ * later consumers (editor panels, the session wire) reach it via {@link getDebugRegistry}
9
+ * rather than threading it through every call site.
10
+ *
11
+ * Provenance note: a registration's "world" is the MOUNT's adapter id (the
12
+ * id a project's root adapter already carries) rather than the
13
+ * `RootInstance.id` `registerThreeRoot`
14
+ * assigns — that id isn't known until AFTER `mount()` resolves (`create-
15
+ * runtime.ts` calls `registerThreeRoot` with it only once `mount()` returns),
16
+ * which is after every `setup()`-time registration has already run. For the
17
+ * single-world case (nearly every project today) this is simply `'setup-three'`;
18
+ * a multi-world manifest names each world's adapter to match its declared id
19
+ * by convention, so collision messages stay meaningful in practice.
20
+ */
21
+
22
+ import type {
23
+ DebugAdapter,
24
+ DebugCommandInfo,
25
+ TickStampedEvent,
26
+ } from '@volter/editor-project/adapter/system-adapter';
27
+ import { z } from 'zod';
28
+ import { createGameScopedSlot } from './core/game-scoped-slot';
29
+ import type { GameLoopLiveness } from './core/types';
30
+ import type { Game } from './game';
31
+
32
+ /**
33
+ * The minimal structural shape `ctx.debug.attachRoom` needs from a joined
34
+ * Colyseus room: just enough to send the reserved `__vgai:debugCommand`
35
+ * message and listen for its `__vgai:debugCommandResult` reply. Structural,
36
+ * not a Colyseus type import — the same seam-boundary style every other
37
+ * `DebugCtxSurface` member uses (no wrapper around Colyseus itself, D2).
38
+ * `onMessage`'s return type is `unknown` because Colyseus's own return shape
39
+ * (void in some client versions, an unsubscribe function in others) isn't
40
+ * load-bearing here: the registry calls it defensively (invokes it on detach
41
+ * only if it's actually a function).
42
+ */
43
+ export interface DebugRoomHandle {
44
+ send(type: string, message?: unknown): void;
45
+ onMessage(type: string, cb: (message: unknown) => void): unknown;
46
+ }
47
+
48
+ /**
49
+ * Infers a `registerCommand`
50
+ * handler's parameter tuple from its declared Zod `args` tuple (dry-run
51
+ * finding, ledger — "`registerCommand` fn typing forces `unknown[]` casts"):
52
+ * a real `z.ZodTuple` infers its element types (`z.tuple([z.number(),
53
+ * z.string()])` → `(n: number, s: string) => ...`), while omitting `args`
54
+ * (the generic's `undefined` default) resolves to the original permissive
55
+ * `(...args: unknown[]) => ...` shape every command had before this generic
56
+ * existed. That permissive fallback is deliberate, not just a placeholder —
57
+ * it keeps every existing no-schema registration (including ones whose
58
+ * handler reads positional args the runtime never validates, since
59
+ * `invoke()` only parses `args` against a schema when one is declared)
60
+ * compiling byte-for-byte unchanged.
61
+ */
62
+ export type DebugCommandArgs<T extends z.ZodTuple | undefined = undefined> = T extends z.ZodTuple
63
+ ? z.infer<T>
64
+ : unknown[];
65
+
66
+ /**
67
+ * The authoring half of the debug/synthetic-player seam — the read/actuate half is
68
+ * `SystemAdapters.DebugAdapter` (`adapter/system-adapter.ts`). Callable from any
69
+ * `init` (one file per new provider/command). Every registration feeds the ONE
70
+ * game-scoped registry (`runtime/debug-registry.ts`) — never a per-world accumulator.
71
+ */
72
+ export interface DebugCtxSurface {
73
+ /** Register (or replace) a named, JSON-serializable state read. Duplicate
74
+ * names from the SAME world replace + warn once; the SAME name from a
75
+ * DIFFERENT world throws (`DebugError` code `DEBUG_NAME_COLLISION`). */
76
+ registerStateProvider(
77
+ name: string,
78
+ fn: () => unknown,
79
+ opts?: { tier?: 'observable' | 'assisted' },
80
+ ): void;
81
+ /** Register (or replace) an invokable command. `locus` is REQUIRED once the
82
+ * project declares a Colyseus room (`DebugRegistry.setRoomDeclared`) — a
83
+ * fixture must say whether it mutates authoritative (server) or predicted
84
+ * (client) state.
85
+ *
86
+ * Generic over the declared `args` Zod tuple ({@link DebugCommandArgs}) so
87
+ * `fn`'s parameters INFER from it: `registerCommand('setHp', { args:
88
+ * z.tuple([z.number()]) }, (hp) => ...)` types `hp` as `number` with no
89
+ * cast. Leaving `args` off keeps `fn` typed `(...args: unknown[]) => ...`,
90
+ * same as before this generic existed. */
91
+ registerCommand<T extends z.ZodTuple | undefined = undefined>(
92
+ name: string,
93
+ spec: { description?: string; args?: T; locus?: 'client' | 'server' },
94
+ fn: (...args: DebugCommandArgs<T>) => unknown | Promise<unknown>,
95
+ ): void;
96
+ /** Push a tick-stamped event onto the debug event ring (spec §3.3) — the
97
+ * engine stamps `tick`/`simT` at emission, not at read time. */
98
+ emit(event: string, detail?: unknown): void;
99
+ /**
100
+ * Task 2.2 — server-locus command routing (client leg). Call once your
101
+ * game code has joined its Colyseus room, so `locus: 'server'` commands
102
+ * have somewhere to send `__vgai:debugCommand`. Returns a detach function
103
+ * (call on room leave/dispose) — game-scoped, last-attached room wins.
104
+ */
105
+ attachRoom(room: DebugRoomHandle): () => void;
106
+ }
107
+
108
+ /** Engine-local error for the debug seam — mirrors the `code` + `data` shape
109
+ * `packages/vgai-sdk/src/errors.ts` uses (no import: the engine does not
110
+ * depend on `@vgai/sdk`). `code` is always machine-readable; nothing reading
111
+ * this error may key off `message` prose. */
112
+ export class DebugError extends Error {
113
+ readonly code: string;
114
+ readonly data?: Record<string, unknown> | undefined;
115
+
116
+ constructor(code: string, message: string, data?: Record<string, unknown>) {
117
+ super(message);
118
+ this.name = 'DebugError';
119
+ this.code = code;
120
+ this.data = data;
121
+ }
122
+ }
123
+
124
+ const REGISTRATION_HINT =
125
+ 'ctx.debug.registerStateProvider(name, fn) — see the template example component';
126
+
127
+ /** A thrown value flattened to JSON-SAFE fields. Every caller of a debug
128
+ * command is on the far side of a JSON boundary — the editor session relay
129
+ * (`POST /__editor/command`) and `page.evaluate` both serialize the error's
130
+ * `data` bag — and an `Error` stringifies to `{}` there, so a command's own
131
+ * refusal text ("no such waypoint") vanished and the developer at
132
+ * `vgai eval` saw only `debug: command "x" threw`. The text has to travel as
133
+ * plain strings, and in the `message` above all: that is the one field every
134
+ * client (`SessionError`, the CLI's own error print) actually surfaces. */
135
+ function describeCause(cause: unknown): {
136
+ causeName: string;
137
+ causeMessage: string;
138
+ causeStack?: string;
139
+ } {
140
+ if (cause instanceof Error) {
141
+ const described = { causeName: cause.name, causeMessage: cause.message };
142
+ return cause.stack === undefined ? described : { ...described, causeStack: cause.stack };
143
+ }
144
+ return { causeName: typeof cause, causeMessage: String(cause) };
145
+ }
146
+
147
+ /** Task 2.2 — how long `invoke()` waits for a `locus: 'server'` command's
148
+ * `__vgai:debugCommandResult` reply before failing loudly. */
149
+ const SERVER_COMMAND_TIMEOUT_MS = 10_000;
150
+
151
+ /** The virtual-input surface the debug bridge (`runtime/debug-bridge.ts`)
152
+ * actuates through: a root's input door — its entry's `debug.input`
153
+ * (`adapter/native-debug-module.ts`) or a `vgai.adapter.ts` input binding
154
+ * (`host/adapter-runtime-bindings.ts`), wired once per root when its bindings
155
+ * install, at the same spot as `setInputActionsSource` — absent until then. */
156
+ export interface DebugVirtualInputTarget {
157
+ setVirtualAction(
158
+ action: string,
159
+ value: boolean | number | { x: number; y: number },
160
+ ): { delivered: boolean; reason?: string };
161
+ tapVirtualAction(action: string): { delivered: boolean; reason?: string };
162
+ clearVirtualActions(): void;
163
+ /** D15/T-D15.5 — schedule a virtual actuation for a specific future (or
164
+ * current) tick, applied at the start of that tick's input phase (composes
165
+ * with `runTicks`). */
166
+ scheduleActionAtTick(
167
+ tick: number,
168
+ action: string,
169
+ value: boolean | number | { x: number; y: number },
170
+ ): void;
171
+ startInputRecording(): void;
172
+ stopInputRecording(): void;
173
+ isInputRecording(): boolean;
174
+ /** The four named-test-source injectors, included here so `inject-input`'s non-`action` kinds route through the
175
+ * SAME per-world resolution as everything else on this interface (D15
176
+ * review objection: routing must never depend on registration order). */
177
+ injectAxis(sourceId: string, value: number): void;
178
+ injectVector2(sourceId: string, value: { x: number; y: number }): void;
179
+ injectPointerDelta(sourceId: string, delta: { x: number; y: number }): void;
180
+ injectPointerPosition(sourceId: string, value: { x: number; y: number }): void;
181
+ }
182
+
183
+ /**
184
+ * D15/T-D15.5 — the `input.trace` builtin provider's shape.
185
+ * `seed`/`fixedDt` are
186
+ * replay-critical metadata a future SP5 consumer needs BESIDE the raw
187
+ * per-tick action deltas (`ticks`, the input door's own trace)
188
+ * to know what to replay the trace AGAINST — recording deltas alone is not
189
+ * enough to reproduce a run. Both are `null` only when no wiring/no
190
+ * `ctx.random`/no `Game` exists behind this world (never a fabricated 0).
191
+ */
192
+ export interface InputTraceSnapshot {
193
+ version: 1;
194
+ seed: number | null;
195
+ fixedDt: number | null;
196
+ ticks: unknown[];
197
+ }
198
+
199
+ /** `Game.runTicks`'s options — see `GameInternal.runTicks`'s doc comment
200
+ * (`runtime/game.ts`, D15/T-D15.3-.4) for full semantics. Named here (not
201
+ * re-declared per-caller) so the bridge (`runtime/debug-bridge.ts`), the
202
+ * editor relay (`command-listener.ts`'s `run-ticks` case), and
203
+ * `play.runTicks` (`@vgai/sdk`) all reference the SAME type. */
204
+ export interface RunTicksOptions {
205
+ /** `'last'` (default) — skip `preRender`/`render` for every tick except
206
+ * the final one. `'all'` — render every tick. `'none'` — never render,
207
+ * not even the last tick. */
208
+ render?: 'last' | 'all' | 'none';
209
+ }
210
+
211
+ /** The run-ticks actuation surface a live `Game` wires in (see
212
+ * {@link DebugRegistry.setRunTicksTarget}) — just `GameInternal.runTicks`'s
213
+ * signature, typed narrowly here so this module never imports `./game`
214
+ * as a value (only `Game` as a type, already the case above). */
215
+ export interface RunTicksTarget {
216
+ runTicks(n: number, opts?: RunTicksOptions): void;
217
+ }
218
+
219
+ interface PendingServerCommand {
220
+ resolve(result: unknown): void;
221
+ reject(err: unknown): void;
222
+ timer: ReturnType<typeof setTimeout>;
223
+ }
224
+
225
+ interface ProviderEntry {
226
+ fn: () => unknown;
227
+ tier: 'observable' | 'assisted';
228
+ worldId: string;
229
+ builtin: boolean;
230
+ }
231
+
232
+ interface CommandEntry {
233
+ description?: string | undefined;
234
+ argsSchema?: z.ZodTuple | undefined;
235
+ locus?: 'client' | 'server' | undefined;
236
+ fn: (...args: unknown[]) => unknown | Promise<unknown>;
237
+ worldId: string;
238
+ }
239
+
240
+ /** What {@link createDebugRegistry} returns — the adapter half (`DebugAdapter`,
241
+ * for `SystemAdapters.debug`) plus the registration/lifecycle surface the
242
+ * hosting adapter (`editor-game/src/host/roots/r3f-root.tsx`) and `createGame` drive. */
243
+ export interface DebugRegistry {
244
+ /** The `SystemAdapters.debug` implementer — one shared instance, seeded
245
+ * onto every world's adapter bag. */
246
+ readonly adapter: DebugAdapter;
247
+ /** Build the `ctx.debug` surface for one world/mount — registrations made
248
+ * through it carry `worldId` as their provenance for collision messages. */
249
+ forRoot(worldId: string): DebugCtxSurface;
250
+ /**
251
+ * Remove non-built-in registrations (Defect 2 fix — this used to be
252
+ * unconditionally global, which made a per-mount call like `hotReload`'s
253
+ * silently wipe every OTHER live world's registrations).
254
+ *
255
+ * - `strip(worldId)` — scoped: removes only providers/commands whose
256
+ * provenance is exactly `worldId` (and clears their warn-once state), so
257
+ * a hot-reloading world re-seeds itself without disturbing anyone else.
258
+ * - `strip()` (no id) — the original global behavior: every non-built-in
259
+ * registration is removed. Intended for "the
260
+ * whole game is going away" (`disposeGame`) or test teardown, not a
261
+ * single mount's warm restart.
262
+ *
263
+ * Either way, the very next registration of a name just removed is silent —
264
+ * there is nothing left to collide with or warn about.
265
+ */
266
+ strip(worldId?: string): void;
267
+ /** Manifest wiring lands in a later wave; until then, call this directly
268
+ * (default `false`) to exercise the locus-required throw. */
269
+ setRoomDeclared(declared: boolean): void;
270
+ /** Wire the built-in `input.actions` provider to a root's input door
271
+ * (T1.2), scoped to `worldId` — lazy, since a root's door doesn't
272
+ * exist yet when the registry is constructed (`createGame`, before any
273
+ * world mounts). Before ANY source is set, `input.actions` reads `[]`;
274
+ * once one or more roots have registered, the built-in `input.actions`
275
+ * provider reads the DEFAULT world's (see {@link resolveInputRootId}) —
276
+ * same resolution every other per-world seam on this interface uses. */
277
+ setInputActionsSource(worldId: string, fn: () => { name: string; valueType: string }[]): void;
278
+ /** D15/T-D15.5 — wire the built-in `input.trace` provider to a live
279
+ * root's input trace, scoped to `worldId` — same lazy-supplier
280
+ * shape/seed spot as {@link setInputActionsSource}, same default-world
281
+ * resolution for the read. Before a source is set, `input.trace` reads
282
+ * `{version: 1, seed: null, fixedDt: null, ticks: []}`. `seed`/`fixedDt`
283
+ * are the two replay-critical metadata fields the design doc's format
284
+ * sketch (§2.c) calls for beside the raw per-tick deltas. An `engine`
285
+ * (package version) stamp remains a KNOWN GAP — no build-time version
286
+ * constant is threaded into the runtime bundle today; a future track
287
+ * adding one should extend this shape, not invent a second trace format. */
288
+ setInputTraceSource(worldId: string, fn: () => InputTraceSnapshot): void;
289
+ /**
290
+ * Wire the debug bridge's actuation methods (`runtime/debug-bridge.ts`) to
291
+ * a root's input door (Task 2.1), scoped to `worldId` — same lazy-
292
+ * supplier shape as {@link setInputActionsSource}, wired at the same seed
293
+ * spot (once per world mount, not once per Game).
294
+ *
295
+ * Every world's target is kept, keyed by `worldId`, and
296
+ * {@link getVirtualInputTarget} resolves ONE of them via
297
+ * {@link resolveInputRootId} — the SAME resolution the debug bridge
298
+ * (`window.__vgai.input.*`) and the editor relay both call through, so they
299
+ * can never disagree about which root an unaddressed actuation reaches. An
300
+ * explicit `worldId` reaches that world specifically. */
301
+ setVirtualInputTarget(worldId: string, target: DebugVirtualInputTarget): void;
302
+ /**
303
+ * Resolve and return a virtual-input target: `worldId` given and
304
+ * registered → that world's; omitted → the DEFAULT world's (per
305
+ * {@link resolveInputRootId} — the manifest's first/default world when a
306
+ * `Game` is behind this registry, else the single registered world, else
307
+ * whichever registered first), consistently, for every caller (the debug
308
+ * bridge and the editor relay both call this — see this interface's own
309
+ * doc comment above). `null` when nothing is registered for the resolved
310
+ * id at all (callers throw a structured `DEBUG_INPUT_UNAVAILABLE` in that
311
+ * case rather than silently no-op-ing). Throws `DebugError`
312
+ * (`DEBUG_INPUT_WORLD_NOT_FOUND`) for an EXPLICIT `worldId` that was never
313
+ * registered — a caller mistake, distinct from "nothing mounted yet".
314
+ */
315
+ getVirtualInputTarget(worldId?: string): DebugVirtualInputTarget | null;
316
+ /**
317
+ * D15/T-D15.4: wire `Game.runTicks` (`runtime/game.ts`) as the run-ticks
318
+ * actuation target — called ONCE by `createGame`, immediately (unlike
319
+ * {@link setVirtualInputTarget}, which waits for a per-world mount, a
320
+ * `Game`'s own `runTicks` exists the instant the Game shell does). The
321
+ * SAME target backs `runtime/debug-bridge.ts`'s `window.__vgai.runTicks`
322
+ * (door a) and the editor relay's `run-ticks` case → `play.runTicks`
323
+ * (door b) — one implementation, byte-identical semantics across doors
324
+ * (D17).
325
+ */
326
+ setRunTicksTarget(target: RunTicksTarget): void;
327
+ /** The target {@link setRunTicksTarget} last set, or `null` before any
328
+ * `Game` has wired one (a bare `createDebugRegistry()` test stand-in with
329
+ * no `createGame` behind it). Consumers throw a structured "unavailable"
330
+ * error in that case rather than silently no-op-ing — see
331
+ * `debug-bridge.ts`'s `runTicks` method. */
332
+ getRunTicksTarget(): RunTicksTarget | null;
333
+ /**
334
+ * Register a WORLD-SETTLED probe: `false` while this game is intentionally
335
+ * between worlds — a scene remount in flight (`reload_current_scene`'s
336
+ * React remount commits asynchronously). The game declares it through the
337
+ * `settled` key of its `debug` entry export (`native-debug-module.ts`);
338
+ * session tick drivers ({@link runTicksWhenSettled} behind BOTH run-ticks
339
+ * doors) wait for every probe to answer `true` before each tick, so WHICH
340
+ * tick first runs a freshly remounted scene is a function of the sim, not
341
+ * of wall timing between driver calls — measured before this seam: the
342
+ * same drive script produced 7 or 8 post-respawn walked ticks depending on
343
+ * how long the driver idled between ticks. Returns the deregistration.
344
+ */
345
+ registerWorldSettledProbe(probe: () => boolean): () => void;
346
+ /** `true` when every registered probe answers `true` (and vacuously with
347
+ * none registered). A probe that THROWS counts as settled=false — a
348
+ * broken probe must stall the driver loudly (its bounded wait names the
349
+ * timeout), never silently un-gate it. */
350
+ worldSettled(): boolean;
351
+ /** D15/T-D15.3/.5 — the CURRENT shared game tick (the same counter the
352
+ * built-in `time` provider's `tick` field reads), for a root's input door
353
+ * to key its `scheduleActionAtTick` numbering off. `0` for a bare `createDebugRegistry()` test stand-in with
354
+ * no real `Game`/tick counter behind it (matching `getTick`'s own
355
+ * constructor-supplied default in that case). */
356
+ getGameTick(): number;
357
+ }
358
+
359
+ function warnOnce(
360
+ warned: Set<string>,
361
+ name: string,
362
+ kind: 'provider' | 'command',
363
+ worldId: string,
364
+ ): void {
365
+ if (warned.has(name)) return;
366
+ warned.add(name);
367
+ // biome-ignore lint/suspicious/noConsole: structured, greppable — the debug seam's own duplicate-registration signal (spec §3.1)
368
+ console.warn(`[debug] ${kind} "${name}" re-registered (world "${worldId}")`);
369
+ }
370
+
371
+ /**
372
+ * Construct a fresh game-scoped debug registry. `getTick`/`getSimT` are
373
+ * suppliers (not values) so the built-in `time` provider always reads the
374
+ * CURRENT counters — `createGame` passes closures over its own mutable
375
+ * `tick`/`simT`, incremented in `runFrame`'s tail (T1.2). `getDefaultRootId`
376
+ * (D15/T-D15.5, optional) resolves the manifest's first/default world id —
377
+ * `createGame` passes `() => (roots.length ? requireDefaultRoot().id :
378
+ * null)`; a bare `createDebugRegistry()` test stand-in with no `Game` behind
379
+ * it omits it (per-world resolution then falls back to "the single
380
+ * registered world" or "whichever registered first" — see
381
+ * `resolveInputRootId`). `getLoopLiveness` (issue #175, optional) supplies
382
+ * the REAL `GameLoop.liveness` — `createGame` passes `() => opts.loop.
383
+ * liveness`; a bare `createDebugRegistry()` test stand-in with no loop
384
+ * behind it omits it, and the built-in `time` provider reports `null`
385
+ * rather than fabricating `'running'` (this module must never claim health
386
+ * it cannot observe, same rule the loop's own liveness getter documents).
387
+ */
388
+ export function createDebugRegistry(opts: {
389
+ getTick(): number;
390
+ getSimT(): number;
391
+ /** The host loop's configured simulation step, when a real Game owns this registry. */
392
+ getFixedDt?(): number;
393
+ getDefaultRootId?(): string | null;
394
+ getLoopLiveness?(): GameLoopLiveness;
395
+ }): DebugRegistry {
396
+ const providers = new Map<string, ProviderEntry>();
397
+ const commands = new Map<string, CommandEntry>();
398
+ const warnedProviders = new Set<string>();
399
+ const warnedCommands = new Set<string>();
400
+ const ring: TickStampedEvent[] = [];
401
+ const RING_CAP = 500;
402
+ // Run-4 friction #5 — monotonic, registry-lifetime counter backing
403
+ // `TickStampedEvent.seq`. Never reset, never shared by two events (unlike
404
+ // `tick`, which a debug-command emission and a fenced consumer's snapshot
405
+ // can legitimately collide on — see that field's doc comment in
406
+ // `adapter/system-adapter.ts`).
407
+ let seqCounter = 0;
408
+
409
+ let roomDeclared = false;
410
+ // D15/T-D15.5 — per-world maps (Map preserves insertion order, which
411
+ // `resolveInputRootId`'s "whichever registered first" fallback relies on
412
+ // when no `getDefaultRootId` is available to disambiguate).
413
+ const inputActionsSources = new Map<string, () => { name: string; valueType: string }[]>();
414
+ const inputTraceSources = new Map<string, () => InputTraceSnapshot>();
415
+ const virtualInputTargets = new Map<string, DebugVirtualInputTarget>();
416
+ let runTicksTarget: RunTicksTarget | null = null;
417
+ const worldSettledProbes = new Set<() => boolean>();
418
+ let attachedRoom: DebugRoomHandle | null = null;
419
+ const pendingServerCommands = new Map<string, PendingServerCommand>();
420
+ let requestCounter = 0;
421
+
422
+ /**
423
+ * The ONE resolution function every per-world input seam shares (D15
424
+ * review objection: the debug bridge and the editor relay must never be
425
+ * able to disagree about which world an unqualified actuation targets).
426
+ *
427
+ * - `explicit` given: must be a registered world id, else throws
428
+ * `DEBUG_INPUT_WORLD_NOT_FOUND` (a caller mistake — distinct from
429
+ * "nothing mounted yet", which returns `null` below instead of throwing).
430
+ * - `explicit` omitted: the manifest's first/default world id
431
+ * (`opts.getDefaultRootId()`) iff that world has actually registered —
432
+ * else (no `Game`/no default resolvable, or the default world never
433
+ * wired one — e.g. a foreign/opaque mount) the single registered world,
434
+ * or, with more than one and no resolvable default, whichever registered
435
+ * FIRST (`Map` insertion order) — the closest analogue to this seam's
436
+ * pre-D15.5 single-slot behavior, but now a stable, principled choice
437
+ * instead of "whichever mounted last".
438
+ * - Nothing registered at all: `null`.
439
+ */
440
+ function resolveInputRootId(explicit?: string): string | null {
441
+ if (explicit !== undefined) {
442
+ if (!virtualInputTargets.has(explicit)) {
443
+ throw new DebugError(
444
+ 'DEBUG_INPUT_WORLD_NOT_FOUND',
445
+ `debug: no input door wired for world "${explicit}" — registered: ` +
446
+ (virtualInputTargets.size ? [...virtualInputTargets.keys()].join(', ') : '(none)'),
447
+ { worldId: explicit, registered: [...virtualInputTargets.keys()] },
448
+ );
449
+ }
450
+ return explicit;
451
+ }
452
+ return resolveRootForSource(virtualInputTargets.keys());
453
+ }
454
+
455
+ /** The same "default world, else the one registered, else whichever
456
+ * registered first" fallback {@link resolveInputRootId} uses for the
457
+ * ACTUATION target, generalized over any per-world registration set
458
+ * (`inputActionsSources`/`inputTraceSources` included) — every one of
459
+ * these maps is keyed by the same world ids, populated at the same
460
+ * per-world mount seed spot, so "the default world" means the same thing
461
+ * for all of them. Never throws (no explicit-id case here — the two
462
+ * builtin providers that call this have no way to accept a caller-chosen
463
+ * worldId today; see their own doc comments). */
464
+ function resolveRootForSource(registered: IterableIterator<string>): string | null {
465
+ const ids = [...registered];
466
+ if (ids.length === 0) return null;
467
+ const defaultId = opts.getDefaultRootId?.() ?? null;
468
+ if (defaultId !== null && ids.includes(defaultId)) return defaultId;
469
+ return ids[0]!;
470
+ }
471
+
472
+ providers.set('time', {
473
+ // `loopLiveness` (issue #175): `null` when no loop is wired behind this
474
+ // registry (a bare `createDebugRegistry()` test stand-in) — never a
475
+ // fabricated `'running'`. Every real `Game` (`createGame`) wires this,
476
+ // so every live play session reports a real value.
477
+ fn: () => ({
478
+ simSeconds: opts.getSimT(),
479
+ tick: opts.getTick(),
480
+ loopLiveness: opts.getLoopLiveness?.() ?? null,
481
+ ...(opts.getFixedDt === undefined ? {} : { fixedDt: opts.getFixedDt() }),
482
+ }),
483
+ tier: 'observable',
484
+ worldId: '__engine__',
485
+ builtin: true,
486
+ });
487
+ providers.set('input.actions', {
488
+ // Resolves to the DEFAULT world's action list (see `resolveInputRootId`)
489
+ // — deterministic across every world that registers, rather than the
490
+ // pre-D15.5 "whichever mounted last" behavior.
491
+ fn: () => {
492
+ const worldId = resolveRootForSource(inputActionsSources.keys());
493
+ return (worldId ? inputActionsSources.get(worldId) : undefined)?.() ?? [];
494
+ },
495
+ tier: 'observable',
496
+ worldId: '__engine__',
497
+ builtin: true,
498
+ });
499
+ // D15/T-D15.5 — the post-gate action-delta trace, readable via a provider.
500
+ // Absent a wired source (no world mounted yet) reads an empty, correctly-
501
+ // versioned trace rather than throwing. Same default-world resolution as
502
+ // `input.actions` immediately above.
503
+ providers.set('input.trace', {
504
+ fn: () => {
505
+ const worldId = resolveRootForSource(inputTraceSources.keys());
506
+ return (
507
+ (worldId ? inputTraceSources.get(worldId) : undefined)?.() ?? {
508
+ version: 1,
509
+ seed: null,
510
+ fixedDt: null,
511
+ ticks: [],
512
+ }
513
+ );
514
+ },
515
+ tier: 'observable',
516
+ worldId: '__engine__',
517
+ builtin: true,
518
+ });
519
+
520
+ function registerStateProvider(
521
+ worldId: string,
522
+ name: string,
523
+ fn: () => unknown,
524
+ tier: 'observable' | 'assisted',
525
+ ): void {
526
+ const existing = providers.get(name);
527
+ if (existing?.builtin) {
528
+ // Defect 1 fix: a builtin name (`time`, `input.actions`) must never be
529
+ // silently shadowed — the old `!existing.builtin` guard skipped BOTH
530
+ // the collision throw and the warn for this case, so
531
+ // `registerStateProvider('time', ...)` quietly replaced engine truth,
532
+ // and a later `strip()` deleted it outright (leaving NO `time`
533
+ // provider at all post-hot-reload). Throw loudly instead; there is no
534
+ // silent-replace path for a builtin.
535
+ throw new DebugError(
536
+ 'DEBUG_BUILTIN_RESERVED',
537
+ `debug: "${name}" is a built-in state provider (registered by "${existing.worldId}") — ` +
538
+ 'built-in names cannot be registered over from a world',
539
+ { name, registered: existing.worldId },
540
+ );
541
+ }
542
+ if (existing) {
543
+ if (existing.worldId !== worldId) {
544
+ throw new DebugError(
545
+ 'DEBUG_NAME_COLLISION',
546
+ `debug: state provider "${name}" is registered by both world "${existing.worldId}" ` +
547
+ `and world "${worldId}" — each provider name must be unique across live roots`,
548
+ { name, roots: [existing.worldId, worldId] },
549
+ );
550
+ }
551
+ warnOnce(warnedProviders, name, 'provider', worldId);
552
+ }
553
+ providers.set(name, { fn, tier, worldId, builtin: false });
554
+ }
555
+
556
+ function registerCommand(
557
+ worldId: string,
558
+ name: string,
559
+ spec: { description?: string; args?: z.ZodTuple; locus?: 'client' | 'server' },
560
+ fn: (...args: unknown[]) => unknown | Promise<unknown>,
561
+ ): void {
562
+ if (spec.locus === undefined && roomDeclared) {
563
+ throw new DebugError(
564
+ 'DEBUG_COMMAND_LOCUS_REQUIRED',
565
+ "this project declares a Colyseus room — declare locus: 'client' | 'server' so " +
566
+ 'fixtures mutate authoritative state, not client prediction',
567
+ { name, worldId },
568
+ );
569
+ }
570
+ const existing = commands.get(name);
571
+ if (existing) {
572
+ if (existing.worldId !== worldId) {
573
+ throw new DebugError(
574
+ 'DEBUG_NAME_COLLISION',
575
+ `debug: command "${name}" is registered by both world "${existing.worldId}" and ` +
576
+ `world "${worldId}" — each command name must be unique across live roots`,
577
+ { name, roots: [existing.worldId, worldId] },
578
+ );
579
+ }
580
+ warnOnce(warnedCommands, name, 'command', worldId);
581
+ }
582
+ commands.set(name, {
583
+ description: spec.description,
584
+ argsSchema: spec.args,
585
+ locus: spec.locus,
586
+ fn,
587
+ worldId,
588
+ });
589
+ }
590
+
591
+ function emit(event: string, detail?: unknown): void {
592
+ seqCounter += 1;
593
+ ring.push({ tick: opts.getTick(), simT: opts.getSimT(), event, detail, seq: seqCounter });
594
+ if (ring.length > RING_CAP) ring.shift();
595
+ }
596
+
597
+ /** See `DebugAdapter.events`'s doc comment (`adapter/system-adapter.ts`)
598
+ * for the full contract — `seq` is the ONLY fence (the defective
599
+ * `sinceTick` filter was removed). */
600
+ function events(sinceSeq?: number): TickStampedEvent[] {
601
+ if (sinceSeq === undefined) return ring.slice();
602
+ return ring.filter((e) => e.seq > sinceSeq);
603
+ }
604
+
605
+ /**
606
+ * Task 2.2 — server-locus command routing (client leg). A `locus: 'server'`
607
+ * command never calls its own registered `fn` locally: `invoke()` instead
608
+ * sends the reserved room message `__vgai:debugCommand` and resolves on the
609
+ * matching `__vgai:debugCommandResult` reply, so a fixture mutates the
610
+ * AUTHORITATIVE (server) copy of state, not client prediction. Request ids
611
+ * are a monotonic per-registry counter (`dbg-<n>`), not `Math.random`/
612
+ * `Date.now`, so two in-flight commands never collide and correlation is
613
+ * trivially inspectable in logs.
614
+ */
615
+ function invokeServerCommand(name: string, args: unknown[]): Promise<unknown> {
616
+ if (!attachedRoom) {
617
+ throw new DebugError(
618
+ 'DEBUG_COMMAND_FAILED',
619
+ `debug: command "${name}" is locus:'server' but no Colyseus room is attached ` +
620
+ '(call ctx.debug.attachRoom(room) once your game joins its room)',
621
+ { reason: 'no room connection' },
622
+ );
623
+ }
624
+ const requestId = `dbg-${++requestCounter}`;
625
+ const room = attachedRoom;
626
+ return new Promise((resolve, reject) => {
627
+ const timer = setTimeout(() => {
628
+ pendingServerCommands.delete(requestId);
629
+ reject(
630
+ new DebugError(
631
+ 'DEBUG_COMMAND_FAILED',
632
+ `debug: command "${name}" (requestId "${requestId}") timed out waiting for the server`,
633
+ { reason: 'server timeout' },
634
+ ),
635
+ );
636
+ }, SERVER_COMMAND_TIMEOUT_MS);
637
+ pendingServerCommands.set(requestId, { resolve, reject, timer });
638
+ room.send('__vgai:debugCommand', { name, args, requestId });
639
+ });
640
+ }
641
+
642
+ // Defect 8 fix — the live detach() for whatever room is CURRENTLY attached
643
+ // (or null if none). `attachRoom` calls this itself before attaching a new
644
+ // room: without it, a second `attachRoom` call (no explicit `detach()` in
645
+ // between) left the first room's `__vgai:debugCommandResult` subscription
646
+ // live forever (a leak — the old room keeps getting messages dispatched to
647
+ // a handler nothing reads anymore) and stranded any of ITS in-flight
648
+ // commands riding the full 10s timeout with no way to ever be answered.
649
+ let detachCurrentRoom: (() => void) | null = null;
650
+
651
+ /** {@link DebugCtxSurface.attachRoom} — shared across every world's ctx
652
+ * surface (game-scoped, last-attached room wins — and, since Defect 8,
653
+ * actually CLEANS UP the previous attachment rather than merely
654
+ * overwriting the pointer). Subscribes to the reserved
655
+ * `__vgai:debugCommandResult` reply and resolves/rejects the matching
656
+ * in-flight {@link invokeServerCommand} promise by `requestId`. */
657
+ function attachRoom(room: DebugRoomHandle): () => void {
658
+ // Last-wins, but with cleanup: detach whatever room was attached before
659
+ // (unsubscribes its listener, rejects ITS in-flight pendings — see
660
+ // `detach` below) rather than leaking it.
661
+ detachCurrentRoom?.();
662
+
663
+ attachedRoom = room;
664
+ const unsubscribe = room.onMessage('__vgai:debugCommandResult', (message) => {
665
+ const reply = message as
666
+ | { requestId?: string; ok?: boolean; result?: unknown; error?: unknown }
667
+ | undefined;
668
+ const requestId = reply?.requestId;
669
+ if (requestId === undefined) return;
670
+ const pending = pendingServerCommands.get(requestId);
671
+ if (!pending) return;
672
+ pendingServerCommands.delete(requestId);
673
+ clearTimeout(pending.timer);
674
+ if (reply?.ok) {
675
+ pending.resolve(reply.result);
676
+ } else {
677
+ pending.reject(
678
+ new DebugError(
679
+ 'DEBUG_COMMAND_FAILED',
680
+ `debug: server command failed: ${String(reply?.error)}`,
681
+ { cause: reply?.error },
682
+ ),
683
+ );
684
+ }
685
+ });
686
+
687
+ const detach = (): void => {
688
+ // Idempotent, and a no-op if a LATER attachRoom already superseded
689
+ // this attachment (its own detach ran first via detachCurrentRoom?.()
690
+ // above) — calling this stale detach again must not clobber the new
691
+ // attachment's state.
692
+ if (detachCurrentRoom !== detach) return;
693
+ detachCurrentRoom = null;
694
+ if (attachedRoom === room) attachedRoom = null;
695
+ if (typeof unsubscribe === 'function') (unsubscribe as () => void)();
696
+ // Defect 8 fix: reject every command still awaiting THIS room's reply
697
+ // right now, rather than let it silently ride out the full 10s
698
+ // `SERVER_COMMAND_TIMEOUT_MS` with no room left to ever answer it.
699
+ for (const [requestId, pending] of pendingServerCommands) {
700
+ clearTimeout(pending.timer);
701
+ pending.reject(
702
+ new DebugError(
703
+ 'DEBUG_COMMAND_FAILED',
704
+ `debug: command (requestId "${requestId}") failed — its room was detached before a reply arrived`,
705
+ { reason: 'no room connection' },
706
+ ),
707
+ );
708
+ }
709
+ pendingServerCommands.clear();
710
+ };
711
+ detachCurrentRoom = detach;
712
+ return detach;
713
+ }
714
+
715
+ const adapter: DebugAdapter = {
716
+ providers() {
717
+ return [...providers.entries()].map(([name, entry]) => ({ name, tier: entry.tier }));
718
+ },
719
+ state(name: string) {
720
+ const entry = providers.get(name);
721
+ if (!entry) {
722
+ throw new DebugError(
723
+ 'STATE_PROVIDER_NOT_FOUND',
724
+ `debug: no state provider registered under "${name}"`,
725
+ {
726
+ registered: [...providers.keys()],
727
+ registrationHint: REGISTRATION_HINT,
728
+ },
729
+ );
730
+ }
731
+ return entry.fn();
732
+ },
733
+ stateAll() {
734
+ const result: Record<string, unknown> = {};
735
+ for (const [name, entry] of providers) {
736
+ try {
737
+ result[name] = entry.fn();
738
+ } catch (err) {
739
+ result[name] = { __error: String(err) };
740
+ }
741
+ }
742
+ return result;
743
+ },
744
+ commands(): DebugCommandInfo[] {
745
+ return [...commands.entries()].map(([name, entry]) => ({
746
+ name,
747
+ description: entry.description,
748
+ argsJsonSchema: entry.argsSchema
749
+ ? z.toJSONSchema(entry.argsSchema, { unrepresentable: 'any' })
750
+ : undefined,
751
+ locus: entry.locus ?? 'client',
752
+ }));
753
+ },
754
+ async invoke(name: string, args: unknown[]): Promise<unknown> {
755
+ const entry = commands.get(name);
756
+ if (!entry) {
757
+ throw new DebugError(
758
+ 'DEBUG_COMMAND_NOT_REGISTERED',
759
+ `debug: no command registered under "${name}"`,
760
+ { registered: [...commands.keys()], registrationHint: REGISTRATION_HINT },
761
+ );
762
+ }
763
+ let parsedArgs: unknown[] = args;
764
+ if (entry.argsSchema) {
765
+ const result = entry.argsSchema.safeParse(args);
766
+ if (!result.success) {
767
+ throw new DebugError(
768
+ 'DEBUG_COMMAND_ARGS_INVALID',
769
+ `debug: command "${name}" received invalid args`,
770
+ { issues: result.error.issues },
771
+ );
772
+ }
773
+ parsedArgs = result.data as unknown[];
774
+ }
775
+ if ((entry.locus ?? 'client') === 'server') {
776
+ return invokeServerCommand(name, parsedArgs);
777
+ }
778
+ try {
779
+ return await entry.fn(...parsedArgs);
780
+ } catch (cause) {
781
+ // `cause` stays for in-process readers; the flattened fields are what
782
+ // survive the wire (see `describeCause`).
783
+ const described = describeCause(cause);
784
+ throw new DebugError(
785
+ 'DEBUG_COMMAND_FAILED',
786
+ `debug: command "${name}" threw: ${described.causeMessage}`,
787
+ { cause, ...described },
788
+ );
789
+ }
790
+ },
791
+ events(sinceSeq?: number) {
792
+ return events(sinceSeq);
793
+ },
794
+ };
795
+
796
+ return {
797
+ adapter,
798
+ forRoot(worldId: string): DebugCtxSurface {
799
+ return {
800
+ registerStateProvider(name, fn, providerOpts) {
801
+ registerStateProvider(worldId, name, fn, providerOpts?.tier ?? 'observable');
802
+ },
803
+ registerCommand(name, spec, fn) {
804
+ // Cast: the public generic (`DebugCtxSurface.registerCommand`,
805
+ // `DebugCommandArgs<T>`) exists purely for the CALLER's inference —
806
+ // internally, every entry is stored/invoked through the same
807
+ // untyped `(...args: unknown[])` shape (`invoke()` parses `args`
808
+ // against `argsSchema` at the seam, not at the type level).
809
+ registerCommand(
810
+ worldId,
811
+ name,
812
+ spec as { description?: string; args?: z.ZodTuple; locus?: 'client' | 'server' },
813
+ fn as (...args: unknown[]) => unknown | Promise<unknown>,
814
+ );
815
+ },
816
+ emit(event, detail) {
817
+ emit(event, detail);
818
+ },
819
+ attachRoom(room) {
820
+ return attachRoom(room);
821
+ },
822
+ };
823
+ },
824
+ strip(worldId?: string) {
825
+ // Defect 2 fix — scoped when `worldId` is given (a single mount's
826
+ // hot-reload re-seed), global otherwise (the whole game going away, or
827
+ // a test's blanket teardown). See this method's interface doc comment.
828
+ for (const [name, entry] of providers) {
829
+ if (entry.builtin) continue;
830
+ if (worldId !== undefined && entry.worldId !== worldId) continue;
831
+ providers.delete(name);
832
+ warnedProviders.delete(name);
833
+ }
834
+ for (const [name, entry] of commands) {
835
+ if (worldId !== undefined && entry.worldId !== worldId) continue;
836
+ commands.delete(name);
837
+ warnedCommands.delete(name);
838
+ }
839
+ },
840
+ setRoomDeclared(declared: boolean) {
841
+ roomDeclared = declared;
842
+ },
843
+ setInputActionsSource(worldId: string, fn: () => { name: string; valueType: string }[]) {
844
+ inputActionsSources.set(worldId, fn);
845
+ },
846
+ setInputTraceSource(worldId: string, fn: () => InputTraceSnapshot) {
847
+ inputTraceSources.set(worldId, fn);
848
+ },
849
+ setVirtualInputTarget(worldId: string, target: DebugVirtualInputTarget) {
850
+ virtualInputTargets.set(worldId, target);
851
+ },
852
+ getVirtualInputTarget(worldId?: string) {
853
+ const resolved = resolveInputRootId(worldId);
854
+ return resolved !== null ? (virtualInputTargets.get(resolved) ?? null) : null;
855
+ },
856
+ setRunTicksTarget(target: RunTicksTarget) {
857
+ runTicksTarget = target;
858
+ },
859
+ getRunTicksTarget() {
860
+ return runTicksTarget;
861
+ },
862
+ registerWorldSettledProbe(probe: () => boolean) {
863
+ worldSettledProbes.add(probe);
864
+ return () => {
865
+ worldSettledProbes.delete(probe);
866
+ };
867
+ },
868
+ worldSettled() {
869
+ for (const probe of worldSettledProbes) {
870
+ try {
871
+ if (!probe()) return false;
872
+ } catch {
873
+ // A throwing probe stalls the driver LOUDLY (the bounded wait's
874
+ // timeout names it) rather than silently un-gating the tick.
875
+ return false;
876
+ }
877
+ }
878
+ return true;
879
+ },
880
+ getGameTick() {
881
+ return opts.getTick();
882
+ },
883
+ };
884
+ }
885
+
886
+ const registryByGame = createGameScopedSlot<DebugRegistry>('debug-registry');
887
+
888
+ /** Called once by `createGame`, right after both the registry and the Game
889
+ * shell object exist, to file the association {@link getDebugRegistry} reads. */
890
+ export function registerDebugRegistry(game: Game, registry: DebugRegistry): void {
891
+ registryByGame.set(game, registry);
892
+ }
893
+
894
+ /** The game-scoped registry backing `game.systemAdapters.debug`, or `null` for
895
+ * a `Game` built without one (there is always one for every `createGame`
896
+ * call — `null` only for a `Game`-shaped stand-in a test builds by hand). */
897
+ export function getDebugRegistry(game: Game): DebugRegistry | null {
898
+ return registryByGame.get(game) ?? null;
899
+ }