@vgai/engine 0.2.0 → 0.4.0-canary.20260715.0

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 (123) hide show
  1. package/README.md +3 -1
  2. package/package.json +24 -4
  3. package/schemas/engine-api.json +124 -0
  4. package/schemas/engine-api.md +53 -0
  5. package/schemas/engine-capabilities.json +124 -0
  6. package/schemas/inputmap.schema.json +314 -0
  7. package/schemas/mat.schema.json +286 -0
  8. package/schemas/prefab.schema.json +10148 -0
  9. package/schemas/scn2d.schema.json +475 -0
  10. package/schemas/vgai-game.schema.json +383 -0
  11. package/schemas/vscn.schema.json +11007 -0
  12. package/src/adapter/{world-kind.ts → adapter-surface.ts} +6 -6
  13. package/src/adapter/authoring.ts +77 -0
  14. package/src/adapter/first-party-systems.ts +23 -34
  15. package/src/adapter/game-adapter.ts +8 -8
  16. package/src/adapter/host-context.ts +2 -4
  17. package/src/adapter/index.ts +4 -4
  18. package/src/adapter/system-adapter.ts +88 -22
  19. package/src/adapter/vgai-scene-game-adapter.ts +244 -194
  20. package/src/animation/anim-graph-types.ts +12 -43
  21. package/src/animation/animation-clock.ts +479 -0
  22. package/src/animation/camera-ownership.ts +467 -0
  23. package/src/animation/cinematic-cues.ts +451 -0
  24. package/src/animation/clip-map.ts +41 -0
  25. package/src/animation/gsap-registration.ts +184 -0
  26. package/src/animation/theatre-clock-binding.ts +111 -0
  27. package/src/animation/theatre-director.ts +347 -0
  28. package/src/animation/theatre-object-binding.ts +661 -0
  29. package/src/animation/xstate-animation-binding.ts +436 -0
  30. package/src/animation/xstate-animation-meta.ts +319 -0
  31. package/src/audio/index.ts +39 -7
  32. package/src/audio/tone-clock-binding.ts +98 -0
  33. package/src/audio/tone-context.ts +129 -0
  34. package/src/audio/tone-offline-render.ts +167 -0
  35. package/src/audio/wav-encode.ts +119 -0
  36. package/src/character/cloth-sim.ts +533 -0
  37. package/src/character/spring-chain.ts +307 -0
  38. package/src/core/game-loop.ts +57 -2
  39. package/src/core/seeded-random.ts +161 -0
  40. package/src/core/system-runner.ts +20 -3
  41. package/src/core/types.ts +50 -0
  42. package/src/data/data-asset.ts +167 -0
  43. package/src/data/data-check-core.ts +242 -0
  44. package/src/data/data-ref.ts +145 -0
  45. package/src/data/vite-plugin-data.ts +290 -0
  46. package/src/dev/performance-profiler.ts +213 -0
  47. package/src/dev/webgl-gpu-timer.ts +53 -0
  48. package/src/ecs/component-manager.ts +45 -12
  49. package/src/ecs/game-component.ts +95 -11
  50. package/src/humanoid/bake.operation.ts +326 -0
  51. package/src/humanoid/body.ts +663 -0
  52. package/src/humanoid/clips.ts +149 -0
  53. package/src/humanoid/compose.ts +209 -0
  54. package/src/humanoid/generate.ts +189 -0
  55. package/src/humanoid/index.ts +36 -0
  56. package/src/humanoid/schema.ts +108 -0
  57. package/src/humanoid/skeleton.ts +345 -0
  58. package/src/index.ts +48 -0
  59. package/src/input/input-manager.ts +1886 -33
  60. package/src/input/input-types.ts +158 -3
  61. package/src/input/prompt-labels.ts +122 -0
  62. package/src/input/rebind-controller.ts +105 -0
  63. package/src/input/schema.ts +206 -52
  64. package/src/manifest/index.ts +5 -5
  65. package/src/manifest/load.ts +125 -72
  66. package/src/manifest/schema.ts +362 -255
  67. package/src/react/game-state.tsx +135 -32
  68. package/src/react/root-adapter.tsx +49 -0
  69. package/src/react/unmanaged-root-detector.ts +66 -0
  70. package/src/react/use-data.ts +124 -0
  71. package/src/react/use-selection.tsx +135 -0
  72. package/src/runtime/create-runtime.ts +112 -273
  73. package/src/runtime/debug-bridge.ts +483 -0
  74. package/src/runtime/debug-registry.ts +856 -0
  75. package/src/runtime/game.ts +342 -93
  76. package/src/runtime/gameplay-rng-trap.ts +134 -0
  77. package/src/runtime/input-router.ts +7 -7
  78. package/src/runtime/mount-game.ts +40 -38
  79. package/src/runtime/mount-manifest.ts +169 -37
  80. package/src/runtime/render-audio-control.ts +168 -0
  81. package/src/runtime/render-control.ts +522 -0
  82. package/src/runtime/render-seed.ts +79 -0
  83. package/src/runtime/state-bridge.ts +24 -10
  84. package/src/runtime/types.ts +110 -33
  85. package/src/scene/asset-loaders.ts +10 -36
  86. package/src/scene/asset-paths.ts +0 -2
  87. package/src/scene/asset-ref-check.ts +248 -0
  88. package/src/scene/asset-registry.ts +22 -0
  89. package/src/scene/component-registry.ts +14 -3
  90. package/src/scene/defaults.ts +1 -0
  91. package/src/scene/light-camera-factory.ts +11 -3
  92. package/src/scene/parse.ts +133 -0
  93. package/src/scene/scene-apply.ts +55 -4
  94. package/src/scene/scene-loader.ts +91 -123
  95. package/src/scene/scene-types.ts +0 -1
  96. package/src/scene/schema/animation.ts +30 -79
  97. package/src/scene/schema/entity.ts +20 -0
  98. package/src/scene/schema/index.ts +2 -46
  99. package/src/scene/schema/light.ts +16 -1
  100. package/src/scene/schema/material.ts +96 -91
  101. package/src/scene/schema/scene-file.ts +1 -7
  102. package/src/scene/user-data.ts +22 -10
  103. package/src/setup/setup-renderer.ts +10 -3
  104. package/src/tools/define-tool.ts +191 -0
  105. package/src/world2d/authoring-2d.ts +17 -1
  106. package/src/world2d/collision-2d.ts +1 -1
  107. package/src/world2d/pixi-game-adapter.ts +19 -17
  108. package/src/world2d/scene2d-loader.ts +1 -0
  109. package/src/world2d/types.ts +8 -2
  110. package/src/animation/anim-graph.ts +0 -406
  111. package/src/animation/anim-system.ts +0 -28
  112. package/src/animation/property-track.ts +0 -178
  113. package/src/animation/schema.ts +0 -204
  114. package/src/audio/ambient.ts +0 -300
  115. package/src/audio/impacts.ts +0 -212
  116. package/src/audio/movement.ts +0 -140
  117. package/src/audio/musical.ts +0 -200
  118. package/src/audio/ui-sounds.ts +0 -171
  119. package/src/audio/vehicle.ts +0 -235
  120. package/src/audio/weapons.ts +0 -152
  121. package/src/runtime/scene-ui-bridge.ts +0 -86
  122. package/src/runtime/scene-ui-data.ts +0 -119
  123. package/src/scene/schema/ui.ts +0 -602
@@ -0,0 +1,856 @@
1
+ /**
2
+ * The game-scoped debug/synthetic-player registry (`docs/SYNTHETIC-PLAYER-SPEC.md`
3
+ * §3.1). ONE registry per `Game` root — every world's `ctx.debug` (see
4
+ * `DebugCtxSurface`, `runtime/types.ts`) feeds the SAME registry via
5
+ * {@link DebugRegistry.forWorld}, so a name a game registers is visible (and
6
+ * name-collision-checked) across every world, not just the one that
7
+ * registered it. `createGame` (`runtime/game.ts`) constructs the registry
8
+ * alongside the state bridge and files it in the `Game -> DebugRegistry`
9
+ * WeakMap this module owns; later consumers (the react `useDebugProvider`/
10
+ * `useDebugCommand` hooks, editor panels) reach it via {@link getDebugRegistry}
11
+ * rather than threading it through every call site.
12
+ *
13
+ * Provenance note: a registration's "world" is the MOUNT's adapter id (the
14
+ * `fromSetup(id, ...)` / `VgaiSceneConfig.id` a project already names each
15
+ * world's entry with) rather than the `WorldInstance.id` `registerThreeWorld`
16
+ * assigns — that id isn't known until AFTER `mount()` resolves (`create-
17
+ * runtime.ts` calls `registerThreeWorld` with it only once `mount()` returns),
18
+ * which is after every `setup()`-time registration has already run. For the
19
+ * single-world case (nearly every project today) this is simply `'vgai-scene'`;
20
+ * a multi-world manifest names each world's adapter to match its declared id
21
+ * by convention, so collision messages stay meaningful in practice.
22
+ */
23
+
24
+ import { z } from 'zod';
25
+ import type { DebugAdapter, DebugCommandInfo, TickStampedEvent } from '../adapter/system-adapter';
26
+ import type { GameLoopLiveness } from '../core/types';
27
+ import type { Game } from './game';
28
+ import type { DebugCommandArgs, DebugCtxSurface, DebugRoomHandle } from './types';
29
+
30
+ /** Engine-local error for the debug seam — mirrors the `code` + `data` shape
31
+ * `packages/vgai-sdk/src/errors.ts` uses (no import: the engine does not
32
+ * depend on `@vgai/sdk`). `code` is always machine-readable; nothing reading
33
+ * this error may key off `message` prose. */
34
+ export class DebugError extends Error {
35
+ readonly code: string;
36
+ readonly data?: Record<string, unknown> | undefined;
37
+
38
+ constructor(code: string, message: string, data?: Record<string, unknown>) {
39
+ super(message);
40
+ this.name = 'DebugError';
41
+ this.code = code;
42
+ this.data = data;
43
+ }
44
+ }
45
+
46
+ const REGISTRATION_HINT =
47
+ 'ctx.debug.registerStateProvider(name, fn) — see the template example component';
48
+
49
+ /** Task 2.2 — how long `invoke()` waits for a `locus: 'server'` command's
50
+ * `__vgai:debugCommandResult` reply before failing loudly. */
51
+ const SERVER_COMMAND_TIMEOUT_MS = 10_000;
52
+
53
+ /** The virtual-input surface the debug bridge (`runtime/debug-bridge.ts`)
54
+ * actuates through — the SAME three methods `InputManager` exposes
55
+ * (`setVirtualAction`/`tapVirtualAction`/`clearVirtualActions`, Task 1.4),
56
+ * typed narrowly here so this module never imports `InputManager` itself.
57
+ * Wired once by the hosting adapter (`vgai-scene-game-adapter.ts`, at the
58
+ * same seed spot as `setInputActionsSource`) — absent until then. */
59
+ export interface DebugVirtualInputTarget {
60
+ setVirtualAction(
61
+ action: string,
62
+ value: boolean | number | { x: number; y: number },
63
+ ): { delivered: boolean; reason?: string };
64
+ tapVirtualAction(action: string): { delivered: boolean; reason?: string };
65
+ clearVirtualActions(): void;
66
+ /** D15/T-D15.5 (`docs/D15-DETERMINISM-DESIGN.md` §2.c) — schedule a virtual
67
+ * actuation for a specific future (or current) tick, applied at the start
68
+ * of that tick's input phase (composes with `runTicks`). */
69
+ scheduleActionAtTick(
70
+ tick: number,
71
+ action: string,
72
+ value: boolean | number | { x: number; y: number },
73
+ ): void;
74
+ startInputRecording(): void;
75
+ stopInputRecording(): void;
76
+ isInputRecording(): boolean;
77
+ /** The four legacy named-test-source injectors (`InputManager`'s own),
78
+ * included here so `inject-input`'s non-`action` kinds route through the
79
+ * SAME per-world resolution as everything else on this interface (D15
80
+ * review objection: routing must never depend on registration order). */
81
+ injectAxis(sourceId: string, value: number): void;
82
+ injectVector2(sourceId: string, value: { x: number; y: number }): void;
83
+ injectPointerDelta(sourceId: string, delta: { x: number; y: number }): void;
84
+ injectPointerPosition(sourceId: string, value: { x: number; y: number }): void;
85
+ }
86
+
87
+ /**
88
+ * D15/T-D15.5 — the `input.trace` builtin provider's shape (docs/D15-
89
+ * DETERMINISM-DESIGN.md §2.c's format sketch). `seed`/`fixedDt` are
90
+ * replay-critical metadata a future SP5 consumer needs BESIDE the raw
91
+ * per-tick action deltas (`ticks`, straight off `InputManager.getInputTrace()`)
92
+ * to know what to replay the trace AGAINST — recording deltas alone is not
93
+ * enough to reproduce a run. Both are `null` only when no wiring/no
94
+ * `ctx.random`/no `Game` exists behind this world (never a fabricated 0).
95
+ */
96
+ export interface InputTraceSnapshot {
97
+ version: 1;
98
+ seed: number | null;
99
+ fixedDt: number | null;
100
+ ticks: unknown[];
101
+ }
102
+
103
+ /** `Game.runTicks`'s options — see `GameInternal.runTicks`'s doc comment
104
+ * (`runtime/game.ts`, D15/T-D15.3-.4) for full semantics. Named here (not
105
+ * re-declared per-caller) so the bridge (`runtime/debug-bridge.ts`), the
106
+ * editor relay (`command-listener.ts`'s `run-ticks` case), and
107
+ * `play.runTicks` (`@vgai/sdk`) all reference the SAME type. */
108
+ export interface RunTicksOptions {
109
+ /** `'last'` (default) — skip `preRender`/`render` for every tick except
110
+ * the final one. `'all'` — render every tick. `'none'` — never render,
111
+ * not even the last tick. */
112
+ render?: 'last' | 'all' | 'none';
113
+ }
114
+
115
+ /** The run-ticks actuation surface a live `Game` wires in (see
116
+ * {@link DebugRegistry.setRunTicksTarget}) — just `GameInternal.runTicks`'s
117
+ * signature, typed narrowly here so this module never imports `./game`
118
+ * as a value (only `Game` as a type, already the case above). */
119
+ export interface RunTicksTarget {
120
+ runTicks(n: number, opts?: RunTicksOptions): void;
121
+ }
122
+
123
+ interface PendingServerCommand {
124
+ resolve(result: unknown): void;
125
+ reject(err: unknown): void;
126
+ timer: ReturnType<typeof setTimeout>;
127
+ }
128
+
129
+ /** Provenance `worldId` for every registration made through the react door
130
+ * (`useDebugProvider`/`useDebugCommand`, `packages/engine/src/react/game-
131
+ * state.tsx`) — react roots have no `setup()`/`ctx.debug` of their own
132
+ * (spec §3.1's "react-world registration door" paragraph), so all of them
133
+ * share this one provenance string; a name collision between two DIFFERENT
134
+ * react components is therefore the same-world "replace + warn once" case,
135
+ * never the cross-world throw (which is reserved for a GameComponent world
136
+ * vs. the react door genuinely disagreeing about a name).
137
+ *
138
+ * Defect 7 fix: this used to be the bare string `'react'`, which collided in
139
+ * provenance with a manifest world literally named `'react'` (e.g. a
140
+ * `kind: 'react'` world whose `id` is `"react"`) — `forWorld('react')` and
141
+ * the react door would then be treated as the SAME world for collision
142
+ * purposes, which is wrong (they are genuinely different registrants that
143
+ * happen to share a display name). Namespaced like `'__engine__'` so no
144
+ * real project world id can ever collide with it. */
145
+ const REACT_WORLD_ID = '__react__';
146
+
147
+ interface ProviderEntry {
148
+ fn: () => unknown;
149
+ tier: 'observable' | 'assisted';
150
+ worldId: string;
151
+ builtin: boolean;
152
+ /** Set only by {@link DebugRegistry.registerReactProvider} — lets its
153
+ * disposer remove ITS OWN registration and no one else's (a later mount
154
+ * under the same react worldId may have legitimately replaced it). */
155
+ token?: symbol | undefined;
156
+ }
157
+
158
+ interface CommandEntry {
159
+ description?: string | undefined;
160
+ argsSchema?: z.ZodTuple | undefined;
161
+ locus?: 'client' | 'server' | undefined;
162
+ fn: (...args: unknown[]) => unknown | Promise<unknown>;
163
+ worldId: string;
164
+ /** See {@link ProviderEntry.token}. */
165
+ token?: symbol | undefined;
166
+ }
167
+
168
+ /** What {@link createDebugRegistry} returns — the adapter half (`DebugAdapter`,
169
+ * for `SystemAdapters.debug`) plus the registration/lifecycle surface the
170
+ * hosting adapter (`vgai-scene-game-adapter.ts`) and `createGame` drive. */
171
+ export interface DebugRegistry {
172
+ /** The `SystemAdapters.debug` implementer — one shared instance, seeded
173
+ * onto every world's adapter bag. */
174
+ readonly adapter: DebugAdapter;
175
+ /** Build the `ctx.debug` surface for one world/mount — registrations made
176
+ * through it carry `worldId` as their provenance for collision messages. */
177
+ forWorld(worldId: string): DebugCtxSurface;
178
+ /** Emit from a React root through the same tick-stamped event ring as
179
+ * `ctx.debug.emit`. React roots have no setup context, so
180
+ * `useDebugEmit()` calls this dedicated door instead of reaching through
181
+ * the registry's internal synthetic world id. */
182
+ emitReact(event: string, detail?: unknown): void;
183
+ /**
184
+ * Remove non-built-in registrations (Defect 2 fix — this used to be
185
+ * unconditionally global, which made a per-mount call like `hotReload`'s
186
+ * silently wipe every OTHER live world's registrations and every react-door
187
+ * registration, whose `useEffect` cleanup never re-fires to restore them).
188
+ *
189
+ * - `strip(worldId)` — scoped: removes only providers/commands whose
190
+ * provenance is exactly `worldId` (and clears their warn-once state), so
191
+ * a hot-reloading world re-seeds itself without disturbing anyone else.
192
+ * React-door registrations (provenance `'__react__'`) are never touched
193
+ * by a world-scoped strip.
194
+ * - `strip()` (no id) — the original global behavior: every non-built-in
195
+ * registration is removed, including react-door ones. Intended for "the
196
+ * whole game is going away" (`disposeGame`) or test teardown, not a
197
+ * single mount's warm restart.
198
+ *
199
+ * Either way, the very next registration of a name just removed is silent —
200
+ * there is nothing left to collide with or warn about.
201
+ */
202
+ strip(worldId?: string): void;
203
+ /** Manifest wiring lands in a later wave; until then, call this directly
204
+ * (default `false`) to exercise the locus-required throw. */
205
+ setRoomDeclared(declared: boolean): void;
206
+ /** Wire the built-in `input.actions` provider to a live `InputManager`
207
+ * (T1.2), scoped to `worldId` — lazy, since a world's InputManager doesn't
208
+ * exist yet when the registry is constructed (`createGame`, before any
209
+ * world mounts). Before ANY source is set, `input.actions` reads `[]`;
210
+ * once one or more roots have registered, the built-in `input.actions`
211
+ * provider reads the DEFAULT world's (see {@link resolveInputWorldId}) —
212
+ * same resolution every other per-world seam on this interface uses. */
213
+ setInputActionsSource(worldId: string, fn: () => { name: string; valueType: string }[]): void;
214
+ /** D15/T-D15.5 — wire the built-in `input.trace` provider to a live
215
+ * `InputManager.getInputTrace`, scoped to `worldId` — same lazy-supplier
216
+ * shape/seed spot as {@link setInputActionsSource}, same default-world
217
+ * resolution for the read. Before a source is set, `input.trace` reads
218
+ * `{version: 1, seed: null, fixedDt: null, ticks: []}`. `seed`/`fixedDt`
219
+ * are the two replay-critical metadata fields the design doc's format
220
+ * sketch (§2.c) calls for beside the raw per-tick deltas — the wiring
221
+ * adapter (`vgai-scene-game-adapter.ts`) assembles them from
222
+ * `getSeededRandom(game)?.seed`/`host.game?.loop.fixedDt` alongside
223
+ * `InputManager.getInputTrace()`'s own `{version, ticks}`. An `engine`
224
+ * (package version) stamp remains a KNOWN GAP — no build-time version
225
+ * constant is threaded into the runtime bundle today; a future track
226
+ * adding one should extend this shape, not invent a second trace format. */
227
+ setInputTraceSource(worldId: string, fn: () => InputTraceSnapshot): void;
228
+ /**
229
+ * Wire the debug bridge's actuation methods (`runtime/debug-bridge.ts`) to
230
+ * a live `InputManager` (Task 2.1), scoped to `worldId` — same lazy-
231
+ * supplier shape as {@link setInputActionsSource}, wired at the same seed
232
+ * spot (once per world mount, not once per Game).
233
+ *
234
+ * Fix for a closed-PR review objection: this used to be a SINGLE slot
235
+ * (last-writer-wins across every world that mounted), which could route
236
+ * the debug bridge (`window.__vgai.input.*`, which read this directly) and
237
+ * the editor relay (which reached the default world's `InputManager`
238
+ * through a DIFFERENT accessor, `Game.input`) to two DIFFERENT roots in a
239
+ * multi-world project — the bridge always got whichever world mounted
240
+ * LAST, the relay always got the FIRST/default world. Now every world's
241
+ * target is kept, keyed by `worldId`, and {@link getVirtualInputTarget}
242
+ * resolves ONE of them via {@link resolveInputWorldId} — the SAME
243
+ * resolution the bridge and the relay both call through, so they can never
244
+ * disagree again. An explicit `worldId` reaches that world specifically. */
245
+ setVirtualInputTarget(worldId: string, target: DebugVirtualInputTarget): void;
246
+ /**
247
+ * Resolve and return a virtual-input target: `worldId` given and
248
+ * registered → that world's; omitted → the DEFAULT world's (per
249
+ * {@link resolveInputWorldId} — the manifest's first/default world when a
250
+ * `Game` is behind this registry, else the single registered world, else
251
+ * whichever registered first), consistently, for every caller (the debug
252
+ * bridge and the editor relay both call this — see this interface's own
253
+ * doc comment above). `null` when nothing is registered for the resolved
254
+ * id at all (callers throw a structured `DEBUG_INPUT_UNAVAILABLE` in that
255
+ * case rather than silently no-op-ing). Throws `DebugError`
256
+ * (`DEBUG_INPUT_WORLD_NOT_FOUND`) for an EXPLICIT `worldId` that was never
257
+ * registered — a caller mistake, distinct from "nothing mounted yet".
258
+ */
259
+ getVirtualInputTarget(worldId?: string): DebugVirtualInputTarget | null;
260
+ /**
261
+ * D15/T-D15.4: wire `Game.runTicks` (`runtime/game.ts`) as the run-ticks
262
+ * actuation target — called ONCE by `createGame`, immediately (unlike
263
+ * {@link setVirtualInputTarget}, which waits for a per-world mount, a
264
+ * `Game`'s own `runTicks` exists the instant the Game shell does). The
265
+ * SAME target backs `runtime/debug-bridge.ts`'s `window.__vgai.runTicks`
266
+ * (door a) and the editor relay's `run-ticks` case → `play.runTicks`
267
+ * (door b) — one implementation, byte-identical semantics across doors
268
+ * (D17).
269
+ */
270
+ setRunTicksTarget(target: RunTicksTarget): void;
271
+ /** The target {@link setRunTicksTarget} last set, or `null` before any
272
+ * `Game` has wired one (a bare `createDebugRegistry()` test stand-in with
273
+ * no `createGame` behind it). Consumers throw a structured "unavailable"
274
+ * error in that case rather than silently no-op-ing — see
275
+ * `debug-bridge.ts`'s `runTicks` method. */
276
+ getRunTicksTarget(): RunTicksTarget | null;
277
+ /** D15/T-D15.3/.5 — the CURRENT shared game tick (the same counter the
278
+ * built-in `time` provider's `tick` field reads), for a per-world
279
+ * `InputManager.poll(tick)` call to key its `scheduleActionAtTick`
280
+ * numbering off — see `vgai-scene-game-adapter.ts`'s `systems.add('input',
281
+ * ...)` wiring. `0` for a bare `createDebugRegistry()` test stand-in with
282
+ * no real `Game`/tick counter behind it (matching `getTick`'s own
283
+ * constructor-supplied default in that case). */
284
+ getGameTick(): number;
285
+ /** React-door registration (Task 1.5, spec §3.1's "react-world
286
+ * registration door") — same accumulator as `forWorld(...)
287
+ * .registerStateProvider`, provenance `'__react__'` (see {@link REACT_WORLD_ID}),
288
+ * but returns a disposer instead of requiring a separate unregister call.
289
+ * Calling the disposer removes the registration ONLY if it is still the
290
+ * live entry under `name` — unmount never clobbers a DIFFERENT mount's
291
+ * later registration of the same name, and (matching `strip()`) removal
292
+ * is silent: the very next registration of that name has nothing to warn
293
+ * about. */
294
+ registerReactProvider(
295
+ name: string,
296
+ fn: () => unknown,
297
+ opts?: { tier?: 'observable' | 'assisted' | undefined },
298
+ ): () => void;
299
+ /** See {@link registerReactProvider} — the command-side counterpart. Same
300
+ * args-tuple-infers-`fn`-params generic as `DebugCtxSurface.registerCommand`
301
+ * ({@link DebugCommandArgs}). */
302
+ registerReactCommand<T extends z.ZodTuple | undefined = undefined>(
303
+ name: string,
304
+ spec: { description?: string; args?: T; locus?: 'client' | 'server' },
305
+ fn: (...args: DebugCommandArgs<T>) => unknown | Promise<unknown>,
306
+ ): () => void;
307
+ }
308
+
309
+ function warnOnce(
310
+ warned: Set<string>,
311
+ name: string,
312
+ kind: 'provider' | 'command',
313
+ worldId: string,
314
+ ): void {
315
+ if (warned.has(name)) return;
316
+ warned.add(name);
317
+ // biome-ignore lint/suspicious/noConsole: structured, greppable — the debug seam's own duplicate-registration signal (spec §3.1)
318
+ console.warn(`[debug] ${kind} "${name}" re-registered (world "${worldId}")`);
319
+ }
320
+
321
+ /**
322
+ * Construct a fresh game-scoped debug registry. `getTick`/`getSimT` are
323
+ * suppliers (not values) so the built-in `time` provider always reads the
324
+ * CURRENT counters — `createGame` passes closures over its own mutable
325
+ * `tick`/`simT`, incremented in `runFrame`'s tail (T1.2). `getDefaultWorldId`
326
+ * (D15/T-D15.5, optional) resolves the manifest's first/default world id —
327
+ * `createGame` passes `() => (roots.length ? requireDefaultWorld().id :
328
+ * null)`; a bare `createDebugRegistry()` test stand-in with no `Game` behind
329
+ * it omits it (per-world resolution then falls back to "the single
330
+ * registered world" or "whichever registered first" — see
331
+ * `resolveInputWorldId`). `getLoopLiveness` (issue #175, optional) supplies
332
+ * the REAL `GameLoop.liveness` — `createGame` passes `() => opts.loop.
333
+ * liveness`; a bare `createDebugRegistry()` test stand-in with no loop
334
+ * behind it omits it, and the built-in `time` provider reports `null`
335
+ * rather than fabricating `'running'` (this module must never claim health
336
+ * it cannot observe, same rule the loop's own liveness getter documents).
337
+ */
338
+ export function createDebugRegistry(opts: {
339
+ getTick(): number;
340
+ getSimT(): number;
341
+ getDefaultWorldId?(): string | null;
342
+ getLoopLiveness?(): GameLoopLiveness;
343
+ }): DebugRegistry {
344
+ const providers = new Map<string, ProviderEntry>();
345
+ const commands = new Map<string, CommandEntry>();
346
+ const warnedProviders = new Set<string>();
347
+ const warnedCommands = new Set<string>();
348
+ const ring: TickStampedEvent[] = [];
349
+ const RING_CAP = 500;
350
+ // Run-4 friction #5 — monotonic, registry-lifetime counter backing
351
+ // `TickStampedEvent.seq`. Never reset, never shared by two events (unlike
352
+ // `tick`, which a debug-command emission and a fenced consumer's snapshot
353
+ // can legitimately collide on — see that field's doc comment in
354
+ // `adapter/system-adapter.ts`).
355
+ let seqCounter = 0;
356
+
357
+ let roomDeclared = false;
358
+ // D15/T-D15.5 — per-world maps (Map preserves insertion order, which
359
+ // `resolveInputWorldId`'s "whichever registered first" fallback relies on
360
+ // when no `getDefaultWorldId` is available to disambiguate).
361
+ const inputActionsSources = new Map<string, () => { name: string; valueType: string }[]>();
362
+ const inputTraceSources = new Map<string, () => InputTraceSnapshot>();
363
+ const virtualInputTargets = new Map<string, DebugVirtualInputTarget>();
364
+ let runTicksTarget: RunTicksTarget | null = null;
365
+ let attachedRoom: DebugRoomHandle | null = null;
366
+ const pendingServerCommands = new Map<string, PendingServerCommand>();
367
+ let requestCounter = 0;
368
+
369
+ /**
370
+ * The ONE resolution function every per-world input seam shares (D15
371
+ * review objection: the debug bridge and the editor relay must never be
372
+ * able to disagree about which world an unqualified actuation targets).
373
+ *
374
+ * - `explicit` given: must be a registered world id, else throws
375
+ * `DEBUG_INPUT_WORLD_NOT_FOUND` (a caller mistake — distinct from
376
+ * "nothing mounted yet", which returns `null` below instead of throwing).
377
+ * - `explicit` omitted: the manifest's first/default world id
378
+ * (`opts.getDefaultWorldId()`) iff that world has actually registered —
379
+ * else (no `Game`/no default resolvable, or the default world never
380
+ * wired one — e.g. a foreign/opaque mount) the single registered world,
381
+ * or, with more than one and no resolvable default, whichever registered
382
+ * FIRST (`Map` insertion order) — the closest analogue to this seam's
383
+ * pre-D15.5 single-slot behavior, but now a stable, principled choice
384
+ * instead of "whichever mounted last".
385
+ * - Nothing registered at all: `null`.
386
+ */
387
+ function resolveInputWorldId(explicit?: string): string | null {
388
+ if (explicit !== undefined) {
389
+ if (!virtualInputTargets.has(explicit)) {
390
+ throw new DebugError(
391
+ 'DEBUG_INPUT_WORLD_NOT_FOUND',
392
+ `debug: no InputManager wired for world "${explicit}" — registered: ` +
393
+ (virtualInputTargets.size ? [...virtualInputTargets.keys()].join(', ') : '(none)'),
394
+ { worldId: explicit, registered: [...virtualInputTargets.keys()] },
395
+ );
396
+ }
397
+ return explicit;
398
+ }
399
+ return resolveWorldForSource(virtualInputTargets.keys());
400
+ }
401
+
402
+ /** The same "default world, else the one registered, else whichever
403
+ * registered first" fallback {@link resolveInputWorldId} uses for the
404
+ * ACTUATION target, generalized over any per-world registration set
405
+ * (`inputActionsSources`/`inputTraceSources` included) — every one of
406
+ * these maps is keyed by the same world ids, populated at the same
407
+ * per-world mount seed spot, so "the default world" means the same thing
408
+ * for all of them. Never throws (no explicit-id case here — the two
409
+ * builtin providers that call this have no way to accept a caller-chosen
410
+ * worldId today; see their own doc comments). */
411
+ function resolveWorldForSource(registered: IterableIterator<string>): string | null {
412
+ const ids = [...registered];
413
+ if (ids.length === 0) return null;
414
+ const defaultId = opts.getDefaultWorldId?.() ?? null;
415
+ if (defaultId !== null && ids.includes(defaultId)) return defaultId;
416
+ return ids[0]!;
417
+ }
418
+
419
+ providers.set('time', {
420
+ // `loopLiveness` (issue #175): `null` when no loop is wired behind this
421
+ // registry (a bare `createDebugRegistry()` test stand-in) — never a
422
+ // fabricated `'running'`. Every real `Game` (`createGame`) wires this,
423
+ // so every live play session reports a real value.
424
+ fn: () => ({
425
+ simSeconds: opts.getSimT(),
426
+ tick: opts.getTick(),
427
+ loopLiveness: opts.getLoopLiveness?.() ?? null,
428
+ }),
429
+ tier: 'observable',
430
+ worldId: '__engine__',
431
+ builtin: true,
432
+ });
433
+ providers.set('input.actions', {
434
+ // Resolves to the DEFAULT world's action list (see `resolveInputWorldId`)
435
+ // — deterministic across every world that registers, rather than the
436
+ // pre-D15.5 "whichever mounted last" behavior.
437
+ fn: () => {
438
+ const worldId = resolveWorldForSource(inputActionsSources.keys());
439
+ return (worldId ? inputActionsSources.get(worldId) : undefined)?.() ?? [];
440
+ },
441
+ tier: 'observable',
442
+ worldId: '__engine__',
443
+ builtin: true,
444
+ });
445
+ // D15/T-D15.5 — the post-gate action-delta trace, readable via a provider.
446
+ // Absent a wired source (no world mounted yet) reads an empty, correctly-
447
+ // versioned trace rather than throwing. Same default-world resolution as
448
+ // `input.actions` immediately above.
449
+ providers.set('input.trace', {
450
+ fn: () => {
451
+ const worldId = resolveWorldForSource(inputTraceSources.keys());
452
+ return (
453
+ (worldId ? inputTraceSources.get(worldId) : undefined)?.() ?? {
454
+ version: 1,
455
+ seed: null,
456
+ fixedDt: null,
457
+ ticks: [],
458
+ }
459
+ );
460
+ },
461
+ tier: 'observable',
462
+ worldId: '__engine__',
463
+ builtin: true,
464
+ });
465
+
466
+ function registerStateProvider(
467
+ worldId: string,
468
+ name: string,
469
+ fn: () => unknown,
470
+ tier: 'observable' | 'assisted',
471
+ token?: symbol,
472
+ ): void {
473
+ const existing = providers.get(name);
474
+ if (existing?.builtin) {
475
+ // Defect 1 fix: a builtin name (`time`, `input.actions`) must never be
476
+ // silently shadowed — the old `!existing.builtin` guard skipped BOTH
477
+ // the collision throw and the warn for this case, so
478
+ // `registerStateProvider('time', ...)` quietly replaced engine truth,
479
+ // and a later `strip()` deleted it outright (leaving NO `time`
480
+ // provider at all post-hot-reload). Throw loudly instead; there is no
481
+ // silent-replace path for a builtin.
482
+ throw new DebugError(
483
+ 'DEBUG_BUILTIN_RESERVED',
484
+ `debug: "${name}" is a built-in state provider (registered by "${existing.worldId}") — ` +
485
+ 'built-in names cannot be registered over, from a world or the react door',
486
+ { name, registered: existing.worldId },
487
+ );
488
+ }
489
+ if (existing) {
490
+ if (existing.worldId !== worldId) {
491
+ throw new DebugError(
492
+ 'DEBUG_NAME_COLLISION',
493
+ `debug: state provider "${name}" is registered by both world "${existing.worldId}" ` +
494
+ `and world "${worldId}" — each provider name must be unique across live roots`,
495
+ { name, roots: [existing.worldId, worldId] },
496
+ );
497
+ }
498
+ warnOnce(warnedProviders, name, 'provider', worldId);
499
+ }
500
+ providers.set(name, { fn, tier, worldId, builtin: false, token });
501
+ }
502
+
503
+ function registerCommand(
504
+ worldId: string,
505
+ name: string,
506
+ spec: { description?: string; args?: z.ZodTuple; locus?: 'client' | 'server' },
507
+ fn: (...args: unknown[]) => unknown | Promise<unknown>,
508
+ token?: symbol,
509
+ ): void {
510
+ if (spec.locus === undefined && roomDeclared) {
511
+ throw new DebugError(
512
+ 'DEBUG_COMMAND_LOCUS_REQUIRED',
513
+ "this project declares a Colyseus room — declare locus: 'client' | 'server' so " +
514
+ 'fixtures mutate authoritative state, not client prediction',
515
+ { name, worldId },
516
+ );
517
+ }
518
+ const existing = commands.get(name);
519
+ if (existing) {
520
+ if (existing.worldId !== worldId) {
521
+ throw new DebugError(
522
+ 'DEBUG_NAME_COLLISION',
523
+ `debug: command "${name}" is registered by both world "${existing.worldId}" and ` +
524
+ `world "${worldId}" — each command name must be unique across live roots`,
525
+ { name, roots: [existing.worldId, worldId] },
526
+ );
527
+ }
528
+ warnOnce(warnedCommands, name, 'command', worldId);
529
+ }
530
+ commands.set(name, {
531
+ description: spec.description,
532
+ argsSchema: spec.args,
533
+ locus: spec.locus,
534
+ fn,
535
+ worldId,
536
+ token,
537
+ });
538
+ }
539
+
540
+ function emit(event: string, detail?: unknown): void {
541
+ seqCounter += 1;
542
+ ring.push({ tick: opts.getTick(), simT: opts.getSimT(), event, detail, seq: seqCounter });
543
+ if (ring.length > RING_CAP) ring.shift();
544
+ }
545
+
546
+ /** See `DebugAdapter.events`'s doc comment (`adapter/system-adapter.ts`)
547
+ * for the full contract — `sinceSeq` (when given) wins over `sinceTick`,
548
+ * since it's the unambiguous one. */
549
+ function events(sinceTick?: number, sinceSeq?: number): TickStampedEvent[] {
550
+ if (sinceSeq !== undefined) return ring.filter((e) => e.seq > sinceSeq);
551
+ if (sinceTick === undefined) return ring.slice();
552
+ return ring.filter((e) => e.tick > sinceTick);
553
+ }
554
+
555
+ /**
556
+ * Task 2.2 — server-locus command routing (client leg). A `locus: 'server'`
557
+ * command never calls its own registered `fn` locally: `invoke()` instead
558
+ * sends the reserved room message `__vgai:debugCommand` and resolves on the
559
+ * matching `__vgai:debugCommandResult` reply, so a fixture mutates the
560
+ * AUTHORITATIVE (server) copy of state, not client prediction. Request ids
561
+ * are a monotonic per-registry counter (`dbg-<n>`), not `Math.random`/
562
+ * `Date.now`, so two in-flight commands never collide and correlation is
563
+ * trivially inspectable in logs.
564
+ */
565
+ function invokeServerCommand(name: string, args: unknown[]): Promise<unknown> {
566
+ if (!attachedRoom) {
567
+ throw new DebugError(
568
+ 'DEBUG_COMMAND_FAILED',
569
+ `debug: command "${name}" is locus:'server' but no Colyseus room is attached ` +
570
+ '(call ctx.debug.attachRoom(room) once your game joins its room)',
571
+ { reason: 'no room connection' },
572
+ );
573
+ }
574
+ const requestId = `dbg-${++requestCounter}`;
575
+ const room = attachedRoom;
576
+ return new Promise((resolve, reject) => {
577
+ const timer = setTimeout(() => {
578
+ pendingServerCommands.delete(requestId);
579
+ reject(
580
+ new DebugError(
581
+ 'DEBUG_COMMAND_FAILED',
582
+ `debug: command "${name}" (requestId "${requestId}") timed out waiting for the server`,
583
+ { reason: 'server timeout' },
584
+ ),
585
+ );
586
+ }, SERVER_COMMAND_TIMEOUT_MS);
587
+ pendingServerCommands.set(requestId, { resolve, reject, timer });
588
+ room.send('__vgai:debugCommand', { name, args, requestId });
589
+ });
590
+ }
591
+
592
+ // Defect 8 fix — the live detach() for whatever room is CURRENTLY attached
593
+ // (or null if none). `attachRoom` calls this itself before attaching a new
594
+ // room: without it, a second `attachRoom` call (no explicit `detach()` in
595
+ // between) left the first room's `__vgai:debugCommandResult` subscription
596
+ // live forever (a leak — the old room keeps getting messages dispatched to
597
+ // a handler nothing reads anymore) and stranded any of ITS in-flight
598
+ // commands riding the full 10s timeout with no way to ever be answered.
599
+ let detachCurrentRoom: (() => void) | null = null;
600
+
601
+ /** {@link DebugCtxSurface.attachRoom} — shared across every world's ctx
602
+ * surface (game-scoped, last-attached room wins — and, since Defect 8,
603
+ * actually CLEANS UP the previous attachment rather than merely
604
+ * overwriting the pointer). Subscribes to the reserved
605
+ * `__vgai:debugCommandResult` reply and resolves/rejects the matching
606
+ * in-flight {@link invokeServerCommand} promise by `requestId`. */
607
+ function attachRoom(room: DebugRoomHandle): () => void {
608
+ // Last-wins, but with cleanup: detach whatever room was attached before
609
+ // (unsubscribes its listener, rejects ITS in-flight pendings — see
610
+ // `detach` below) rather than leaking it.
611
+ detachCurrentRoom?.();
612
+
613
+ attachedRoom = room;
614
+ const unsubscribe = room.onMessage('__vgai:debugCommandResult', (message) => {
615
+ const reply = message as
616
+ | { requestId?: string; ok?: boolean; result?: unknown; error?: unknown }
617
+ | undefined;
618
+ const requestId = reply?.requestId;
619
+ if (requestId === undefined) return;
620
+ const pending = pendingServerCommands.get(requestId);
621
+ if (!pending) return;
622
+ pendingServerCommands.delete(requestId);
623
+ clearTimeout(pending.timer);
624
+ if (reply?.ok) {
625
+ pending.resolve(reply.result);
626
+ } else {
627
+ pending.reject(
628
+ new DebugError(
629
+ 'DEBUG_COMMAND_FAILED',
630
+ `debug: server command failed: ${String(reply?.error)}`,
631
+ { cause: reply?.error },
632
+ ),
633
+ );
634
+ }
635
+ });
636
+
637
+ const detach = (): void => {
638
+ // Idempotent, and a no-op if a LATER attachRoom already superseded
639
+ // this attachment (its own detach ran first via detachCurrentRoom?.()
640
+ // above) — calling this stale detach again must not clobber the new
641
+ // attachment's state.
642
+ if (detachCurrentRoom !== detach) return;
643
+ detachCurrentRoom = null;
644
+ if (attachedRoom === room) attachedRoom = null;
645
+ if (typeof unsubscribe === 'function') (unsubscribe as () => void)();
646
+ // Defect 8 fix: reject every command still awaiting THIS room's reply
647
+ // right now, rather than let it silently ride out the full 10s
648
+ // `SERVER_COMMAND_TIMEOUT_MS` with no room left to ever answer it.
649
+ for (const [requestId, pending] of pendingServerCommands) {
650
+ clearTimeout(pending.timer);
651
+ pending.reject(
652
+ new DebugError(
653
+ 'DEBUG_COMMAND_FAILED',
654
+ `debug: command (requestId "${requestId}") failed — its room was detached before a reply arrived`,
655
+ { reason: 'no room connection' },
656
+ ),
657
+ );
658
+ }
659
+ pendingServerCommands.clear();
660
+ };
661
+ detachCurrentRoom = detach;
662
+ return detach;
663
+ }
664
+
665
+ const adapter: DebugAdapter = {
666
+ providers() {
667
+ return [...providers.entries()].map(([name, entry]) => ({ name, tier: entry.tier }));
668
+ },
669
+ state(name: string) {
670
+ const entry = providers.get(name);
671
+ if (!entry) {
672
+ throw new DebugError(
673
+ 'STATE_PROVIDER_NOT_FOUND',
674
+ `debug: no state provider registered under "${name}"`,
675
+ {
676
+ registered: [...providers.keys()],
677
+ registrationHint: REGISTRATION_HINT,
678
+ },
679
+ );
680
+ }
681
+ return entry.fn();
682
+ },
683
+ stateAll() {
684
+ const result: Record<string, unknown> = {};
685
+ for (const [name, entry] of providers) {
686
+ try {
687
+ result[name] = entry.fn();
688
+ } catch (err) {
689
+ result[name] = { __error: String(err) };
690
+ }
691
+ }
692
+ return result;
693
+ },
694
+ commands(): DebugCommandInfo[] {
695
+ return [...commands.entries()].map(([name, entry]) => ({
696
+ name,
697
+ description: entry.description,
698
+ argsJsonSchema: entry.argsSchema
699
+ ? z.toJSONSchema(entry.argsSchema, { unrepresentable: 'any' })
700
+ : undefined,
701
+ locus: entry.locus ?? 'client',
702
+ }));
703
+ },
704
+ async invoke(name: string, args: unknown[]): Promise<unknown> {
705
+ const entry = commands.get(name);
706
+ if (!entry) {
707
+ throw new DebugError(
708
+ 'DEBUG_COMMAND_NOT_REGISTERED',
709
+ `debug: no command registered under "${name}"`,
710
+ { registered: [...commands.keys()], registrationHint: REGISTRATION_HINT },
711
+ );
712
+ }
713
+ let parsedArgs: unknown[] = args;
714
+ if (entry.argsSchema) {
715
+ const result = entry.argsSchema.safeParse(args);
716
+ if (!result.success) {
717
+ throw new DebugError(
718
+ 'DEBUG_COMMAND_ARGS_INVALID',
719
+ `debug: command "${name}" received invalid args`,
720
+ { issues: result.error.issues },
721
+ );
722
+ }
723
+ parsedArgs = result.data as unknown[];
724
+ }
725
+ if ((entry.locus ?? 'client') === 'server') {
726
+ return invokeServerCommand(name, parsedArgs);
727
+ }
728
+ try {
729
+ return await entry.fn(...parsedArgs);
730
+ } catch (cause) {
731
+ throw new DebugError('DEBUG_COMMAND_FAILED', `debug: command "${name}" threw`, { cause });
732
+ }
733
+ },
734
+ events(sinceTick?: number, sinceSeq?: number) {
735
+ return events(sinceTick, sinceSeq);
736
+ },
737
+ };
738
+
739
+ return {
740
+ adapter,
741
+ forWorld(worldId: string): DebugCtxSurface {
742
+ return {
743
+ registerStateProvider(name, fn, providerOpts) {
744
+ registerStateProvider(worldId, name, fn, providerOpts?.tier ?? 'observable');
745
+ },
746
+ registerCommand(name, spec, fn) {
747
+ // Cast: the public generic (`DebugCtxSurface.registerCommand`,
748
+ // `DebugCommandArgs<T>`) exists purely for the CALLER's inference —
749
+ // internally, every entry is stored/invoked through the same
750
+ // untyped `(...args: unknown[])` shape (`invoke()` parses `args`
751
+ // against `argsSchema` at the seam, not at the type level).
752
+ registerCommand(
753
+ worldId,
754
+ name,
755
+ spec as { description?: string; args?: z.ZodTuple; locus?: 'client' | 'server' },
756
+ fn as (...args: unknown[]) => unknown | Promise<unknown>,
757
+ );
758
+ },
759
+ emit(event, detail) {
760
+ emit(event, detail);
761
+ },
762
+ attachRoom(room) {
763
+ return attachRoom(room);
764
+ },
765
+ };
766
+ },
767
+ emitReact(event, detail) {
768
+ emit(event, detail);
769
+ },
770
+ strip(worldId?: string) {
771
+ // Defect 2 fix — scoped when `worldId` is given (a single mount's
772
+ // hot-reload re-seed), global otherwise (the whole game going away, or
773
+ // a test's blanket teardown). See this method's interface doc comment.
774
+ for (const [name, entry] of providers) {
775
+ if (entry.builtin) continue;
776
+ if (worldId !== undefined && entry.worldId !== worldId) continue;
777
+ providers.delete(name);
778
+ warnedProviders.delete(name);
779
+ }
780
+ for (const [name, entry] of commands) {
781
+ if (worldId !== undefined && entry.worldId !== worldId) continue;
782
+ commands.delete(name);
783
+ warnedCommands.delete(name);
784
+ }
785
+ },
786
+ setRoomDeclared(declared: boolean) {
787
+ roomDeclared = declared;
788
+ },
789
+ setInputActionsSource(worldId: string, fn: () => { name: string; valueType: string }[]) {
790
+ inputActionsSources.set(worldId, fn);
791
+ },
792
+ setInputTraceSource(worldId: string, fn: () => InputTraceSnapshot) {
793
+ inputTraceSources.set(worldId, fn);
794
+ },
795
+ setVirtualInputTarget(worldId: string, target: DebugVirtualInputTarget) {
796
+ virtualInputTargets.set(worldId, target);
797
+ },
798
+ getVirtualInputTarget(worldId?: string) {
799
+ const resolved = resolveInputWorldId(worldId);
800
+ return resolved !== null ? (virtualInputTargets.get(resolved) ?? null) : null;
801
+ },
802
+ setRunTicksTarget(target: RunTicksTarget) {
803
+ runTicksTarget = target;
804
+ },
805
+ getRunTicksTarget() {
806
+ return runTicksTarget;
807
+ },
808
+ getGameTick() {
809
+ return opts.getTick();
810
+ },
811
+ registerReactProvider(name, fn, providerOpts) {
812
+ const token = Symbol(name);
813
+ registerStateProvider(REACT_WORLD_ID, name, fn, providerOpts?.tier ?? 'observable', token);
814
+ return () => {
815
+ const entry = providers.get(name);
816
+ if (entry?.token === token) {
817
+ providers.delete(name);
818
+ warnedProviders.delete(name);
819
+ }
820
+ };
821
+ },
822
+ registerReactCommand(name, spec, fn) {
823
+ const token = Symbol(name);
824
+ // See the `forWorld().registerCommand` cast above — same reason.
825
+ registerCommand(
826
+ REACT_WORLD_ID,
827
+ name,
828
+ spec as { description?: string; args?: z.ZodTuple; locus?: 'client' | 'server' },
829
+ fn as (...args: unknown[]) => unknown | Promise<unknown>,
830
+ token,
831
+ );
832
+ return () => {
833
+ const entry = commands.get(name);
834
+ if (entry?.token === token) {
835
+ commands.delete(name);
836
+ warnedCommands.delete(name);
837
+ }
838
+ };
839
+ },
840
+ };
841
+ }
842
+
843
+ const registryByGame = new WeakMap<Game, DebugRegistry>();
844
+
845
+ /** Called once by `createGame`, right after both the registry and the Game
846
+ * shell object exist, to file the association {@link getDebugRegistry} reads. */
847
+ export function registerDebugRegistry(game: Game, registry: DebugRegistry): void {
848
+ registryByGame.set(game, registry);
849
+ }
850
+
851
+ /** The game-scoped registry backing `game.systemAdapters.debug`, or `null` for
852
+ * a `Game` built without one (there is always one for every `createGame`
853
+ * call — `null` only for a `Game`-shaped stand-in a test builds by hand). */
854
+ export function getDebugRegistry(game: Game): DebugRegistry | null {
855
+ return registryByGame.get(game) ?? null;
856
+ }