@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
@@ -1,9 +1,10 @@
1
1
  import type RAPIER from '@dimforge/rapier3d-compat';
2
2
  import type { EffectComposer } from 'postprocessing';
3
3
  import type * as THREE from 'three';
4
+ import type { z } from 'zod';
4
5
  import type { SystemAdapters } from '../adapter/system-adapter';
5
- import type { AnimGraph } from '../animation/anim-graph';
6
6
  import type { AssetCache } from '../assets';
7
+ import type { SeededRandom } from '../core/seeded-random';
7
8
  import type { createSystemRunner } from '../core/system-runner';
8
9
  import type { createDebugDraw } from '../dev/debug-draw';
9
10
  import type { ComponentManager } from '../ecs/component-manager';
@@ -14,7 +15,87 @@ import type { SceneFile } from '../scene/scene-types';
14
15
  import type { AudioContext as GameAudio } from '../setup/setup-audio';
15
16
  import type { ParticlesContext } from '../setup/setup-particles';
16
17
  import type { Game, WorldInstance } from './game';
17
- import type { SceneUIGameServices } from './scene-ui-bridge';
18
+
19
+ /**
20
+ * The minimal structural shape `ctx.debug.attachRoom` needs from a joined
21
+ * Colyseus room (Task 2.2, `docs/SYNTHETIC-PLAYER-SPEC.md` §3.1's "Multiplayer
22
+ * locus" note): just enough to send the reserved `__vgai:debugCommand`
23
+ * message and listen for its `__vgai:debugCommandResult` reply. Structural,
24
+ * not a Colyseus type import — the same seam-boundary style every other
25
+ * `DebugCtxSurface` member uses (no wrapper around Colyseus itself, D2).
26
+ * `onMessage`'s return type is `unknown` because Colyseus's own return shape
27
+ * (void in some client versions, an unsubscribe function in others) isn't
28
+ * load-bearing here: the registry calls it defensively (invokes it on detach
29
+ * only if it's actually a function).
30
+ */
31
+ export interface DebugRoomHandle {
32
+ send(type: string, message?: unknown): void;
33
+ onMessage(type: string, cb: (message: unknown) => void): unknown;
34
+ }
35
+
36
+ /**
37
+ * Infers a `registerCommand`/`registerReactCommand`/`useDebugCommand`
38
+ * handler's parameter tuple from its declared Zod `args` tuple (dry-run
39
+ * finding, `docs/ACCEPTANCE-DRIVER-BUILD-PLAN.md` Wave 6 ledger —
40
+ * "`registerCommand` fn typing forces `unknown[]` casts"): a real
41
+ * `z.ZodTuple` infers its element types (`z.tuple([z.number(), z.string()])`
42
+ * → `(n: number, s: string) => ...`), while omitting `args` (the generic's
43
+ * `undefined` default) resolves to the original permissive
44
+ * `(...args: unknown[]) => ...` shape every command had before this generic
45
+ * existed. That permissive fallback is deliberate, not just a placeholder —
46
+ * it keeps every existing no-schema registration (including ones whose
47
+ * handler reads positional args the runtime never validates, since
48
+ * `invoke()` only parses `args` against a schema when one is declared)
49
+ * compiling byte-for-byte unchanged.
50
+ */
51
+ export type DebugCommandArgs<T extends z.ZodTuple | undefined = undefined> = T extends z.ZodTuple
52
+ ? z.infer<T>
53
+ : unknown[];
54
+
55
+ /**
56
+ * The authoring half of the debug/synthetic-player seam
57
+ * (`docs/SYNTHETIC-PLAYER-SPEC.md` §3.1) — the read/actuate half is
58
+ * `SystemAdapters.DebugAdapter` (`adapter/system-adapter.ts`). Callable from
59
+ * any GameComponent's `init` (one file per new provider/command), or from a
60
+ * react world's `useDebugProvider`/`useDebugCommand`/`useDebugEmit` hooks. Every registration
61
+ * feeds the ONE game-scoped registry (`runtime/debug-registry.ts`) — never a
62
+ * per-world accumulator.
63
+ */
64
+ export interface DebugCtxSurface {
65
+ /** Register (or replace) a named, JSON-serializable state read. Duplicate
66
+ * names from the SAME world replace + warn once; the SAME name from a
67
+ * DIFFERENT world throws (`DebugError` code `DEBUG_NAME_COLLISION`). */
68
+ registerStateProvider(
69
+ name: string,
70
+ fn: () => unknown,
71
+ opts?: { tier?: 'observable' | 'assisted' },
72
+ ): void;
73
+ /** Register (or replace) an invokable command. `locus` is REQUIRED once the
74
+ * project declares a Colyseus room (`DebugRegistry.setRoomDeclared`) — a
75
+ * fixture must say whether it mutates authoritative (server) or predicted
76
+ * (client) state.
77
+ *
78
+ * Generic over the declared `args` Zod tuple ({@link DebugCommandArgs}) so
79
+ * `fn`'s parameters INFER from it: `registerCommand('setHp', { args:
80
+ * z.tuple([z.number()]) }, (hp) => ...)` types `hp` as `number` with no
81
+ * cast. Leaving `args` off keeps `fn` typed `(...args: unknown[]) => ...`,
82
+ * same as before this generic existed. */
83
+ registerCommand<T extends z.ZodTuple | undefined = undefined>(
84
+ name: string,
85
+ spec: { description?: string; args?: T; locus?: 'client' | 'server' },
86
+ fn: (...args: DebugCommandArgs<T>) => unknown | Promise<unknown>,
87
+ ): void;
88
+ /** Push a tick-stamped event onto the debug event ring (spec §3.3) — the
89
+ * engine stamps `tick`/`simT` at emission, not at read time. */
90
+ emit(event: string, detail?: unknown): void;
91
+ /**
92
+ * Task 2.2 — server-locus command routing (client leg). Call once your
93
+ * game code has joined its Colyseus room, so `locus: 'server'` commands
94
+ * have somewhere to send `__vgai:debugCommand`. Returns a detach function
95
+ * (call on room leave/dispose) — game-scoped, last-attached room wins.
96
+ */
97
+ attachRoom(room: DebugRoomHandle): () => void;
98
+ }
18
99
 
19
100
  /**
20
101
  * All engine subsystems, passed to every game `setup(ctx)` and to each
@@ -75,10 +156,6 @@ export interface GameContext {
75
156
  * automatically per frame. Dev-only visualization — not for shipped
76
157
  * visuals. */
77
158
  debugDraw: ReturnType<typeof createDebugDraw>;
78
- /** Active animation graphs, keyed by their `Object3D`; ticked in the
79
- * `animation` phase. Look up an entity's `AnimGraph` to set params/triggers.
80
- * GLTF/scene-defined graphs register here automatically. */
81
- animGraphs: Map<THREE.Object3D, AnimGraph>;
82
159
  /** Shared GLTF/texture cache. Use `assets.loadGLTF(...)` etc. to load+cache
83
160
  * models at runtime so repeated loads reuse geometry/material. */
84
161
  assets: AssetCache;
@@ -89,21 +166,17 @@ export interface GameContext {
89
166
  /** Manager for all live GameComponent instances (attach/detach/HMR). The
90
167
  * scene loader drives this; gameplay code rarely touches it directly. */
91
168
  components: ComponentManager;
92
- /** DOM element overlaying the canvas for game UI (React via `mountUI`, or
93
- * vanilla). `pointer-events:none` by default — set `auto` on interactive
94
- * elements. Cleared on scene switch / hot-reload. */
95
- uiContainer: HTMLDivElement;
96
169
  /** Register a game-owned `SystemAdapters` capability (networking,
97
170
  * navigation, …) on the mounted game's adapter surface, so the editor's
98
171
  * introspection panels (via `getActiveSystems()`) can see it. The engine
99
- * wires the first-party physics/input/assets/animation entries itself;
172
+ * wires the first-party physics/input/assets/audio entries itself;
100
173
  * capabilities the GAME owns (a Colyseus connection, a NavMeshManager)
101
174
  * are registered here by the game's setup. Optional capability — absent
102
175
  * in contexts that expose no adapter surface (some test harnesses);
103
176
  * call as `ctx.registerSystemAdapter?.(…)`.
104
177
  *
105
178
  * Ordering contract: **game registrations always win** — the engine seeds
106
- * its first-party entries (physics/input/assets/animation) BEFORE setup()
179
+ * its first-party entries (physics/input/assets/audio) BEFORE setup()
107
180
  * runs, so a game that registers any of those same kinds during its own
108
181
  * setup overrides the engine's entry, not the other way around. This also
109
182
  * holds across a warm restart (`hotReload`): every kind the outgoing game
@@ -113,27 +186,31 @@ export interface GameContext {
113
186
  * mounted surface. */
114
187
  registerSystemAdapter?<K extends keyof SystemAdapters>(kind: K, adapter: SystemAdapters[K]): void;
115
188
 
189
+ /** The debug/synthetic-player authoring surface (`docs/SYNTHETIC-PLAYER-SPEC.md`
190
+ * §3.1) — see {@link DebugCtxSurface}. Optional, same absence precedent as
191
+ * `registerSystemAdapter`: present on every first-party mount, absent only
192
+ * in bare test harnesses that build a hand-rolled `ctx`. */
193
+ debug?: DebugCtxSurface;
194
+
116
195
  /**
117
- * Register (or replace) this game's scene-UI game-time services — the data
118
- * source, custom onEvent handler, and/or worldProjector overrides a scene's
119
- * authored UI mounts against (design/24-scene-ui.md D2/D7). Optional, same
120
- * precedent as `registerSystemAdapter`: absent in contexts with no scene-UI
121
- * surface at all (bare test harnesses).
122
- *
123
- * Timing is safe by construction: the scene loader awaits
124
- * `componentManager.initAll()` before `loadScene`/`loadSceneFromData`
125
- * return, and the adapter mounts scene UI only after that so calling this
126
- * from a `GameComponent.init()` always beats the mount (its services are
127
- * folded into the FIRST render, not a late update). A call AFTER the mount
128
- * (e.g. from a click handler) updates the live mount in place instead
129
- * (`SceneUIHandle.update`, or a dispose+remount if the renderer has none).
130
- *
131
- * Per-key merge (D2): a key set here always wins over both the project's
132
- * registration-time registry and the engine's own D4/D5/D6 defaults. Hot
133
- * reload strips every game-registered key back to none before the incoming
134
- * game's `init()` runs — same contract as `registerSystemAdapter`'s re-seed.
196
+ * D15 (T-D15.1, `docs/D15-DETERMINISM-DESIGN.md` §2.a) — the game-scoped,
197
+ * named-stream seeded PRNG. `ctx.random()` draws from the `'gameplay'`
198
+ * stream; `ctx.random.stream('vfx')` (or any other name) draws from an
199
+ * INDEPENDENT stream, so a cosmetic/VFX draw can never shift the gameplay
200
+ * draw sequence. Use this instead of raw `Math.random()` for anything a
201
+ * project wants to keep reproducible; `ctx.random` itself always exists
202
+ * and is always internally consistent (same seed same sequence) whether
203
+ * or not the project's manifest declares `determinism.seededRandom` that
204
+ * flag only gates the BOOT-TIME seeding from
205
+ * `manifest.determinism.defaultSeed`/`?vgai-seed=`, the burn-down lint
206
+ * scan, and the dev-mode `Math.random` phase trap (three separate
207
+ * enforcers, `test/gameplay-rng-ban.test.ts` + `runtime/gameplay-rng-trap.ts`),
208
+ * never `ctx.random`'s mere presence. Same absence precedent as `debug`
209
+ * above: present on every first-party mount, optional only for bare test
210
+ * harnesses that build a hand-rolled `ctx`. `reseed(seed)` (future draws
211
+ * only) is `play.seed.set`'s eventual target (T-D15.6, not yet wired).
135
212
  */
136
- setSceneUIServices?(services: SceneUIGameServices): void;
213
+ random?: SeededRandom;
137
214
 
138
215
  // ─── Game root (T7.1 slice 1 — GAME-ROOT-DESIGN.md D6) ───
139
216
 
@@ -143,9 +220,9 @@ export interface GameContext {
143
220
  * harness that builds a hand-rolled `ctx` object keeps compiling
144
221
  * unmodified — the same precedent as `registerSystemAdapter`. */
145
222
  game?: Game | undefined;
146
- /** Alias for `game.worlds` — the live, declaration-ordered world registry.
223
+ /** Alias for `game.roots` — the live, declaration-ordered world registry.
147
224
  * Same optionality/absence rule as `game` above. */
148
- worlds?: ReadonlyArray<WorldInstance> | undefined;
225
+ roots?: ReadonlyArray<WorldInstance> | undefined;
149
226
  }
150
227
 
151
228
  /** Return value from a game setup function. */
@@ -1,5 +1,7 @@
1
1
  /**
2
- * Canonical GLTF / texture / animgraph load + cache layer (A2).
2
+ * Canonical GLTF / texture load + cache layer (A2). (Formerly also hosted the
3
+ * `.animgraph.json` loader — removed by E5; see
4
+ * packages/engine/src/animation/xstate-animation-binding.ts.)
3
5
  *
4
6
  * ONE place that owns the URL-keyed caches and the clone-safety contract, shared
5
7
  * by BOTH the engine runtime (scene-loader, create-runtime) AND the editor
@@ -22,9 +24,7 @@
22
24
 
23
25
  import * as THREE from 'three';
24
26
  import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js';
25
- import type { AnimGraphFile } from '../animation/anim-graph-types';
26
- import { AnimGraphFileSchema } from '../animation/schema';
27
- import { gltfLoader, resolveUrl, textureLoader } from '../loader';
27
+ import { gltfLoader, textureLoader } from '../loader';
28
28
  import { SceneParseError } from './parse';
29
29
  import { setUserData } from './user-data';
30
30
 
@@ -33,7 +33,6 @@ const gltfCache = new Map<
33
33
  string,
34
34
  Promise<{ scene: THREE.Group; animations: THREE.AnimationClip[] }>
35
35
  >();
36
- const animGraphCache = new Map<string, Promise<AnimGraphFile>>();
37
36
 
38
37
  /** Load (and cache) a texture via the shared, prefix-aware TextureLoader. */
39
38
  export function loadTexture(url: string): THREE.Texture {
@@ -146,34 +145,10 @@ export function resolveGltfNode(
146
145
  }
147
146
 
148
147
  /**
149
- * Fetch (and cache) an animgraph JSON file via the shared prefix-aware fetch.
150
- * Validated via `AnimGraphFileSchema` (T4.6) a malformed `.animgraph.json`
151
- * throws a `SceneParseError` naming the file, not a deep TypeError once the
152
- * bad data reaches `AnimGraph`/`LayerRuntime`.
153
- */
154
- export function loadAnimGraphData(src: string): Promise<AnimGraphFile> {
155
- let cached = animGraphCache.get(src);
156
- if (!cached) {
157
- cached = fetch(resolveUrl(src))
158
- .then((r) => {
159
- if (!r.ok) throw new Error(`Failed to load animgraph: ${src}`);
160
- return r.json();
161
- })
162
- .then((json) => {
163
- const result = AnimGraphFileSchema.safeParse(json);
164
- if (!result.success) throw new SceneParseError(result.error.issues, src);
165
- return result.data;
166
- });
167
- animGraphCache.set(src, cached);
168
- }
169
- return cached;
170
- }
171
-
172
- /**
173
- * Clear the module-level GLTF / texture / animgraph caches. Call on full runtime
174
- * teardown (create-runtime fullCleanup / hot-reload) to release cached GPU
175
- * resources and avoid leaking across editor Play sessions. See the lifetime note
176
- * at the top of this file for why these are NOT cleared per scene load.
148
+ * Clear the module-level GLTF / texture caches. Call on full runtime teardown
149
+ * (create-runtime fullCleanup / hot-reload) to release cached GPU resources
150
+ * and avoid leaking across editor Play sessions. See the lifetime note at the
151
+ * top of this file for why these are NOT cleared per scene load.
177
152
  *
178
153
  * NOTE: the IBL/skybox env-map cache lives in scene-loader (it needs a
179
154
  * WebGLRenderer for PMREM); scene-loader's `clearAssetCaches` wraps this and
@@ -182,7 +157,6 @@ export function loadAnimGraphData(src: string): Promise<AnimGraphFile> {
182
157
  export function clearAssetCaches(): void {
183
158
  textureCache.clear();
184
159
  gltfCache.clear();
185
- animGraphCache.clear();
186
160
  }
187
161
 
188
162
  /**
@@ -190,6 +164,6 @@ export function clearAssetCaches(): void {
190
164
  * test to assert caches stay bounded (one entry per distinct asset URL) across
191
165
  * repeated loads of the same scene, rather than growing per load.
192
166
  */
193
- export function assetCacheSizes(): { textures: number; gltf: number; animGraphs: number } {
194
- return { textures: textureCache.size, gltf: gltfCache.size, animGraphs: animGraphCache.size };
167
+ export function assetCacheSizes(): { textures: number; gltf: number } {
168
+ return { textures: textureCache.size, gltf: gltfCache.size };
195
169
  }
@@ -75,7 +75,6 @@ export function collectAssetPaths(
75
75
  if (e.particles?.material?.map) paths.add(e.particles.material.map);
76
76
 
77
77
  if (e.audio?.src) paths.add(e.audio.src);
78
- if (e.animation?.animGraph) paths.add(e.animation.animGraph);
79
78
  if (e.prefab && isFilePrefab(e.prefab)) paths.add(e.prefab);
80
79
  });
81
80
 
@@ -102,7 +101,6 @@ function renameInEntity(e: SceneEntity, oldPath: string, newPath: string): void
102
101
  if (e.particles?.material?.map === oldPath) e.particles.material.map = newPath;
103
102
 
104
103
  if (e.audio?.src === oldPath) e.audio.src = newPath;
105
- if (e.animation?.animGraph === oldPath) e.animation.animGraph = newPath;
106
104
  if (e.prefab === oldPath) e.prefab = newPath;
107
105
  }
108
106
 
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Shared asset-reference integrity check (B6 follow-up to B2 item #20,
3
+ * docs/AI-NATIVE-AUTHORING-IMPLEMENTATION-SPEC.md §8 B6).
4
+ *
5
+ * RELOCATED HERE (from `packages/vgai-cli/src/apply-diff.ts`, where it was
6
+ * originally built as `dereferenceNewAssetRefs`) so it can be shared by BOTH
7
+ * `vgai apply-diff` (the file-mode CLI verb) and the SDK's
8
+ * `project.scene.apply` operation (`packages/vgai-sdk/src/project/scene-operations.ts`)
9
+ * WITHOUT the SDK importing from the CLI — that would invert the intended
10
+ * dependency direction (CLI -> SDK -> engine, §3.4). The engine's scene layer
11
+ * is the correct shared home: both the CLI and the SDK already depend
12
+ * directly on `@vgai/engine/scene/*` for scene parsing/patching, so this
13
+ * module sits alongside `parse.ts`/`scene-apply.ts` as one more piece of
14
+ * scene-layer machinery neither caller re-implements.
15
+ *
16
+ * WHAT THIS CATCHES that schema validation alone does not: the Zod scene
17
+ * schema only checks that e.g. `materialRef` is a STRING — never that the
18
+ * string points at anything real. A diff introducing
19
+ * `materialRef: "materials/typo.mat.json"` (nonexistent, or present but
20
+ * malformed) passes ordinary scene/patch validation and would be written;
21
+ * the failure would otherwise only surface later, at runtime asset fetch.
22
+ * `dereferenceNewAssetRefs` closes that gap by re-resolving every asset
23
+ * reference the diff INTRODUCED (present in `result`, absent from
24
+ * `inputScene` — deliberately not pre-existing refs, so a scene with
25
+ * unrelated prior breakage can still receive an unrelated repair;
26
+ * `validate-scenes` remains the whole-project content gate) against disk,
27
+ * refusing the write if anything is missing or fails its own schema.
28
+ *
29
+ * Reference kinds and their treatment (unchanged from the original CLI-only
30
+ * implementation):
31
+ * - Zod-validated (JSON formats with engine Zod schemas): `materialRef`
32
+ * (.mat.json -> parseMaterialFile), `prefab` in its file-path form
33
+ * (.prefab.json -> parsePrefabFile). Prefabs are validated ONE level
34
+ * deep — the referenced prefab file itself must exist and parse; assets
35
+ * IT references are that file's own concern. (`animation.animGraph` ->
36
+ * AnimGraphFileSchema was removed by E5 — AnimGraph no longer exists.)
37
+ * - Existence-checked only (binary formats with no Zod schema to parse
38
+ * them through): gltf `mesh.src`, every material texture-map path
39
+ * (including the nested clearcoat/transmission/sheen/iridescence groups),
40
+ * `particles.material.map`, `audio.src`, `environment.skybox`.
41
+ * - `.inputmap.json` is structurally out of scope: no `.vscn.json`/
42
+ * `.prefab.json` field references an input map.
43
+ */
44
+
45
+ import { existsSync, readFileSync } from 'node:fs';
46
+ import { basename, dirname, join, resolve } from 'node:path';
47
+ import { parseMaterialFile, parsePrefabFile, SceneParseError } from './parse';
48
+ import type { SceneEntity, SceneFile } from './scene-types';
49
+
50
+ /** One asset reference found in a scene document: where it is + what it points at. */
51
+ export interface AssetRef {
52
+ /** The authored path string (web-root-relative, optionally /-prefixed). */
53
+ path: string;
54
+ /** Human-readable field location for error messages, e.g. `entity "crate" field materialRef`. */
55
+ field: string;
56
+ kind: 'material' | 'prefab' | 'binary';
57
+ }
58
+
59
+ /**
60
+ * One asset reference that failed to dereference. `reason` is a one-line-or-
61
+ * more human-readable explanation (schema-validation failures may be
62
+ * multi-line); `field`/`path` mirror `AssetRef` and `diskPath` is the
63
+ * resolved absolute path that was checked, so every caller (CLI text report,
64
+ * SDK structured error data) can build its own presentation from the same
65
+ * facts without re-deriving them.
66
+ */
67
+ export interface AssetRefError {
68
+ field: string;
69
+ path: string;
70
+ diskPath: string;
71
+ reason: string;
72
+ }
73
+
74
+ /** Material texture-map fields that hold image paths — top-level and nested feature groups. */
75
+ const MATERIAL_MAP_KEYS = [
76
+ 'map',
77
+ 'normalMap',
78
+ 'emissiveMap',
79
+ 'aoMap',
80
+ 'roughnessMap',
81
+ 'metalnessMap',
82
+ 'displacementMap',
83
+ ] as const;
84
+ const MATERIAL_GROUP_MAP_KEYS: Record<string, readonly string[]> = {
85
+ clearcoat: ['clearcoatMap', 'clearcoatRoughnessMap'],
86
+ transmission: ['transmissionMap'],
87
+ sheen: ['sheenColorMap', 'sheenRoughnessMap'],
88
+ iridescence: ['iridescenceMap', 'iridescenceThicknessMap'],
89
+ };
90
+
91
+ /** Same file-path-vs-registry-id heuristic as `asset-paths.ts`. */
92
+ function isFilePrefab(value: string): boolean {
93
+ return value.includes('/');
94
+ }
95
+
96
+ function entityLabel(e: SceneEntity): string {
97
+ return e.id ? `entity "${e.name}" (id ${e.id})` : `entity "${e.name}"`;
98
+ }
99
+
100
+ /** Collect every asset reference in a scene document (entities walked recursively + environment), labeled for error messages. */
101
+ export function collectAssetRefs(doc: SceneFile): AssetRef[] {
102
+ const refs: AssetRef[] = [];
103
+
104
+ function walk(entities: SceneEntity[]): void {
105
+ for (const e of entities) {
106
+ const label = entityLabel(e);
107
+ if (e.mesh?.type === 'gltf' && e.mesh.src) {
108
+ refs.push({ path: e.mesh.src, field: `${label} field mesh.src`, kind: 'binary' });
109
+ }
110
+ if (e.material) {
111
+ const mat = e.material as Record<string, unknown>;
112
+ for (const key of MATERIAL_MAP_KEYS) {
113
+ const val = mat[key];
114
+ if (typeof val === 'string') {
115
+ refs.push({ path: val, field: `${label} field material.${key}`, kind: 'binary' });
116
+ }
117
+ }
118
+ for (const [group, keys] of Object.entries(MATERIAL_GROUP_MAP_KEYS)) {
119
+ const groupVal = mat[group];
120
+ if (groupVal && typeof groupVal === 'object') {
121
+ for (const key of keys) {
122
+ const val = (groupVal as Record<string, unknown>)[key];
123
+ if (typeof val === 'string') {
124
+ refs.push({
125
+ path: val,
126
+ field: `${label} field material.${group}.${key}`,
127
+ kind: 'binary',
128
+ });
129
+ }
130
+ }
131
+ }
132
+ }
133
+ }
134
+ if (e.materialRef) {
135
+ refs.push({ path: e.materialRef, field: `${label} field materialRef`, kind: 'material' });
136
+ }
137
+ if (e.particles?.material?.map) {
138
+ refs.push({
139
+ path: e.particles.material.map,
140
+ field: `${label} field particles.material.map`,
141
+ kind: 'binary',
142
+ });
143
+ }
144
+ if (e.audio?.src) {
145
+ refs.push({ path: e.audio.src, field: `${label} field audio.src`, kind: 'binary' });
146
+ }
147
+ if (e.prefab && isFilePrefab(e.prefab)) {
148
+ refs.push({ path: e.prefab, field: `${label} field prefab`, kind: 'prefab' });
149
+ }
150
+ if (e.children) walk(e.children);
151
+ }
152
+ }
153
+
154
+ walk(doc.entities);
155
+ if (doc.environment?.skybox) {
156
+ refs.push({ path: doc.environment.skybox, field: 'environment.skybox', kind: 'binary' });
157
+ }
158
+ return refs;
159
+ }
160
+
161
+ /**
162
+ * Resolve the asset root the scene's references are relative to. Asset paths
163
+ * are web-root-relative (the runtime fetches them from the served `public/`
164
+ * directory), and scene files conventionally live INSIDE `public/`
165
+ * (`public/scenes/*.vscn.json`) — so the nearest ancestor directory literally
166
+ * named `public` is the root. Fallback for scenes not under a `public/`
167
+ * ancestor: the scene's own directory.
168
+ */
169
+ export function findAssetRoot(scenePath: string): string {
170
+ const dir = resolve(dirname(scenePath));
171
+ let cursor = dir;
172
+ for (;;) {
173
+ if (basename(cursor) === 'public') return cursor;
174
+ const parent = dirname(cursor);
175
+ if (parent === cursor) return dir;
176
+ cursor = parent;
177
+ }
178
+ }
179
+
180
+ /** Format Zod/scene-parse issues as `path: message`, one per line. */
181
+ function formatIssues(issues: { path: PropertyKey[]; message: string }[]): string {
182
+ return issues.map((i) => ` ${i.path.join('.')}: ${i.message}`).join('\n');
183
+ }
184
+
185
+ /**
186
+ * Dereference every asset reference `result` introduces relative to
187
+ * `inputScene` (present in the result, absent from the input) against disk,
188
+ * relative to `scenePath`'s asset root (see `findAssetRoot`). Returns
189
+ * structured errors (empty = every new reference resolved and validated) —
190
+ * never throws; callers decide how to present/escalate a non-empty result
191
+ * (the CLI folds these into one `ApplyDiffCliError` message, the SDK raises
192
+ * a declared `ASSET_REF_INVALID` `OperationError`).
193
+ */
194
+ export function dereferenceNewAssetRefs(
195
+ inputScene: SceneFile,
196
+ result: SceneFile,
197
+ scenePath: string,
198
+ ): AssetRefError[] {
199
+ const preexisting = new Set(collectAssetRefs(inputScene).map((r) => r.path));
200
+ const newRefs = collectAssetRefs(result).filter((r) => !preexisting.has(r.path));
201
+ if (newRefs.length === 0) return [];
202
+
203
+ const assetRoot = findAssetRoot(scenePath);
204
+ const errors: AssetRefError[] = [];
205
+
206
+ for (const ref of newRefs) {
207
+ const diskPath = join(assetRoot, ref.path.replace(/^\//, ''));
208
+ if (!existsSync(diskPath)) {
209
+ errors.push({
210
+ field: ref.field,
211
+ path: ref.path,
212
+ diskPath,
213
+ reason: `file not found at ${diskPath} (resolved against ${assetRoot})`,
214
+ });
215
+ continue;
216
+ }
217
+ if (ref.kind === 'binary') continue; // existence-only (see the exclusion note above)
218
+
219
+ let json: unknown;
220
+ try {
221
+ json = JSON.parse(readFileSync(diskPath, 'utf-8'));
222
+ } catch (err) {
223
+ errors.push({
224
+ field: ref.field,
225
+ path: ref.path,
226
+ diskPath,
227
+ reason: `${diskPath} is not valid JSON: ${err instanceof Error ? err.message : err}`,
228
+ });
229
+ continue;
230
+ }
231
+ try {
232
+ if (ref.kind === 'material') {
233
+ parseMaterialFile(json, diskPath);
234
+ } else {
235
+ parsePrefabFile(json, diskPath);
236
+ }
237
+ } catch (err) {
238
+ const detail = err instanceof SceneParseError ? `\n${formatIssues(err.issues)}` : ` ${err}`;
239
+ errors.push({
240
+ field: ref.field,
241
+ path: ref.path,
242
+ diskPath,
243
+ reason: `${diskPath} fails schema validation:${detail}`,
244
+ });
245
+ }
246
+ }
247
+ return errors;
248
+ }
@@ -5,6 +5,7 @@
5
5
  * and editor entity factory import from this module.
6
6
  */
7
7
 
8
+ import * as THREE from 'three';
8
9
  import { resolveUrl } from '../loader';
9
10
 
10
11
  export interface ImportCorrection {
@@ -18,6 +19,27 @@ export interface AssetMeta {
18
19
  importCorrection?: ImportCorrection;
19
20
  }
20
21
 
22
+ /**
23
+ * Apply an asset's visual-only import correction to the loaded GLTF content.
24
+ * Call this on the model child, before placing it under the entity wrapper, so
25
+ * gameplay transforms, physics, cameras, and movement remain in VGAI's Y-up /
26
+ * -Z-forward coordinate contract.
27
+ */
28
+ export function applyImportCorrection(
29
+ content: THREE.Object3D,
30
+ correction: ImportCorrection | undefined,
31
+ ): void {
32
+ if (!correction) return;
33
+ if (correction.scale !== undefined) content.scale.multiplyScalar(correction.scale);
34
+ if (correction.rotation) {
35
+ content.rotation.set(
36
+ THREE.MathUtils.degToRad(correction.rotation[0]),
37
+ THREE.MathUtils.degToRad(correction.rotation[1]),
38
+ THREE.MathUtils.degToRad(correction.rotation[2]),
39
+ );
40
+ }
41
+ }
42
+
21
43
  type AssetRegistry = Record<string, AssetMeta>;
22
44
 
23
45
  let registryCache: AssetRegistry | null = null;
@@ -1,13 +1,24 @@
1
1
  import type * as THREE from 'three';
2
2
  import type { ComponentManager } from '../ecs/component-manager';
3
- import { GameComponent, type GameComponentClass } from '../ecs/game-component';
3
+ import {
4
+ GAME_COMPONENT_BRAND,
5
+ GameComponent,
6
+ type GameComponentClass,
7
+ } from '../ecs/game-component';
4
8
 
5
9
  /** A registry mapping component names to GameComponent classes. */
6
10
  export type ComponentRegistry = Record<string, GameComponentClass>;
7
11
 
8
- /** Type guard: is this value a GameComponent class? */
12
+ /** Type guard: is this value a GameComponent class? `instanceof` alone is NOT
13
+ * enough: a scaffolded project's node-side scripts resolve TWO physical
14
+ * copies of the engine (published `@vgai/engine/*` + the `@engine/*` source
15
+ * alias), and a class extending the OTHER copy's `GameComponent` fails the
16
+ * prototype-chain check — so this also accepts the cross-copy
17
+ * {@link GAME_COMPONENT_BRAND}. */
9
18
  export function isGameComponentClass(entry: unknown): entry is GameComponentClass {
10
- return typeof entry === 'function' && entry.prototype instanceof GameComponent;
19
+ if (typeof entry !== 'function') return false;
20
+ if (entry.prototype instanceof GameComponent) return true;
21
+ return (entry as unknown as Record<symbol, unknown>)[GAME_COMPONENT_BRAND] === true;
11
22
  }
12
23
 
13
24
  /**
@@ -40,6 +40,7 @@ export const DEFAULTS = {
40
40
  intensity: 1,
41
41
  groundColor: '#000000',
42
42
  distance: 0,
43
+ decay: 2,
43
44
  angle: Math.PI / 6,
44
45
  penumbra: 0,
45
46
  // three.js's own RectAreaLight constructor defaults (area lights only).
@@ -42,15 +42,23 @@ export function createLight(def: SceneLight): THREE.Light {
42
42
  case 'directional':
43
43
  return new THREE.DirectionalLight(color, intensity);
44
44
  case 'point':
45
- return new THREE.PointLight(color, intensity, def.distance ?? d.distance);
46
- case 'spot':
47
- return new THREE.SpotLight(
45
+ return new THREE.PointLight(
46
+ color,
47
+ intensity,
48
+ def.distance ?? d.distance,
49
+ def.decay ?? d.decay,
50
+ );
51
+ case 'spot': {
52
+ const light = new THREE.SpotLight(
48
53
  color,
49
54
  intensity,
50
55
  def.distance ?? d.distance,
51
56
  def.angle ?? d.angle,
52
57
  def.penumbra ?? d.penumbra,
53
58
  );
59
+ light.decay = def.decay ?? d.decay;
60
+ return light;
61
+ }
54
62
  case 'hemisphere':
55
63
  return new THREE.HemisphereLight(color, def.groundColor ?? d.groundColor, intensity);
56
64
  case 'area': {