@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,483 @@
1
+ /**
2
+ * The standalone in-page debug bridge (`docs/SYNTHETIC-PLAYER-SPEC.md` §3.4,
3
+ * D17/D18; Task 2.1 of `docs/ACCEPTANCE-DRIVER-BUILD-PLAN.md`). Query-gated
4
+ * `window.__vgai`, the same production-protection-lives-in-the-installer
5
+ * pattern `render-control.ts`'s `installRenderControlHarness` established for
6
+ * `?vgai-render=1` (production protection lives HERE, not just at whatever
7
+ * call site invokes this — a caller that calls
8
+ * {@link maybeInstallDebugBridge} unconditionally on every boot still only
9
+ * ever gets a handle when the gate below actually passes).
10
+ *
11
+ * D17: this is door (a) of the seam's three doors — the SAME `DebugAdapter`
12
+ * names/JSON the editor relay (door b) and `@vgai/probe` (door c, which
13
+ * drives door (a) itself over Playwright) all read. D18: the bridge installs
14
+ * only when the page opts in (`?vgai-debug=1`) AND is either a dev build
15
+ * (`import.meta.env.DEV`) or the project's manifest explicitly opts a
16
+ * production build in (`manifest.debug.allowInProduction`, the described
17
+ * field whose runtime reader THIS module is).
18
+ */
19
+
20
+ import type { DebugAdapter, TickStampedEvent } from '../adapter/system-adapter';
21
+ import type { GameLoopLiveness } from '../core/types';
22
+ import { DebugError, type DebugRegistry, type RunTicksOptions } from './debug-registry';
23
+
24
+ /** Query-param name that opts a standalone page into the debug bridge (D18) —
25
+ * mirrors `render-seed.ts`'s `RENDER_MODE_QUERY_PARAM` naming/shape, own
26
+ * const because this is a different call site with a different flag. */
27
+ export const DEBUG_MODE_QUERY_PARAM = 'vgai-debug';
28
+
29
+ /** The subset of `ResolvedGameManifest` the D18 gate reads — typed narrowly
30
+ * (not imported from `manifest/load.ts`) so this module doesn't need the
31
+ * full manifest shape, just the one described field it is the runtime
32
+ * reader for. */
33
+ export interface DebugBridgeManifest {
34
+ readonly debug?: { readonly allowInProduction: boolean } | undefined;
35
+ }
36
+
37
+ /** The bridge's actuation surface — byte-identical method names to
38
+ * `InputManager`'s virtual-action primitives (Task 1.4), reached through
39
+ * `DebugRegistry.getVirtualInputTarget(worldId?)` rather than a direct
40
+ * import. Every method takes an optional trailing `worldId` (D15/T-D15.5,
41
+ * the review-objection-2 fix): omitted, it resolves to the SAME default
42
+ * world the editor relay's `inject-input` case resolves to (one shared
43
+ * resolution function, `debug-registry.ts`'s `resolveInputWorldId`) — never
44
+ * "whichever world's `InputManager` happened to register last". An
45
+ * explicit `worldId` reaches that world's `InputManager` specifically. */
46
+ export interface VgaiDebugInputHandle {
47
+ setVirtualAction(
48
+ action: string,
49
+ value: boolean | number | { x: number; y: number },
50
+ worldId?: string,
51
+ ): { delivered: boolean; reason?: string };
52
+ tapVirtualAction(action: string, worldId?: string): { delivered: boolean; reason?: string };
53
+ clearVirtualActions(worldId?: string): void;
54
+ /**
55
+ * D15/T-D15.5 (`docs/D15-DETERMINISM-DESIGN.md` §2.c): schedule a virtual
56
+ * actuation for a specific tick — applied at the START of that tick's
57
+ * input phase, composing with `runTicks` (a schedule for tick 500 fires
58
+ * exactly once the sim has been driven through tick 500, regardless of
59
+ * burst size). A digital `true` produces a genuine `isJustPressed` edge
60
+ * exactly at the target tick. Throws `INPUT_ACTION_NOT_FOUND`/a valueType
61
+ * mismatch (same as `setVirtualAction`) or `TICK_ALREADY_PASSED`
62
+ * (`data.currentTick`, the NEXT tick to be serviced) for a tick that
63
+ * already elapsed.
64
+ *
65
+ * A scheduled tick whose input phase never runs at all (that world
66
+ * paused/frozen, or skipped by a multi-tick gap, when the target tick
67
+ * would have been serviced) DROPS the actuation rather than applying it
68
+ * late — it is never delivered, and there is no `{delivered: false,
69
+ * reason}`-shaped result to read here (the call already returned, long
70
+ * before the drop happens). The only observable trace is an
71
+ * `'input.schedule.dropped'` debug event (`{tick, action}`), readable via
72
+ * `state()`/`stateAll()`'s `events` or `snapshot().events`.
73
+ */
74
+ scheduleActionAtTick(
75
+ tick: number,
76
+ action: string,
77
+ value: boolean | number | { x: number; y: number },
78
+ worldId?: string,
79
+ ): void;
80
+ /** Start (or restart) recording the post-gate action-delta trace, readable
81
+ * back via `state('input.trace')`/`stateAll()` (a built-in provider —
82
+ * itself resolved against the DEFAULT world only; see that provider's own
83
+ * doc comment in `debug-registry.ts`). */
84
+ startRecording(worldId?: string): void;
85
+ /** Stop recording — the trace accumulated so far stays readable. */
86
+ stopRecording(worldId?: string): void;
87
+ /** Whether a recording is currently active. */
88
+ isRecording(worldId?: string): boolean;
89
+ /** Accumulate a synthetic pointer delta for a named test source — mirrors
90
+ * `InputManager.injectPointerDelta` (the "injected test input" seam):
91
+ * multiple calls within the same frame SUM, and the accumulator clears
92
+ * each frame (`endFrame`). Bindings with `valueType: 'pointerDelta'`
93
+ * reading this source (`{ type: 'test_pointer_delta', sourceId }`) see the
94
+ * accumulated value via `getPointerDelta`. */
95
+ injectPointerDelta(sourceId: string, delta: { x: number; y: number }, worldId?: string): void;
96
+ /** Set a synthetic absolute pointer position for a named test source —
97
+ * mirrors `InputManager.injectPointerPosition`: LAST-WRITE-WINS across
98
+ * contributing sources on read, and persists until changed. Bindings with
99
+ * `valueType: 'pointerPosition'` reading this source (`{ type:
100
+ * 'test_pointer_position', sourceId }`) see it via `getPointerPosition`. */
101
+ injectPointerPosition(sourceId: string, value: { x: number; y: number }, worldId?: string): void;
102
+ }
103
+
104
+ /** `snapshot()`'s return shape — ONE synchronous pass over the registry, so
105
+ * every field reflects the exact same instant (AC-B1.2's batched-read
106
+ * primitive: a probe polling this never sees `time` from one tick and
107
+ * `state` from another). */
108
+ export interface VgaiDebugSnapshot {
109
+ /** `loopLiveness` (issue #175): the REAL `GameLoop.liveness` behind this
110
+ * session — `'hidden-paused'` while the T2.1 idle throttle has stopped
111
+ * the loop (tab hidden), `null` only when no loop is wired at all (a
112
+ * bare debug-registry test stand-in with no real `Game`). Never a
113
+ * fabricated `'running'`. */
114
+ time: { simSeconds: number; tick: number; loopLiveness: GameLoopLiveness | null };
115
+ state: Record<string, unknown>;
116
+ events: TickStampedEvent[];
117
+ pageErrors: string[];
118
+ }
119
+
120
+ /** The frozen `window.__vgai` shape (D18/spec §3.4) — version it if it ever
121
+ * needs a breaking change; `providers`/`state`/`stateAll`/`commands`/`events`
122
+ * are straight off the game's `DebugAdapter`, `invoke` re-checks D18 gating
123
+ * (belt-and-braces, AC-A1.7), and `input`/`snapshot` are bridge-only. */
124
+ export interface VgaiDebugHandle {
125
+ readonly version: 1;
126
+ providers: DebugAdapter['providers'];
127
+ state: DebugAdapter['state'];
128
+ stateAll: DebugAdapter['stateAll'];
129
+ commands: DebugAdapter['commands'];
130
+ invoke(name: string, args: unknown[]): Promise<unknown>;
131
+ events: DebugAdapter['events'];
132
+ input: VgaiDebugInputHandle;
133
+ /** See `DebugAdapter.events`'s doc comment for `sinceTick`/`sinceSeq`'s
134
+ * contract (run-4 friction #5) — `snapshot`'s `events` member is filtered
135
+ * the exact same way, just batched with `time`/`state`/`pageErrors` into
136
+ * one synchronous read. */
137
+ snapshot(sinceTick?: number, sinceSeq?: number): VgaiDebugSnapshot;
138
+ /**
139
+ * D15/T-D15.4 door (a) (`docs/D15-DETERMINISM-DESIGN.md` §2.b): synchronously
140
+ * drive `n` fixed gameplay ticks via the live `Game`'s `GameInternal.runTicks`
141
+ * (`runtime/game.ts`) — reached through `DebugRegistry.getRunTicksTarget()`,
142
+ * the SAME target the editor relay's `run-ticks` case (→ `play.runTicks`,
143
+ * door b) calls, so behavior is byte-identical across every door (D17).
144
+ * Throws `DEBUG_RUN_TICKS_UNAVAILABLE` if no `Game` has wired a target yet
145
+ * (no world mounted); throws whatever `runTicks` itself throws otherwise
146
+ * (e.g. `RUN_TICKS_PAUSED`) — never a silent no-op.
147
+ */
148
+ runTicks(n: number, opts?: RunTicksOptions): void;
149
+ /**
150
+ * Collapses `input.setVirtualAction(action, true)` → wait `simSeconds` of
151
+ * REAL sim time (real ticks — deliberately NOT `runTicks`/fast-forward;
152
+ * this is the honest human-input-path proof) → `input.clearVirtualActions()`
153
+ * into ONE async bridge call, as a TOP-LEVEL method (not under `input`)
154
+ * because it needs the sim clock, not just the input target. Was 15+
155
+ * transport round trips over the editor relay (`@vgai/probe`'s old
156
+ * `GameInput.hold`: set → a 150ms-interval `waitSimTime` snapshot poll loop
157
+ * → clear); now one `page.evaluate`/relay call that runs the wait
158
+ * in-process on the page.
159
+ *
160
+ * Resolves the world's input target exactly like the other `input.*`
161
+ * methods (`worldId` omitted → the same default-world resolution every
162
+ * door on this seam shares). A gated actuation (the initial
163
+ * `setVirtualAction` reports `delivered:false`) clears immediately and
164
+ * resolves that SAME `{delivered:false, reason}` shape WITHOUT waiting —
165
+ * matching `setVirtualAction`'s own gated-result contract. If the live
166
+ * game's sim clock stalls while waiting (play stopped/paused mid-hold),
167
+ * the action is still cleared (never left stuck) and this resolves
168
+ * `{delivered:false, reason:'play stopped during hold'}` instead of
169
+ * hanging forever. Throws `DEBUG_INPUT_UNAVAILABLE`/
170
+ * `DEBUG_INPUT_WORLD_NOT_FOUND` up front, same as `input.setVirtualAction`,
171
+ * when no target is wired/resolvable at all.
172
+ */
173
+ holdFor(
174
+ action: string,
175
+ simSeconds: number,
176
+ worldId?: string,
177
+ ): Promise<{ delivered: boolean; reason?: string }>;
178
+ /**
179
+ * Defect 5 fix — undo everything THIS install did: remove the
180
+ * `error`/`unhandledrejection` window listeners it added, and delete
181
+ * `window.__vgai` so the registry is no longer reachable from the page.
182
+ * Idempotent (a second call is a harmless no-op). `mountManifestWorlds`
183
+ * wires this into its session's `stop()`; a caller mounting more
184
+ * directly (a hand-rolled host) should call it on its own teardown path.
185
+ */
186
+ uninstall(): void;
187
+ }
188
+
189
+ /** Structural `window` surface this module needs — just enough to publish
190
+ * the handle and listen for page errors, typed narrowly (no `any`, no DOM
191
+ * lib dependency beyond what every other runtime module already assumes). */
192
+ export interface DebugBridgeWindowTarget {
193
+ addEventListener?(
194
+ type: 'error' | 'unhandledrejection',
195
+ listener: (event: { message?: string; error?: unknown; reason?: unknown }) => void,
196
+ ): void;
197
+ /** Defect 5 fix — the installer's `uninstall()` calls this (when present)
198
+ * to remove the exact listener references it added via `addEventListener`
199
+ * above. Optional, like `addEventListener`, so a minimal test/host stub
200
+ * that never needs to uninstall can omit it. */
201
+ removeEventListener?(
202
+ type: 'error' | 'unhandledrejection',
203
+ listener: (event: { message?: string; error?: unknown; reason?: unknown }) => void,
204
+ ): void;
205
+ [key: string]: unknown;
206
+ }
207
+
208
+ export interface MaybeInstallDebugBridgeOptions {
209
+ /** The game-scoped registry (`getDebugRegistry(session.game)`) — its
210
+ * `.adapter` backs every read/invoke method on the published handle, and
211
+ * its `getVirtualInputTarget()` backs `input.*`. */
212
+ readonly registry: DebugRegistry;
213
+ readonly manifest: DebugBridgeManifest;
214
+ /** Where to read `?vgai-debug=1` from. Defaults to `window.location` when a
215
+ * real `window` exists; a headless caller with no `window` at all (and no
216
+ * override) gets no bridge — there's nowhere to read a URL from. */
217
+ readonly url?: { readonly search: string } | undefined;
218
+ /** Where to publish the handle and install the page-error listeners.
219
+ * Defaults to the real `window`; override in a unit test to avoid
220
+ * touching (or requiring) the global object. */
221
+ readonly window?: DebugBridgeWindowTarget | undefined;
222
+ }
223
+
224
+ const PAGE_ERROR_CAP = 100;
225
+
226
+ /** `holdFor`'s in-process poll interval — this loop never leaves the page
227
+ * (no transport round trip per poll, unlike `@vgai/probe`'s old
228
+ * `waitSimTime`), so it can afford to be tighter than that loop's 150ms. */
229
+ const HOLD_FOR_POLL_MS = 50;
230
+
231
+ /** Consecutive `HOLD_FOR_POLL_MS` polls with the tick unchanged before
232
+ * `holdFor` gives up waiting and treats the game as stopped — mirrors
233
+ * `@vgai/probe`'s `WAIT_FOR_STALL_POLL_LIMIT` reasoning (`wait-for.ts`:
234
+ * a genuinely frozen sim clock must never poll forever), scaled to this
235
+ * faster in-process interval so the wall-clock grace period (~5s) lands in
236
+ * the same neighborhood. */
237
+ const HOLD_FOR_STALL_POLL_LIMIT = 100;
238
+
239
+ /** Resolves once `simSeconds` of sim time has elapsed since the call (normal
240
+ * host-loop ticks while visible; deterministic `runTicks` through the same
241
+ * game phases when the host loop reports `hidden-paused`), or once the tick
242
+ * has stopped changing for
243
+ * `HOLD_FOR_STALL_POLL_LIMIT` consecutive polls (`stalled: true` — the loop
244
+ * driving ticks stopped, e.g. play was stopped/paused). Never rejects.
245
+ * Exported (not just used by `holdFor` below) so the editor relay's
246
+ * `command-listener.ts` `holdFor` case can share the EXACT same
247
+ * poll/stall/hidden-drive logic against its own `DebugAdapter` (reached via
248
+ * `getActiveSystems().debug` rather than a `DebugRegistry`) — D17:
249
+ * byte-identical behavior across doors, not two hand-copies that can
250
+ * silently drift apart. */
251
+ export function waitForHoldBudget(
252
+ adapter: DebugAdapter,
253
+ simSeconds: number,
254
+ driveHiddenTicks?: (n: number) => void,
255
+ ): Promise<{ stalled: boolean }> {
256
+ return new Promise((resolve) => {
257
+ const start = adapter.state('time') as {
258
+ simSeconds: number;
259
+ tick: number;
260
+ loopLiveness?: GameLoopLiveness | null;
261
+ };
262
+ let lastTick = start.tick;
263
+ let stalledPolls = 0;
264
+ const poll = () => {
265
+ const current = adapter.state('time') as {
266
+ simSeconds: number;
267
+ tick: number;
268
+ loopLiveness?: GameLoopLiveness | null;
269
+ };
270
+ if (current.simSeconds - start.simSeconds >= simSeconds) {
271
+ resolve({ stalled: false });
272
+ return;
273
+ }
274
+ if (current.loopLiveness === 'hidden-paused' && driveHiddenTicks) {
275
+ // One poll represents HOLD_FOR_POLL_MS of requested simulation
276
+ // progress. Keep this deterministic instead of translating scheduler
277
+ // jitter through a wall-clock read into a different tick count.
278
+ const ticks = Math.max(1, Math.round(HOLD_FOR_POLL_MS / (1000 / 60)));
279
+ driveHiddenTicks(ticks);
280
+ setTimeout(poll, HOLD_FOR_POLL_MS);
281
+ return;
282
+ }
283
+ stalledPolls = current.tick === lastTick ? stalledPolls + 1 : 0;
284
+ lastTick = current.tick;
285
+ if (stalledPolls >= HOLD_FOR_STALL_POLL_LIMIT) {
286
+ resolve({ stalled: true });
287
+ return;
288
+ }
289
+ setTimeout(poll, HOLD_FOR_POLL_MS);
290
+ };
291
+ setTimeout(poll, HOLD_FOR_POLL_MS);
292
+ });
293
+ }
294
+
295
+ function hasRealWindow(): boolean {
296
+ return typeof window !== 'undefined';
297
+ }
298
+
299
+ function isDebugModeRequested(url: { readonly search: string }): boolean {
300
+ return new URLSearchParams(url.search).get(DEBUG_MODE_QUERY_PARAM) === '1';
301
+ }
302
+
303
+ /** D18's gate: a dev build, or a production build the manifest explicitly
304
+ * opted in. Read fresh on every call (not cached at install time) so
305
+ * `invoke()`'s belt-and-braces re-check (AC-A1.7) is a genuine second
306
+ * evaluation, not a rubber stamp of a value computed once at install. */
307
+ function isDebugAllowed(manifest: DebugBridgeManifest): boolean {
308
+ return Boolean(import.meta.env?.DEV) || manifest.debug?.allowInProduction === true;
309
+ }
310
+
311
+ function buildDebugHandle(opts: {
312
+ registry: DebugRegistry;
313
+ manifest: DebugBridgeManifest;
314
+ window: DebugBridgeWindowTarget;
315
+ }): VgaiDebugHandle {
316
+ const { registry, manifest } = opts;
317
+ const adapter = registry.adapter;
318
+
319
+ const pageErrors: string[] = [];
320
+ function pushPageError(message: string): void {
321
+ pageErrors.push(message);
322
+ if (pageErrors.length > PAGE_ERROR_CAP) pageErrors.shift();
323
+ }
324
+ // Named (not inline-anonymous) so `uninstall()` below can pass the exact
325
+ // same reference to `removeEventListener` (Defect 5 fix).
326
+ const onWindowError = (event: { message?: string; error?: unknown }) => {
327
+ pushPageError(event.message || String(event.error));
328
+ };
329
+ const onWindowRejection = (event: { reason?: unknown }) => {
330
+ pushPageError(`Unhandled promise rejection: ${String(event.reason)}`);
331
+ };
332
+ opts.window.addEventListener?.('error', onWindowError);
333
+ opts.window.addEventListener?.('unhandledrejection', onWindowRejection);
334
+
335
+ function requireInputTarget(method: string, worldId?: string) {
336
+ const target = registry.getVirtualInputTarget(worldId);
337
+ if (!target) {
338
+ throw new DebugError(
339
+ 'DEBUG_INPUT_UNAVAILABLE',
340
+ `debug bridge: ${method}() has no virtual-input target wired — no default threejs ` +
341
+ "world has mounted yet, or this project's mount path never wired one",
342
+ );
343
+ }
344
+ return target;
345
+ }
346
+
347
+ return {
348
+ version: 1,
349
+ // None of these `DebugAdapter` methods reference `this` (they close over
350
+ // the registry's own private maps) — passing the references directly is
351
+ // safe and avoids five redundant wrapper closures.
352
+ providers: adapter.providers,
353
+ state: adapter.state,
354
+ stateAll: adapter.stateAll,
355
+ commands: adapter.commands,
356
+ events: adapter.events,
357
+ async invoke(name, args) {
358
+ // Belt-and-braces D18 re-check (AC-A1.7): the outer install gate
359
+ // already required this to be true, but a caller could hold onto this
360
+ // handle across an environment/manifest change — refuse loudly rather
361
+ // than trust a decision made once at install time.
362
+ if (!isDebugAllowed(manifest)) {
363
+ throw new DebugError(
364
+ 'DEBUG_COMMANDS_UNSUPPORTED',
365
+ `debug: command "${name}" invocation refused — production build without ` +
366
+ 'debug.allowInProduction (D18)',
367
+ { name },
368
+ );
369
+ }
370
+ return adapter.invoke(name, args);
371
+ },
372
+ input: {
373
+ setVirtualAction: (action, value, worldId) =>
374
+ requireInputTarget('setVirtualAction', worldId).setVirtualAction(action, value),
375
+ tapVirtualAction: (action, worldId) =>
376
+ requireInputTarget('tapVirtualAction', worldId).tapVirtualAction(action),
377
+ clearVirtualActions: (worldId) =>
378
+ requireInputTarget('clearVirtualActions', worldId).clearVirtualActions(),
379
+ scheduleActionAtTick: (tick, action, value, worldId) =>
380
+ requireInputTarget('scheduleActionAtTick', worldId).scheduleActionAtTick(
381
+ tick,
382
+ action,
383
+ value,
384
+ ),
385
+ startRecording: (worldId) =>
386
+ requireInputTarget('startRecording', worldId).startInputRecording(),
387
+ stopRecording: (worldId) => requireInputTarget('stopRecording', worldId).stopInputRecording(),
388
+ isRecording: (worldId) => requireInputTarget('isRecording', worldId).isInputRecording(),
389
+ injectPointerDelta: (sourceId, delta, worldId) =>
390
+ requireInputTarget('injectPointerDelta', worldId).injectPointerDelta(sourceId, delta),
391
+ injectPointerPosition: (sourceId, value, worldId) =>
392
+ requireInputTarget('injectPointerPosition', worldId).injectPointerPosition(sourceId, value),
393
+ },
394
+ snapshot(sinceTick, sinceSeq) {
395
+ // One synchronous pass — see VgaiDebugSnapshot's doc comment.
396
+ return {
397
+ time: adapter.state('time') as {
398
+ simSeconds: number;
399
+ tick: number;
400
+ loopLiveness: GameLoopLiveness | null;
401
+ },
402
+ state: adapter.stateAll(),
403
+ events: adapter.events(sinceTick, sinceSeq),
404
+ pageErrors: pageErrors.slice(),
405
+ };
406
+ },
407
+ runTicks(n, runTicksOpts) {
408
+ const target = registry.getRunTicksTarget();
409
+ if (!target) {
410
+ throw new DebugError(
411
+ 'DEBUG_RUN_TICKS_UNAVAILABLE',
412
+ 'debug bridge: runTicks() has no run-ticks target wired — no Game has mounted yet',
413
+ );
414
+ }
415
+ target.runTicks(n, runTicksOpts);
416
+ },
417
+ async holdFor(action, simSeconds, worldId) {
418
+ const target = requireInputTarget('holdFor', worldId);
419
+ const setResult = target.setVirtualAction(action, true);
420
+ if (!setResult.delivered) {
421
+ target.clearVirtualActions();
422
+ // `exactOptionalPropertyTypes`: don't write an explicit `reason:
423
+ // undefined` when the gate itself didn't supply one — omit the key
424
+ // entirely rather than assign `undefined` into an optional `string`
425
+ // property.
426
+ return setResult.reason !== undefined
427
+ ? { delivered: false, reason: setResult.reason }
428
+ : { delivered: false };
429
+ }
430
+ const runTicksTarget = registry.getRunTicksTarget();
431
+ const { stalled } = await waitForHoldBudget(
432
+ adapter,
433
+ simSeconds,
434
+ runTicksTarget ? (n) => runTicksTarget.runTicks(n, { render: 'last' }) : undefined,
435
+ );
436
+ target.clearVirtualActions();
437
+ return stalled
438
+ ? { delivered: false, reason: 'play stopped during hold' }
439
+ : { delivered: true };
440
+ },
441
+ uninstall() {
442
+ opts.window.removeEventListener?.('error', onWindowError);
443
+ opts.window.removeEventListener?.('unhandledrejection', onWindowRejection);
444
+ delete opts.window['__vgai'];
445
+ },
446
+ };
447
+ }
448
+
449
+ /**
450
+ * Install `window.__vgai` iff the page opted in (`?vgai-debug=1`) AND D18's
451
+ * gate passes (dev build, or manifest `debug.allowInProduction`). When the
452
+ * param is present but the gate fails, warns once (this single call IS the
453
+ * "once" — there is exactly one install attempt per boot) and installs
454
+ * nothing. Returns the installed handle (mostly useful for tests), or
455
+ * `undefined` when nothing was installed.
456
+ */
457
+ export function maybeInstallDebugBridge(
458
+ opts: MaybeInstallDebugBridgeOptions,
459
+ ): VgaiDebugHandle | undefined {
460
+ const url = opts.url ?? (hasRealWindow() ? window.location : undefined);
461
+ if (!url) return undefined;
462
+ if (!isDebugModeRequested(url)) return undefined;
463
+
464
+ const windowTarget =
465
+ opts.window ?? (hasRealWindow() ? (window as unknown as DebugBridgeWindowTarget) : undefined);
466
+ if (!windowTarget) return undefined;
467
+
468
+ if (!isDebugAllowed(opts.manifest)) {
469
+ // biome-ignore lint/suspicious/noConsole: structured, greppable D18 signal — mirrors debug-registry.ts's own console.warn idiom.
470
+ console.warn(
471
+ '[debug] ?vgai-debug=1 ignored: production build without debug.allowInProduction (D18)',
472
+ );
473
+ return undefined;
474
+ }
475
+
476
+ const handle = buildDebugHandle({
477
+ registry: opts.registry,
478
+ manifest: opts.manifest,
479
+ window: windowTarget,
480
+ });
481
+ windowTarget['__vgai'] = handle;
482
+ return handle;
483
+ }