@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
@@ -2,25 +2,25 @@
2
2
  // helper: an ENGINE-side generalization of `examples/tri-world/src/main.ts`'s
3
3
  // hand-built `WorldMountSpec[]`.
4
4
  //
5
- // `resolveAllWorlds`/`resolveWorldAdapter` (`packages/editor/src/
5
+ // `resolveAllWorlds`/`resolveRootAdapter` (`packages/editor/src/
6
6
  // adapter-resolver.ts`) already do this translation for the EDITOR, but they
7
7
  // are Vite-coupled by construction (`/@fs/` dynamic imports of project files
8
8
  // through the dev server, §0.1/§0.5 of the design doc) — unusable from a
9
9
  // plain static build. This module mirrors their per-kind dispatch
10
10
  // (`default-three`/`default-pixi`/`default-react`, `{ module }`, `{ ingest }`)
11
11
  // but never imports a project file itself: the caller's OWN bundler already
12
- // resolved whatever module graph the manifest's worlds need, and hands the
12
+ // resolved whatever module graph the manifest's roots need, and hands the
13
13
  // already-built pieces in via `entries` (keyed by world id) — no `/@fs/`, no
14
14
  // dev server, no editor import.
15
15
  //
16
16
  // Engine-core react/pixi-free discipline (§0.6, mirrored from
17
17
  // `create-runtime.ts`'s own documented rule for its `Pixi2DGameAdapter`/
18
- // `ReactWorldAdapter` type-only imports): this file never value-imports
18
+ // `ReactRootAdapter` type-only imports): this file never value-imports
19
19
  // `pixi.js`, `world2d/pixi-game-adapter.ts`, `react`, or `react-dom` — a
20
20
  // pixijs or react world's adapter is ALWAYS supplied already-constructed via
21
21
  // `entries[id].adapter` (the caller's own module graph built it, exactly like
22
22
  // `tri-world`'s original hand-mount built `new PixiSceneGameAdapter(...)` and
23
- // a hand-rolled `ReactWorldAdapter` itself). Threejs is different: `three`/
23
+ // a hand-rolled `ReactRootAdapter` itself). Threejs is different: `three`/
24
24
  // `VgaiSceneGameAdapter` are already unconditional dependencies of every
25
25
  // caller of `createGameRuntime`'s legacy path (this same file's sibling,
26
26
  // immediately below in this directory), so building a `VgaiSceneGameAdapter`
@@ -28,21 +28,25 @@
28
28
  // weight — only pixi/react are avoided.
29
29
 
30
30
  import { fromSetup, VgaiSceneGameAdapter } from '../adapter';
31
+ import { assertNever } from '../adapter/adapter-surface';
31
32
  import type { GameAdapter } from '../adapter/game-adapter';
32
- import { assertNever } from '../adapter/world-kind';
33
33
  import {
34
34
  loadGameManifest,
35
+ type ResolvedAdapterRoot,
35
36
  type ResolvedGameManifest,
36
- type ResolvedWorldEntry,
37
37
  } from '../manifest/load';
38
38
  import type { ComponentRegistry } from '../scene/component-registry';
39
39
  import {
40
40
  createGameRuntime,
41
41
  type GameSession,
42
42
  type Pixi2DGameAdapter,
43
- type ReactWorldAdapter,
43
+ type ReactRootAdapter,
44
44
  type WorldMountSpec,
45
45
  } from './create-runtime';
46
+ import { type DebugBridgeWindowTarget, maybeInstallDebugBridge } from './debug-bridge';
47
+ import { getDebugRegistry } from './debug-registry';
48
+ import { getGameplayRngTrapControl } from './gameplay-rng-trap';
49
+ import { RENDER_SEED_QUERY_PARAM } from './render-seed';
46
50
  import type { GameSetupFn } from './types';
47
51
 
48
52
  // ---------------------------------------------------------------------------
@@ -96,7 +100,7 @@ export interface PixiMountEntry {
96
100
 
97
101
  /**
98
102
  * A caller-supplied entry for a `kind: 'react'` world — ALWAYS a
99
- * fully-constructed {@link ReactWorldAdapter} (the caller's own module graph
103
+ * fully-constructed {@link ReactRootAdapter} (the caller's own module graph
100
104
  * calls `createRoot(host.container).render(<GameProvider game={host.game}>
101
105
  * <Entry/></GameProvider>)` itself, exactly like `tri-world`'s original
102
106
  * hand-mount did). A standalone build has ONE module graph, so there is no
@@ -109,7 +113,7 @@ export interface PixiMountEntry {
109
113
  */
110
114
  export interface ReactMountEntry {
111
115
  readonly kind: 'react';
112
- readonly adapter: ReactWorldAdapter;
116
+ readonly adapter: ReactRootAdapter;
113
117
  }
114
118
 
115
119
  export type MountEntry = ThreeMountEntry | PixiMountEntry | ReactMountEntry;
@@ -143,6 +147,71 @@ export interface MountManifestOptions {
143
147
  /** Forwarded to `createGameRuntime` — Node/headless test harnesses only,
144
148
  * never a real host. See `WorldsRuntimeConfig.headless`. */
145
149
  readonly headless?: boolean | undefined;
150
+ /**
151
+ * Task 2.1 (`docs/ACCEPTANCE-DRIVER-BUILD-PLAN.md`,
152
+ * `docs/SYNTHETIC-PLAYER-SPEC.md` §3.4): overrides for the `?vgai-debug=1`
153
+ * bridge `mountManifestWorlds` installs at the tail of every mount — the
154
+ * engine-owned install point so every standalone project gets it with zero
155
+ * template edits. Omit both in a real host (defaults to `window.location`/
156
+ * `window`); a headless test supplies fakes here instead of touching the
157
+ * global object, mirroring `render-control.ts`'s own `location`/`target`
158
+ * override precedent.
159
+ */
160
+ readonly debugBridge?:
161
+ | {
162
+ readonly url?: { readonly search: string } | undefined;
163
+ readonly window?: DebugBridgeWindowTarget | undefined;
164
+ }
165
+ | undefined;
166
+ /**
167
+ * D15 (T-D15.1) — the "explicit config" leg of the boot-time seed
168
+ * precedence (`manifest.determinism.defaultSeed` → `?vgai-seed=` → this),
169
+ * highest-precedence, for a caller that already knows the exact seed it
170
+ * wants (a future CLI `--seed`/probe fixture `seed` option, T-D15.6).
171
+ * Ignored entirely unless the manifest declares
172
+ * `determinism.seededRandom` — see `resolveDeterminismSeed` below.
173
+ */
174
+ readonly seed?: number | undefined;
175
+ /** Where to read `?vgai-seed=` from for the boot-time seed reader. Same
176
+ * override precedent as `debugBridge.url` (defaults to `window.location`
177
+ * when a real `window` exists; a headless caller with no override gets no
178
+ * query-param seed, same as `maybeInstallDebugBridge`'s own url default). */
179
+ readonly seedUrl?: { readonly search: string } | undefined;
180
+ }
181
+
182
+ /**
183
+ * D15 (T-D15.1) boot-time seed reader — resolves the root seed
184
+ * `mountManifestWorlds` threads into `createGameRuntime` (and therefore
185
+ * `createGame`, BEFORE any world's `mount()`/`setup()` runs). Returns
186
+ * `undefined` when the manifest doesn't declare `determinism.seededRandom`
187
+ * at all — `?vgai-seed=` and `defaultSeed` are both ignored in that case
188
+ * (§2.a: "the mount path seeds ctx.random ... iff declared"; an undeclared
189
+ * project's `ctx.random` still exists, just boots from `createGame`'s own
190
+ * fixed default, unaffected by the manifest or the URL).
191
+ *
192
+ * Precedence when declared (highest wins): `explicitSeed` (a caller-supplied
193
+ * config value) → `?vgai-seed=<int>` on `url` → `manifest.determinism
194
+ * .defaultSeed`.
195
+ */
196
+ export function resolveDeterminismSeed(opts: {
197
+ readonly determinism: ResolvedGameManifest['determinism'];
198
+ readonly explicitSeed: number | undefined;
199
+ readonly url: { readonly search: string } | undefined;
200
+ }): number | undefined {
201
+ if (opts.determinism?.seededRandom !== true) return undefined;
202
+ const querySeed = opts.url ? readSeedQueryParam(opts.url) : undefined;
203
+ return opts.explicitSeed ?? querySeed ?? opts.determinism.defaultSeed;
204
+ }
205
+
206
+ function readSeedQueryParam(url: { readonly search: string }): number | undefined {
207
+ const raw = new URLSearchParams(url.search).get(RENDER_SEED_QUERY_PARAM);
208
+ if (raw === null || raw === '') return undefined;
209
+ const parsed = Number(raw);
210
+ return Number.isFinite(parsed) ? parsed : undefined;
211
+ }
212
+
213
+ function hasRealWindow(): boolean {
214
+ return typeof window !== 'undefined';
146
215
  }
147
216
 
148
217
  // `mountManifestWorlds` returns the SAME `GameSession` shape
@@ -159,24 +228,24 @@ export type MountedManifestSession = GameSession;
159
228
  /**
160
229
  * Distinguish an already-`loadGameManifest`d {@link ResolvedGameManifest}
161
230
  * from a raw (pre-Zod-parse) manifest value. The two shapes differ in
162
- * exactly one load-bearing way for this check: a `ResolvedWorldEntry.adapter`
231
+ * exactly one load-bearing way for this check: a `ResolvedAdapterRoot.adapter`
163
232
  * is `{ type: 'default' | 'module' | 'ingest', identity, ... }`, while a raw
164
- * `WorldEntry.adapter` is the literal string `'default'` or a bare `{module}`/
233
+ * `AdapterRoot.adapter` is the literal string `'default'` or a bare `{module}`/
165
234
  * `{ingest}` object with NO `type`/`identity` fields (`schema.ts`'s
166
- * `WorldAdapterSchema`). An empty `worlds` array is treated as "not resolved"
235
+ * `RootAdapterSchema`). An empty `roots` array is treated as "not resolved"
167
236
  * (falls through to `loadGameManifest`, whose own Zod validation reports the
168
237
  * empty-array error, if any, more precisely than a guess here could).
169
238
  */
170
239
  function looksAlreadyResolved(value: unknown): value is ResolvedGameManifest {
171
240
  if (typeof value !== 'object' || value === null) return false;
172
- const worlds = (value as { worlds?: unknown }).worlds;
173
- if (!Array.isArray(worlds) || worlds.length === 0) return false;
174
- return worlds.every((world) => {
241
+ const roots = (value as { roots?: unknown }).roots;
242
+ if (!Array.isArray(roots) || roots.length === 0) return false;
243
+ return roots.every((world) => {
175
244
  if (typeof world !== 'object' || world === null) return false;
176
245
  const adapter = (world as { adapter?: unknown }).adapter;
177
246
  if (typeof adapter !== 'object' || adapter === null) return false;
178
247
  const type = (adapter as { type?: unknown }).type;
179
- return type === 'default' || type === 'module' || type === 'ingest';
248
+ return type === 'builtin' || type === 'module' || type === 'ingest';
180
249
  });
181
250
  }
182
251
 
@@ -192,12 +261,12 @@ export function resolveManifest(raw: unknown): ResolvedGameManifest {
192
261
  }
193
262
 
194
263
  // ---------------------------------------------------------------------------
195
- // Per-kind entry -> adapter resolution (mirrors `resolveWorldAdapter`'s
264
+ // Per-kind entry -> adapter resolution (mirrors `resolveRootAdapter`'s
196
265
  // per-identity dispatch, `packages/editor/src/adapter-resolver.ts`)
197
266
  // ---------------------------------------------------------------------------
198
267
 
199
268
  function resolveThreeAdapter(
200
- world: ResolvedWorldEntry,
269
+ world: ResolvedAdapterRoot,
201
270
  entry: ThreeMountEntry | undefined,
202
271
  ): GameAdapter {
203
272
  if (entry?.adapter) return entry.adapter;
@@ -213,7 +282,7 @@ function resolveThreeAdapter(
213
282
  if (world.adapter.type === 'ingest') {
214
283
  throw new Error(
215
284
  `mountManifestWorlds: world "${world.id}" (threejs) declares an { ingest } adapter — ` +
216
- "ingest worlds require the editor's dev-server-backed mount machinery (an EditorStore " +
285
+ "ingest roots require the editor's dev-server-backed mount machinery (an EditorStore " +
217
286
  "plus iframe/DOM capture, see adapter-resolver.ts's resolveIngestThreeAdapter) and are " +
218
287
  'not supported by mountManifestWorlds (docs/WAVE3-ADAPTER-PLUMBING-DESIGN.md D-Z7 — no ' +
219
288
  'porting aids, no hosted ingest routes).',
@@ -249,7 +318,7 @@ function resolveThreeAdapter(
249
318
  }
250
319
 
251
320
  function resolvePixiAdapter(
252
- world: ResolvedWorldEntry,
321
+ world: ResolvedAdapterRoot,
253
322
  entry: PixiMountEntry | undefined,
254
323
  ): Pixi2DGameAdapter {
255
324
  if (entry?.adapter) return entry.adapter;
@@ -263,13 +332,13 @@ function resolvePixiAdapter(
263
332
  }
264
333
 
265
334
  function resolveReactAdapter(
266
- world: ResolvedWorldEntry,
335
+ world: ResolvedAdapterRoot,
267
336
  entry: ReactMountEntry | undefined,
268
- ): ReactWorldAdapter {
337
+ ): ReactRootAdapter {
269
338
  if (entry?.adapter) return entry.adapter;
270
339
  throw new Error(
271
340
  `mountManifestWorlds: world "${world.id}" (react) has no entries["${world.id}"] — a react ` +
272
- 'world always needs a caller-supplied, already-constructed `ReactWorldAdapter` ' +
341
+ 'world always needs a caller-supplied, already-constructed `ReactRootAdapter` ' +
273
342
  `(entries["${world.id}"] = { kind: 'react', adapter }, mounting via your own already-imported ` +
274
343
  'react-dom `createRoot` + your own `<GameProvider>`) — mountManifestWorlds never ' +
275
344
  "value-imports react/react-dom (mirrors create-runtime.ts's own react-free-core discipline; " +
@@ -278,13 +347,13 @@ function resolveReactAdapter(
278
347
  }
279
348
 
280
349
  function buildWorldMountSpec(
281
- world: ResolvedWorldEntry,
350
+ world: ResolvedAdapterRoot,
282
351
  entry: MountEntry | undefined,
283
352
  ): WorldMountSpec {
284
- if (entry !== undefined && entry.kind !== world.kind) {
353
+ if (entry !== undefined && entry.kind !== world.surface) {
285
354
  throw new Error(
286
355
  `mountManifestWorlds: entries["${world.id}"] declares kind "${entry.kind}" but the ` +
287
- `manifest's world "${world.id}" is kind "${world.kind}" — fix the entries key (or the ` +
356
+ `manifest's root "${world.id}" uses surface "${world.surface}" — fix the entries key (or the ` +
288
357
  'manifest) so the two agree.',
289
358
  );
290
359
  }
@@ -300,23 +369,23 @@ function buildWorldMountSpec(
300
369
  // above (or `entry` is `undefined`) — TS can't narrow a `Record` lookup
301
370
  // through that runtime check, so each branch casts to its own entry shape;
302
371
  // the actual safety comes from the mismatch guard, not from the cast.
303
- if (world.kind === 'threejs') {
372
+ if (world.surface === 'threejs') {
304
373
  const threeEntry = entry as ThreeMountEntry | undefined;
305
374
  return { ...base, kind: 'threejs', adapter: resolveThreeAdapter(world, threeEntry) };
306
375
  }
307
- if (world.kind === 'pixijs') {
376
+ if (world.surface === 'pixijs') {
308
377
  const pixiEntry = entry as PixiMountEntry | undefined;
309
378
  return { ...base, kind: 'pixijs', adapter: resolvePixiAdapter(world, pixiEntry) };
310
379
  }
311
- if (world.kind === 'react') {
380
+ if (world.surface === 'react') {
312
381
  const reactEntry = entry as ReactMountEntry | undefined;
313
382
  return { ...base, kind: 'react', adapter: resolveReactAdapter(world, reactEntry) };
314
383
  }
315
384
  // Exhaustiveness guard (§7.4-2, same idiom as `resolveAllWorlds`'s own
316
- // dispatch loop): `world.kind` is the closed `WorldEntry['kind']` union
385
+ // dispatch loop): `world.kind` is the closed `AdapterRoot['kind']` union
317
386
  // (`z.enum(['threejs','pixijs','react'])`), so a hypothetical 4th kind
318
387
  // must fail to compile here, not silently fall through.
319
- return assertNever(world.kind, 'mountManifestWorlds');
388
+ return assertNever(world.surface, 'mountManifestWorlds');
320
389
  }
321
390
 
322
391
  // ---------------------------------------------------------------------------
@@ -328,7 +397,7 @@ function buildWorldMountSpec(
328
397
  * standalone — no editor, no dev server. The generalization of
329
398
  * `examples/tri-world/src/main.ts`'s hand-built `WorldMountSpec[]` (D-Z2,
330
399
  * docs/WAVE3-ADAPTER-PLUMBING-DESIGN.md). Reuses the pure `loadGameManifest`
331
- * + `createGameRuntime({ worlds })` and mirrors `resolveAllWorlds`'s per-kind
400
+ * + `createGameRuntime({ roots })` and mirrors `resolveAllWorlds`'s per-kind
332
401
  * dispatch — but every entry the manifest needs beyond what a `default`-
333
402
  * adapter scene/entry can express on its own comes from the caller's OWN
334
403
  * already-imported module graph (`opts.entries`), never a dynamic import
@@ -342,20 +411,83 @@ function buildWorldMountSpec(
342
411
  */
343
412
  export async function mountManifestWorlds(opts: MountManifestOptions): Promise<GameSession> {
344
413
  const manifest = resolveManifest(opts.manifest);
345
- if (manifest.worlds.length === 0) {
346
- throw new Error('mountManifestWorlds: manifest declares no worlds — nothing to mount.');
414
+ if (manifest.roots.length === 0) {
415
+ throw new Error('mountManifestWorlds: manifest declares no roots — nothing to mount.');
347
416
  }
348
417
  const entries = opts.entries ?? {};
349
418
 
350
- const worlds: WorldMountSpec[] = manifest.worlds.map((world) =>
419
+ const roots: WorldMountSpec[] = manifest.roots.map((world) =>
351
420
  buildWorldMountSpec(world, entries[world.id]),
352
421
  );
353
422
 
354
- return createGameRuntime({
423
+ // D15 (T-D15.1) — resolved BEFORE `createGameRuntime` (which constructs the
424
+ // Game, and therefore `ctx.random`, before any world's `mount()`/`setup()`
425
+ // runs — see `createGame`'s own doc comment): the seed must be baked in at
426
+ // construction time, not patched in afterward, or a world's `setup()`
427
+ // would already have drawn from the wrong (default) sequence before any
428
+ // post-hoc reseed could take effect.
429
+ const seedUrl = opts.seedUrl ?? (hasRealWindow() ? window.location : undefined);
430
+ const resolvedSeed = resolveDeterminismSeed({
431
+ determinism: manifest.determinism,
432
+ explicitSeed: opts.seed,
433
+ url: seedUrl,
434
+ });
435
+
436
+ const session = await createGameRuntime({
355
437
  container: opts.container,
356
- worlds,
438
+ roots,
357
439
  width: opts.width ?? manifest.resolution?.width,
358
440
  height: opts.height ?? manifest.resolution?.height,
359
441
  headless: opts.headless,
442
+ seed: resolvedSeed,
360
443
  });
444
+
445
+ // D15 (T-D15.3) — the dev-mode Math.random phase trap: only while the
446
+ // manifest declares the contract AND this is a dev build (mirrors D18's
447
+ // debug-bridge gate — `import.meta.env.DEV`, never a production build
448
+ // unless something ELSE explicitly opts in, which this trap has no
449
+ // opt-in for at all: it is dev-only, full stop). `getGameplayRngTrapControl`
450
+ // returns `null` only for a bare `Game`-shaped test stand-in predating
451
+ // T7.1 — real mounts always have one (same precedent as `getDebugRegistry`
452
+ // just below).
453
+ if (manifest.determinism?.seededRandom === true && Boolean(import.meta.env?.DEV)) {
454
+ getGameplayRngTrapControl(session.game)?.setEnabled(true);
455
+ }
456
+
457
+ // Task 2.1 — install the `?vgai-debug=1` bridge at the TAIL of every
458
+ // manifest mount (engine-owned, so a standalone project gets it with zero
459
+ // template edits). `getDebugRegistry` returns `null` only for a bare
460
+ // `Game`-shaped test stand-in predating T7.1 — real mounts always have one.
461
+ const debugRegistry = getDebugRegistry(session.game);
462
+ if (debugRegistry) {
463
+ // Defect 3 fix: `setRoomDeclared` (the locus-required rule — a project
464
+ // with a Colyseus room must declare `locus: 'client' | 'server'` on every
465
+ // debug command, docs/SYNTHETIC-PLAYER-SPEC.md §3.1) was never actually
466
+ // called from a real mount path — this is that wiring. `manifest.server`
467
+ // (`ResolvedGameManifest.server`, `manifest/load.ts`) is `{ room, module }
468
+ // | undefined`; its mere presence is the "this project declares a room"
469
+ // signal, independent of whether/when the game actually joins it.
470
+ if (manifest.server) {
471
+ debugRegistry.setRoomDeclared(true);
472
+ }
473
+ // Defect 5 fix: `maybeInstallDebugBridge` adds `window` listeners and
474
+ // publishes `window.__vgai` with no way to undo either — wire its
475
+ // `uninstall()` into THIS session's own `stop()` so a caller that tears
476
+ // this mount down doesn't leave the bridge (and its listeners) live.
477
+ const bridgeHandle = maybeInstallDebugBridge({
478
+ registry: debugRegistry,
479
+ manifest,
480
+ url: opts.debugBridge?.url,
481
+ window: opts.debugBridge?.window,
482
+ });
483
+ if (bridgeHandle) {
484
+ const stopSession = session.stop;
485
+ session.stop = () => {
486
+ bridgeHandle.uninstall();
487
+ stopSession();
488
+ };
489
+ }
490
+ }
491
+
492
+ return session;
361
493
  }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Render-mode AUDIO control seam (I6, `docs/AI-NATIVE-AUTHORING-
3
+ * IMPLEMENTATION-SPEC.md` §15 I6 "Encode and Mux" + the audio-capture half of
4
+ * §13 G4). This is `render-control.ts`'s sibling for the audio side: where
5
+ * that module publishes `window.__vgaiRender` (exact-time seeks + composited
6
+ * frame passes), THIS module publishes `window.__vgaiRenderAudio` — an
7
+ * OPT-IN surface a render-mode page installs only when it declares a Tone
8
+ * score, so `vgai render-cinematic` (`packages/vgai-cli/src/render-
9
+ * cinematic.ts`) can detect "this cinematic has a deterministic audio track"
10
+ * with a single property check (`__vgaiRenderAudio?.hasAudio === true`)
11
+ * rather than needing a schema field threaded through every fixture.
12
+ *
13
+ * Deliberately a SEPARATE global from `__vgaiRender`/`VgaiRenderHarness`
14
+ * (`render-control.ts`), not an extra method bolted onto that interface:
15
+ * every existing render-mode page (I0's `render-cinematic` fixture, I8's
16
+ * `reference-cinematic`) has no Tone score at all, and this keeps their
17
+ * bundles/tests completely untouched — `render-control.ts` itself is not
18
+ * modified by this unit.
19
+ *
20
+ * ## Why `renderAudio` takes `(start, end)` and `compose` is a FACTORY
21
+ *
22
+ * `Tone.Offline` (via `audio/tone-offline-render.ts`) gives the composition
23
+ * callback LOCAL time 0 at whatever `start` was requested — an offline
24
+ * render of `[10, 10.72)` schedules its first event at LOCAL time 0, not
25
+ * absolute time 10 (see that module's own doc comment). A render-mode page
26
+ * authors its score in ABSOLUTE/canonical time (e.g. "a note at the same
27
+ * t=1.2s the video's Director cut happens"), so the actual Tone-native
28
+ * compose callback passed to `renderToneOffline` must subtract the
29
+ * requested range's `start` from every scheduled event time. Rather than
30
+ * have every fixture re-derive that subtraction inline, this module's
31
+ * {@link RenderAudioControlOptions.compose} is a FACTORY: called once per
32
+ * `renderAudio(start, end)` invocation with `start`, returning the actual
33
+ * `Tone.Offline`-native callback closed over that offset. This is the
34
+ * concrete fix for the G12 review follow-up named in this unit's build
35
+ * brief ("renderToneOffline gives the composition LOCAL time 0 at `start`,
36
+ * so your composition must close over the range/offset").
37
+ *
38
+ * ## Wire format — why WAV bytes cross as base64, not a raw sample array
39
+ *
40
+ * `page.evaluate`'s return value is JSON-serialized; a `Float32Array`/
41
+ * typed-array result would either fail to serialize or be coerced into a
42
+ * verbose `{ "0": ..., "1": ... }` object, and a plain JS number array of
43
+ * (potentially) hundreds of thousands of floats is slow to serialize and
44
+ * bloats the IPC payload. Encoding the FULL WAV file (header + 16-bit PCM
45
+ * data, `audio/wav-encode.ts`) to a single base64 string in-page keeps the
46
+ * Node-side capture driver (`render-cinematic.ts`) to one
47
+ * `Buffer.from(wavBase64, 'base64')` + one `writeFile` — no PCM assembly
48
+ * logic duplicated on the Node side at all.
49
+ */
50
+
51
+ import { renderToneOffline, type ToneOfflineComposeFn } from '../audio/tone-offline-render';
52
+ import { encodeWav16, pcmStats } from '../audio/wav-encode';
53
+ import { isRenderModeRequested } from './render-control';
54
+
55
+ /** Builds the actual `Tone.Offline`-native compose callback for a render
56
+ * starting at `rangeStart` (absolute/canonical seconds) — see the module
57
+ * doc's "closes over the range/offset" section above. */
58
+ export type RenderAudioComposeFactory = (rangeStart: number) => ToneOfflineComposeFn;
59
+
60
+ export interface RenderAudioControlOptions {
61
+ /** See {@link RenderAudioComposeFactory}. */
62
+ readonly compose: RenderAudioComposeFactory;
63
+ /** Output channel count. Default 2 (stereo) — same default as `tone-offline-render.ts`. */
64
+ readonly channels?: number;
65
+ /** Output sample rate. Default 44100 — same default as `tone-offline-render.ts`. */
66
+ readonly sampleRate?: number;
67
+ /** Where to publish the harness. Defaults to the real `window` — override in a unit test. */
68
+ readonly target?: Record<string, unknown>;
69
+ /** Where to read `?vgai-render=1` from. Defaults to `window.location`. */
70
+ readonly location?: { readonly search: string };
71
+ }
72
+
73
+ /** One offline audio render, encoded as a complete WAV file — the exact
74
+ * shape `render-cinematic.ts`'s capture driver reads back via `page.evaluate`. */
75
+ export interface RenderAudioResult {
76
+ /** Base64 of a complete little-endian 16-bit-PCM RIFF/WAVE file (see
77
+ * `audio/wav-encode.ts`) — decode with `Buffer.from(wavBase64, 'base64')`. */
78
+ readonly wavBase64: string;
79
+ readonly sampleRate: number;
80
+ readonly channels: number;
81
+ readonly durationSeconds: number;
82
+ /** RMS/peak over the raw PCM, BEFORE 16-bit quantization — lets a caller
83
+ * assert "genuinely non-silent audio, not a muxed-in silent track"
84
+ * without re-decoding the WAV it just received. */
85
+ readonly rms: number;
86
+ readonly peak: number;
87
+ }
88
+
89
+ /** The `window.__vgaiRenderAudio` surface. Presence of this global (guarded
90
+ * by `hasAudio: true`, never a bare `undefined`/`false` value on a present
91
+ * object) IS the "this cinematic declares a Tone score" signal `render-
92
+ * cinematic.ts` checks — see this module's own top doc comment. */
93
+ export interface VgaiRenderAudioHarness {
94
+ readonly hasAudio: true;
95
+ /** Render `[start, end)` (absolute/canonical seconds, exactly the same
96
+ * range `window.__vgaiRender`'s frame walk covers) offline via
97
+ * `Tone.Offline`, returning a complete WAV file. Deterministic: identical
98
+ * `(start, end)` on an unchanged score produces byte-identical
99
+ * `wavBase64` every call (the same `Tone.Offline` determinism
100
+ * `tone-offline-render.ts`/G2 already proves — this module adds no
101
+ * additional source of nondeterminism). */
102
+ renderAudio(start: number, end: number): Promise<RenderAudioResult>;
103
+ }
104
+
105
+ /**
106
+ * Build (and, when render mode is actually requested, publish) the
107
+ * `window.__vgaiRenderAudio` harness — same AC-4-shaped production
108
+ * protection as `installRenderControlHarness`: even called unconditionally
109
+ * on every boot, this only ever constructs/attaches the harness when
110
+ * `isRenderModeRequested(location)` is true, so a normal production gameplay
111
+ * page never gets `window.__vgaiRenderAudio` (and normal play never
112
+ * re-renders its music through an offline context — that would be a
113
+ * separate, much stranger bug).
114
+ */
115
+ export function installRenderAudioHarness(
116
+ opts: RenderAudioControlOptions,
117
+ ): VgaiRenderAudioHarness | undefined {
118
+ const location = opts.location ?? window.location;
119
+ if (!isRenderModeRequested(location)) return undefined;
120
+
121
+ const target = opts.target ?? (window as unknown as Record<string, unknown>);
122
+ const { compose, channels, sampleRate } = opts;
123
+
124
+ const harness: VgaiRenderAudioHarness = {
125
+ hasAudio: true,
126
+ async renderAudio(start: number, end: number): Promise<RenderAudioResult> {
127
+ const rendered = await renderToneOffline({
128
+ start,
129
+ end,
130
+ ...(channels !== undefined ? { channels } : {}),
131
+ ...(sampleRate !== undefined ? { sampleRate } : {}),
132
+ compose: compose(start),
133
+ });
134
+ const channelData: Float32Array[] = [];
135
+ for (let ch = 0; ch < rendered.buffer.numberOfChannels; ch++) {
136
+ channelData.push(rendered.buffer.getChannelData(ch));
137
+ }
138
+ const wavBytes = encodeWav16({ channelData, sampleRate: rendered.buffer.sampleRate });
139
+ const stats = pcmStats(channelData);
140
+ return {
141
+ wavBase64: uint8ArrayToBase64(wavBytes),
142
+ sampleRate: rendered.buffer.sampleRate,
143
+ channels: rendered.buffer.numberOfChannels,
144
+ durationSeconds: rendered.durationSeconds,
145
+ rms: stats.rms,
146
+ peak: stats.peak,
147
+ };
148
+ },
149
+ };
150
+
151
+ target['__vgaiRenderAudio'] = harness;
152
+ return harness;
153
+ }
154
+
155
+ /** Browser-native base64 encode (`btoa`) over a `Uint8Array`, chunked so a
156
+ * multi-second stereo WAV doesn't blow `String.fromCharCode`'s argument-count
157
+ * limit in one call. Node has no `btoa` for arbitrary bytes reliably across
158
+ * versions, but this module only ever runs in the browser (render-mode
159
+ * pages), matching `tone-offline-render.ts`'s own "browser-only" contract. */
160
+ function uint8ArrayToBase64(bytes: Uint8Array): string {
161
+ const CHUNK_SIZE = 0x8000;
162
+ let binary = '';
163
+ for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
164
+ const chunk = bytes.subarray(i, i + CHUNK_SIZE);
165
+ binary += String.fromCharCode(...chunk);
166
+ }
167
+ return btoa(binary);
168
+ }