@vgai/engine 0.5.1 → 0.5.3

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 (185) hide show
  1. package/README.md +18 -11
  2. package/package.json +10 -6
  3. package/schemas/engine-api.json +1 -68
  4. package/schemas/engine-api.md +1 -32
  5. package/schemas/engine-capabilities.json +30 -42
  6. package/schemas/{vgai-game.schema.json → vgai-project.schema.json} +52 -34
  7. package/src/adapter/adapter-surface.ts +5 -5
  8. package/src/adapter/authoring.ts +168 -226
  9. package/src/adapter/colyseus-networking-adapter.ts +44 -5
  10. package/src/adapter/first-party-systems.ts +156 -42
  11. package/src/adapter/host-context.ts +177 -46
  12. package/src/adapter/index.ts +47 -51
  13. package/src/adapter/ingest/game-contract.ts +2 -2
  14. package/src/adapter/ingest/scene-capture.ts +18 -19
  15. package/src/adapter/ingest/structural-ids.ts +127 -0
  16. package/src/adapter/ingest/upstream-pin.ts +9 -12
  17. package/src/adapter/loop-gate-report.ts +11 -11
  18. package/src/adapter/rapier-physics-adapter.ts +27 -9
  19. package/src/adapter/root-adapter.ts +217 -0
  20. package/src/adapter/{vgai-scene-game-adapter.ts → setup-three-root-adapter.ts} +173 -351
  21. package/src/adapter/system-adapter.ts +80 -63
  22. package/src/ai/navigation.ts +1 -1
  23. package/src/animation/animation-clock.ts +1 -1
  24. package/src/animation/camera-ownership.ts +1 -2
  25. package/src/animation/cubic-spline-interpolant.ts +132 -0
  26. package/src/animation/theatre-clock-binding.ts +2 -2
  27. package/src/animation/theatre-object-binding.ts +4 -4
  28. package/src/animation/xstate-animation-binding.ts +75 -5
  29. package/src/{scene/schema → asset-formats}/camera.ts +2 -5
  30. package/src/{scene/schema → asset-formats}/collider.ts +2 -5
  31. package/src/asset-formats/index.ts +54 -0
  32. package/src/{scene/schema → asset-formats}/instances.ts +10 -6
  33. package/src/{scene/schema → asset-formats}/light.ts +3 -6
  34. package/src/{scene/schema → asset-formats}/material.ts +4 -7
  35. package/src/{scene/schema → asset-formats}/mesh.ts +4 -7
  36. package/src/asset-formats/parse.ts +39 -0
  37. package/src/{scene/schema → asset-formats}/particles.ts +3 -6
  38. package/src/{scene/schema/environment.ts → asset-formats/render-env.ts} +23 -86
  39. package/src/{scene/schema → asset-formats}/tuples.ts +1 -1
  40. package/src/{scene/asset-loaders.ts → asset-loaders.ts} +8 -9
  41. package/src/asset-parse-error.ts +33 -0
  42. package/src/{scene/asset-registry.ts → asset-registry.ts} +1 -1
  43. package/src/assets.ts +1 -1
  44. package/src/audio/wav-encode.ts +9 -9
  45. package/src/canvas-react/engine-bridge.ts +59 -0
  46. package/src/canvas-react/index.ts +50 -0
  47. package/src/canvas-react/pixi-primitive.tsx +202 -0
  48. package/src/canvas-react/pixi-react-adapter.tsx +290 -0
  49. package/src/canvas-react/pixi-react-root-factory.tsx +88 -0
  50. package/src/canvas-react/world-context.ts +328 -0
  51. package/src/core/frame-pacing.ts +100 -0
  52. package/src/core/game-loop.ts +50 -28
  53. package/src/core/seeded-random.ts +7 -7
  54. package/src/core/sim-clock.ts +388 -0
  55. package/src/core/system-runner.ts +17 -63
  56. package/src/core/types.ts +34 -15
  57. package/src/data/data-asset.ts +3 -3
  58. package/src/data/data-check-core.ts +6 -7
  59. package/src/data/data-ref.ts +11 -11
  60. package/src/data/vite-plugin-data.ts +10 -10
  61. package/src/{scene/defaults.ts → defaults.ts} +18 -40
  62. package/src/dev/render-debug-adapter.ts +1 -1
  63. package/src/dev/webgl-frame-capture.ts +1 -1
  64. package/src/ecs/scene-index.ts +439 -0
  65. package/src/ecs/scene-query.ts +43 -0
  66. package/src/{scene → ecs}/user-data.ts +17 -36
  67. package/src/index.ts +7 -9
  68. package/src/input/input-manager.ts +29 -32
  69. package/src/input/input-types.ts +2 -2
  70. package/src/input/schema.ts +5 -5
  71. package/src/loader.ts +57 -0
  72. package/src/manifest/editor-port.ts +69 -0
  73. package/src/manifest/filename.ts +49 -0
  74. package/src/manifest/index.ts +8 -2
  75. package/src/manifest/load-file.ts +11 -0
  76. package/src/manifest/load.ts +65 -77
  77. package/src/manifest/locate.ts +55 -0
  78. package/src/manifest/schema.ts +400 -233
  79. package/src/{scene → physics}/collider-dimensions.ts +3 -3
  80. package/src/physics/physics-registry.ts +1 -1
  81. package/src/{world2d/authoring-2d.ts → pixi/authoring.ts} +24 -11
  82. package/src/pixi/index.ts +43 -0
  83. package/src/{world2d/ingest-iframe-2d.ts → pixi/ingest-iframe.ts} +9 -9
  84. package/src/{world2d/ingest2d.ts → pixi/ingest.ts} +28 -28
  85. package/src/{world2d/physics2d-registry.ts → pixi/physics-registry.ts} +1 -1
  86. package/src/{world2d/scene-capture-2d.ts → pixi/scene-capture.ts} +3 -3
  87. package/src/{world2d/system-adapters-2d.ts → pixi/system-adapters.ts} +2 -2
  88. package/src/react/unmanaged-root-detector.ts +26 -1
  89. package/src/react/use-data.ts +5 -5
  90. package/src/react/use-selection.tsx +15 -42
  91. package/src/react/{game-state.tsx → world-state.tsx} +44 -46
  92. package/src/render/auto-batcher.ts +1 -2
  93. package/src/{scene → render}/instance-mesh.ts +1 -1
  94. package/src/{scene → render}/light-camera-factory.ts +14 -13
  95. package/src/render/lod.ts +17 -0
  96. package/src/{scene → render}/material-factory.ts +8 -7
  97. package/src/{scene → render}/particles-factory.ts +62 -12
  98. package/src/render/render-batch-system.ts +14 -41
  99. package/src/render/render-features.ts +1 -1
  100. package/src/render/render-settings.ts +1 -2
  101. package/src/render/spark-renderer-lifecycle.ts +1 -1
  102. package/src/runtime/create-runtime.ts +352 -463
  103. package/src/runtime/debug-bridge.ts +148 -93
  104. package/src/runtime/debug-registry.ts +79 -54
  105. package/src/runtime/dev-layers.ts +40 -0
  106. package/src/runtime/frame-selector-cache.ts +4 -4
  107. package/src/runtime/game.ts +618 -369
  108. package/src/runtime/gameplay-rng-trap.ts +6 -7
  109. package/src/runtime/input-router.ts +11 -11
  110. package/src/runtime/mount-game.ts +54 -55
  111. package/src/runtime/mount-manifest.ts +154 -150
  112. package/src/runtime/presentation.ts +141 -0
  113. package/src/runtime/render-audio-control.ts +64 -53
  114. package/src/runtime/render-control.ts +45 -51
  115. package/src/runtime/render-seed.ts +3 -4
  116. package/src/runtime/state-bridge.ts +17 -18
  117. package/src/runtime/types.ts +94 -61
  118. package/src/setup/setup-renderer.ts +11 -6
  119. package/src/world3d-react/engine-bridge.ts +46 -33
  120. package/src/world3d-react/index.ts +31 -26
  121. package/src/world3d-react/r3f-adapter.tsx +211 -96
  122. package/src/world3d-react/r3f-root-factory.tsx +91 -0
  123. package/src/world3d-react/renderer-config.ts +137 -0
  124. package/src/world3d-react/world-context.ts +141 -111
  125. package/schemas/entity2d.schema.json +0 -468
  126. package/schemas/prefab.schema.json +0 -9992
  127. package/schemas/scn2d.schema.json +0 -494
  128. package/schemas/vscn.schema.json +0 -10851
  129. package/src/adapter/game-adapter.ts +0 -164
  130. package/src/adapter/ingest/overlay-applier.ts +0 -207
  131. package/src/adapter/ingest/overlay-apply.ts +0 -168
  132. package/src/adapter/ingest/overlay-file.ts +0 -126
  133. package/src/adapter/ingest/overlay-report.ts +0 -176
  134. package/src/animation/gsap-registration.ts +0 -184
  135. package/src/audio/audio-introspection.ts +0 -290
  136. package/src/audio/index.ts +0 -39
  137. package/src/audio/tone-clock-binding.ts +0 -98
  138. package/src/audio/tone-context.ts +0 -175
  139. package/src/audio/tone-offline-render.ts +0 -167
  140. package/src/ecs/component-manager.ts +0 -814
  141. package/src/ecs/game-component.ts +0 -260
  142. package/src/ecs/hmr-swap-report.ts +0 -65
  143. package/src/physics/trigger-dispatch.ts +0 -97
  144. package/src/react/root-adapter.tsx +0 -49
  145. package/src/scene/asset-paths.ts +0 -121
  146. package/src/scene/asset-ref-check.ts +0 -248
  147. package/src/scene/component-registry.ts +0 -51
  148. package/src/scene/parse.ts +0 -204
  149. package/src/scene/scene-apply.ts +0 -407
  150. package/src/scene/scene-diff-schema.ts +0 -115
  151. package/src/scene/scene-diff-types.ts +0 -29
  152. package/src/scene/scene-loader.ts +0 -1526
  153. package/src/scene/scene-query.ts +0 -63
  154. package/src/scene/scene-types.ts +0 -33
  155. package/src/scene/scene-version.ts +0 -40
  156. package/src/scene/schema/animation.ts +0 -46
  157. package/src/scene/schema/audio.ts +0 -25
  158. package/src/scene/schema/entity-ref.ts +0 -78
  159. package/src/scene/schema/entity.ts +0 -189
  160. package/src/scene/schema/index.ts +0 -51
  161. package/src/scene/schema/joint.ts +0 -26
  162. package/src/scene/schema/physics.ts +0 -49
  163. package/src/scene/schema/scene-file.ts +0 -292
  164. package/src/scene/schema/shadow.ts +0 -24
  165. package/src/scene/schema/spline.ts +0 -21
  166. package/src/world2d/asset-paths2d.ts +0 -44
  167. package/src/world2d/capture-to-scene2d.ts +0 -52
  168. package/src/world2d/collision-2d.ts +0 -99
  169. package/src/world2d/entity2d-asset.ts +0 -22
  170. package/src/world2d/index.ts +0 -91
  171. package/src/world2d/physics2d-transform.ts +0 -173
  172. package/src/world2d/physics2d-units.ts +0 -10
  173. package/src/world2d/pixi-game-adapter.ts +0 -439
  174. package/src/world2d/pixi-surface.ts +0 -78
  175. package/src/world2d/scene2d-identity.ts +0 -49
  176. package/src/world2d/scene2d-loader.ts +0 -433
  177. package/src/world2d/schema/entity2d.ts +0 -163
  178. package/src/world2d/schema/physics2d.ts +0 -64
  179. package/src/world2d/schema/sprite.ts +0 -99
  180. package/src/world2d/schema/tilemap.ts +0 -39
  181. package/src/world2d/schema/tuples2d.ts +0 -25
  182. package/src/world2d/transform-writer-2d.ts +0 -42
  183. package/src/world2d/types.ts +0 -74
  184. package/src/world3d-react/behavior.tsx +0 -146
  185. /package/src/{scene → render}/mesh-shadow.ts +0 -0
@@ -0,0 +1,388 @@
1
+ /**
2
+ * `SimClock` — P3, the sim-time scheduler (punchlist item P3, rung 4).
3
+ *
4
+ * `await` **is** the scheduler; JS already won that argument. The only thing
5
+ * missing from the engine was *clock binding*: a `delay(seconds)` that resolves
6
+ * on the fixed loop's own accumulator instead of on wall time, so a timer
7
+ * pauses when the game pauses, steps when the game steps, and reproduces
8
+ * exactly in an offline export. That, plus a timed-dispose helper for debris,
9
+ * is this whole module.
10
+ *
11
+ * ## This is NOT `AnimationClock`, and it never will be
12
+ *
13
+ * `animation/animation-clock.ts` is a seekable **cinematic** clock: it plays,
14
+ * pauses, loops, runs in reverse and is `seek()`ed to arbitrary times so
15
+ * Theatre/GSAP timelines and cue evaluators can be scrubbed. `SimClock` is the
16
+ * **monotonic gameplay clock**: it only ever moves forward, one fixed substep
17
+ * at a time, and there is no seek. Both live in one engine because they answer
18
+ * different questions — "where is the cinematic playhead" versus "how much
19
+ * gameplay time has actually elapsed".
20
+ *
21
+ * The reason they can never merge is a fact about Promises: **a Promise cannot
22
+ * un-resolve.** Scrub a seekable clock backwards past a `delay` that already
23
+ * fired and there is no correct behavior — you cannot un-await it. So `delay`
24
+ * binds to the clock that cannot rewind, and cinematic scrubbing stays with the
25
+ * clock that can.
26
+ *
27
+ * ## Two forms, because sync and async are genuinely different here
28
+ *
29
+ * `Game.runTicks` is **synchronous** (`runtime/game.ts`): it runs N substeps in
30
+ * one straight-line loop. A `delay` continuation is a microtask, so under
31
+ * `runTicks` — and under an offline export driven the same way — it does *not*
32
+ * interleave between ticks; it runs when the caller's stack unwinds, after the
33
+ * whole run. Under the ordinary rAF loop each frame yields, so the difference
34
+ * is invisible.
35
+ *
36
+ * Therefore:
37
+ *
38
+ * - `await clock.delay(n)` is the **ergonomic** form. Use it for gameplay.
39
+ * - `clock.after(n, fn)` is the **exact** form: `fn` is invoked inline, during
40
+ * the flush of the exact due tick, under every driver — rAF, `runTicks`,
41
+ * `step()`, offline export.
42
+ * - `clock.disposeAfter(obj, n)` is built on `after`, so debris removal is
43
+ * frame-exact in an offline export rather than "some time after the run".
44
+ *
45
+ * ## Firing rule (deterministic — pinned by `test/sim-clock.test.ts`)
46
+ *
47
+ * - The runtime calls `flush(simT)` immediately after it bumps `tick`/`simT`,
48
+ * inside the same `advanced` guard — so a paused or frozen world fires no
49
+ * timers at all, and `Game.play.step()` fires exactly the timers that one
50
+ * substep makes due.
51
+ * - A timer is due when `simT >= scheduledAt + seconds`.
52
+ * - Due timers run in due-time ascending order, insertion-sequence ascending
53
+ * on ties.
54
+ * - A timer scheduled *during* a flush is never due in that same flush, even
55
+ * at `seconds === 0`. That is what makes a self-rescheduling timer unable to
56
+ * hang the frame.
57
+ *
58
+ * ## Cancellation is `AbortSignal`
59
+ *
60
+ * The same rung-1 web primitive the rest of this program uses. An
61
+ * already-aborted signal makes `delay` reject immediately and `after` a no-op;
62
+ * rejection is a `DOMException` with `name === 'AbortError'`, matching `fetch`.
63
+ * Disposing the clock rejects every pending `delay` the same way — game code
64
+ * awaiting a delay is expected to tolerate rejection exactly as aborted `fetch`
65
+ * callers do.
66
+ *
67
+ * ## Ownership: the clock is GAME-scoped, and only the Game disposes it
68
+ *
69
+ * One clock per `Game`, shared by every world on it (`getSimClock(game)`); a
70
+ * mount with no Game shell builds a private, mount-local one instead. Whoever
71
+ * CREATED a clock disposes it: `GameInternal.dispose()` disposes the
72
+ * game-scoped one — `runtime/create-runtime.ts` calls that after every root has
73
+ * torn down — and a root adapter disposes only its own mount-local fallback.
74
+ * A per-root teardown must NEVER dispose the game-scoped clock: disposing one
75
+ * world's `mounted` is a supported way to end a sub-session while the Game
76
+ * keeps running, and doing so would freeze `now()`, reject every sibling
77
+ * world's pending `delay` and turn its later `after()` calls into silent
78
+ * no-ops. (`seededRandom` and `debugRegistry` are game-scoped the same way, and
79
+ * per-root teardown has never destroyed either — that asymmetry is what
80
+ * exposed the bug.)
81
+ *
82
+ * ## Cancelling your own timers is the GAME's job — warm restart will not
83
+ *
84
+ * A timer outlives the code that scheduled it unless something cancels it, and
85
+ * `hotReload` (warm restart) deliberately does not reach the game-scoped clock:
86
+ * it is per-ROOT, so cancelling here would kill a sibling root's live timers —
87
+ * the same ownership violation as above. There is deliberately no `reset()`.
88
+ *
89
+ * So a stale `after` closure surviving a warm restart is the same hazard class
90
+ * as a stale event listener, and it has the same owner and the same remedy: the
91
+ * game's own cleanup. `hotReload` runs the game's `dispose()` first for exactly
92
+ * this reason, and both `after` and `delay` take an `AbortSignal` so one
93
+ * controller cancels everything a `setup()` scheduled:
94
+ *
95
+ * ```ts
96
+ * export async function setup(ctx: GameContext): Promise<GameCleanup> {
97
+ * const ac = new AbortController();
98
+ * ctx.clock.after(3, () => spawnWave(), { signal: ac.signal });
99
+ * return { dispose: () => ac.abort() }; // cancels timers AND listeners
100
+ * }
101
+ * ```
102
+ *
103
+ * This is the engine-wide "Examples must clean up" contract (CLAUDE.md), not a
104
+ * special rule for timers.
105
+ *
106
+ * ## Deliberately absent
107
+ *
108
+ * - **No `every`/`repeat`/interval.** Nothing needs one, and `after`
109
+ * re-arming itself is three lines the game owns.
110
+ * - **No fiber kernel, no coroutine emulation, no `task.spawn`.** Calling an
111
+ * `async function` *is* `task.spawn` — the language already has it.
112
+ * - **No wall clock.** This module reads no system timer and creates no
113
+ * host timeout; every time value comes from the runtime's own accumulator.
114
+ */
115
+
116
+ import type * as THREE from 'three';
117
+
118
+ /** Cancel handle returned by {@link SimClock.after}/{@link SimClock.disposeAfter}. */
119
+ export interface SimTimerHandle {
120
+ /** Cancel the timer if it has not fired yet. Idempotent. */
121
+ cancel(): void;
122
+ }
123
+
124
+ /** Options accepted by every scheduling call. */
125
+ export interface SimScheduleOptions {
126
+ /** Cancel/reject when this signal aborts (already-aborted is honored too). */
127
+ readonly signal?: AbortSignal | undefined;
128
+ }
129
+
130
+ /** The sim clock as GAME CODE sees it — the whole public surface. */
131
+ export interface SimClock {
132
+ /** Sim seconds elapsed: the fixed loop's own accumulator. Monotonic. */
133
+ now(): number;
134
+ /** Completed fixed substeps — the same counter `Game`'s `tick` carries. */
135
+ tickNow(): number;
136
+ /**
137
+ * Resolve after `seconds` of SIM time. The ergonomic form; see the module
138
+ * header for why it is not frame-exact under a synchronous driver.
139
+ * Rejects with an `AbortError` `DOMException` if `opts.signal` aborts (or is
140
+ * already aborted), or if the clock is disposed while it is pending.
141
+ */
142
+ delay(seconds: number, opts?: SimScheduleOptions): Promise<void>;
143
+ /**
144
+ * Invoke `fn` inline during the flush of the exact due tick — the exact
145
+ * form. A no-op if `opts.signal` is already aborted.
146
+ */
147
+ after(seconds: number, fn: () => void, opts?: SimScheduleOptions): SimTimerHandle;
148
+ /**
149
+ * Debris: dispose `obj` after `seconds` of sim time. Built on {@link after},
150
+ * so removal lands on an exact tick. The disposal itself is supplied by the
151
+ * runtime (see {@link SimClockOptions.dispose}) — this module knows nothing
152
+ * about physics or rendering.
153
+ */
154
+ disposeAfter(obj: THREE.Object3D, seconds: number): SimTimerHandle;
155
+ }
156
+
157
+ /**
158
+ * The runtime-facing half — NOT for game code. Split off the public
159
+ * {@link SimClock} the same way `runtime/game.ts` splits `GameInternal` off
160
+ * `Game`: `GameContext.clock` is typed as `SimClock`, so game code cannot
161
+ * reach `flush`/`dispose`.
162
+ */
163
+ export interface SimClockInternal extends SimClock {
164
+ /**
165
+ * Advance to `simT` and fire everything now due. Called by `runFrameImpl`
166
+ * immediately after the `tick`/`simT` bump, inside the `advanced` guard.
167
+ * One call === one completed substep, which is why {@link SimClock.tickNow}
168
+ * can simply count them.
169
+ */
170
+ flush(simT: number): void;
171
+ /**
172
+ * Drop every pending timer and reject every pending `delay`. Called by
173
+ * whoever CREATED this clock and by nobody else — `GameInternal.dispose()`
174
+ * for the game-scoped one, the owning mount for a mount-local fallback. See
175
+ * the module header's ownership section.
176
+ */
177
+ dispose(): void;
178
+ }
179
+
180
+ export interface SimClockOptions {
181
+ /**
182
+ * How {@link SimClock.disposeAfter} disposes an object. Supplied by the
183
+ * runtime, which is the only layer that knows about physics
184
+ * registries and shared geometry; the clock only knows *when*.
185
+ */
186
+ readonly dispose: (obj: THREE.Object3D) => void;
187
+ }
188
+
189
+ interface SimTimer {
190
+ readonly dueAt: number;
191
+ readonly seq: number;
192
+ readonly fn: () => void;
193
+ cancelled: boolean;
194
+ detachAbort: (() => void) | null;
195
+ }
196
+
197
+ const NOOP_HANDLE: SimTimerHandle = { cancel() {} };
198
+
199
+ /** `fetch`-shaped abort rejection: a `DOMException` named `AbortError`. */
200
+ function abortError(message: string): DOMException {
201
+ return new DOMException(message, 'AbortError');
202
+ }
203
+
204
+ function assertSeconds(method: string, seconds: number): void {
205
+ if (!Number.isFinite(seconds) || seconds < 0) {
206
+ throw new RangeError(
207
+ `SimClock.${method}: seconds must be a finite, non-negative number, got ${seconds}`,
208
+ );
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Build the one sim clock a `Game` owns. See the module header for the
214
+ * contract; `runtime/game.ts` is the only production caller.
215
+ */
216
+ export function createSimClock(options: SimClockOptions): SimClockInternal {
217
+ const disposeObject = options.dispose;
218
+
219
+ let simT = 0;
220
+ let ticks = 0;
221
+ let seq = 0;
222
+ let disposed = false;
223
+
224
+ const pending = new Set<SimTimer>();
225
+ /** Rejectors for in-flight `delay`s, so `dispose()` can settle them all. */
226
+ const pendingDelays = new Set<(message: string) => void>();
227
+
228
+ function cancelTimer(timer: SimTimer): void {
229
+ if (timer.cancelled) return;
230
+ timer.cancelled = true;
231
+ pending.delete(timer);
232
+ timer.detachAbort?.();
233
+ timer.detachAbort = null;
234
+ }
235
+
236
+ function after(seconds: number, fn: () => void, opts?: SimScheduleOptions): SimTimerHandle {
237
+ assertSeconds('after', seconds);
238
+ const signal = opts?.signal;
239
+ // Already-aborted, or a clock that is gone: a no-op, never a throw.
240
+ if (disposed || signal?.aborted) return NOOP_HANDLE;
241
+
242
+ const timer: SimTimer = {
243
+ dueAt: simT + seconds,
244
+ seq: seq++,
245
+ fn,
246
+ cancelled: false,
247
+ detachAbort: null,
248
+ };
249
+ pending.add(timer);
250
+
251
+ if (signal) {
252
+ const onAbort = (): void => cancelTimer(timer);
253
+ signal.addEventListener('abort', onAbort, { once: true });
254
+ timer.detachAbort = (): void => signal.removeEventListener('abort', onAbort);
255
+ }
256
+
257
+ return {
258
+ cancel(): void {
259
+ cancelTimer(timer);
260
+ },
261
+ };
262
+ }
263
+
264
+ return {
265
+ now: (): number => simT,
266
+ tickNow: (): number => ticks,
267
+ after,
268
+
269
+ delay(seconds: number, opts?: SimScheduleOptions): Promise<void> {
270
+ assertSeconds('delay', seconds);
271
+ const signal = opts?.signal;
272
+ if (signal?.aborted) {
273
+ return Promise.reject(abortError('SimClock.delay: aborted before it was scheduled'));
274
+ }
275
+ if (disposed) {
276
+ return Promise.reject(abortError('SimClock.delay: the sim clock is disposed'));
277
+ }
278
+ return new Promise<void>((resolve, reject) => {
279
+ let handle: SimTimerHandle | null = null;
280
+ let detachAbort: (() => void) | null = null;
281
+ let settled = false;
282
+
283
+ const finish = (): void => {
284
+ settled = true;
285
+ pendingDelays.delete(rejectDelay);
286
+ detachAbort?.();
287
+ detachAbort = null;
288
+ };
289
+ function rejectDelay(message: string): void {
290
+ if (settled) return;
291
+ finish();
292
+ handle?.cancel();
293
+ reject(abortError(message));
294
+ }
295
+
296
+ handle = after(seconds, () => {
297
+ if (settled) return;
298
+ finish();
299
+ resolve();
300
+ });
301
+ pendingDelays.add(rejectDelay);
302
+
303
+ if (signal) {
304
+ const onAbort = (): void => rejectDelay('SimClock.delay: aborted');
305
+ signal.addEventListener('abort', onAbort, { once: true });
306
+ detachAbort = (): void => signal.removeEventListener('abort', onAbort);
307
+ }
308
+ });
309
+ },
310
+
311
+ disposeAfter(obj: THREE.Object3D, seconds: number): SimTimerHandle {
312
+ assertSeconds('disposeAfter', seconds);
313
+ return after(seconds, () => disposeObject(obj));
314
+ },
315
+
316
+ flush(nextSimT: number): void {
317
+ if (disposed) return;
318
+ simT = nextSimT;
319
+ ticks++;
320
+ if (pending.size === 0) return;
321
+
322
+ // Snapshot BEFORE running anything: a timer scheduled by one of these
323
+ // callbacks lands in `pending` but not in this batch, so it can never be
324
+ // due in the same flush (module header, firing rule 4).
325
+ const batch: SimTimer[] = [];
326
+ for (const timer of pending) {
327
+ if (!timer.cancelled && timer.dueAt <= simT) batch.push(timer);
328
+ }
329
+ if (batch.length === 0) return;
330
+ batch.sort((a, b) => a.dueAt - b.dueAt || a.seq - b.seq);
331
+
332
+ for (const timer of batch) {
333
+ // A previously-fired callback may have cancelled this one.
334
+ if (timer.cancelled) continue;
335
+ cancelTimer(timer);
336
+ try {
337
+ timer.fn();
338
+ } catch (err) {
339
+ // biome-ignore lint/suspicious/noConsole: loud degrade — one bad timer must not abort the frame's remaining timers (same isolation idiom as `runFrameImpl`'s per-world try/catch)
340
+ console.error('[sim-clock] timer callback threw:', err);
341
+ }
342
+ }
343
+ },
344
+
345
+ dispose(): void {
346
+ if (disposed) return;
347
+ disposed = true;
348
+ for (const timer of pending) {
349
+ timer.detachAbort?.();
350
+ timer.detachAbort = null;
351
+ timer.cancelled = true;
352
+ }
353
+ pending.clear();
354
+ const rejectors = [...pendingDelays];
355
+ pendingDelays.clear();
356
+ for (const rejectDelay of rejectors) {
357
+ rejectDelay('SimClock: disposed while a delay was pending');
358
+ }
359
+ },
360
+ };
361
+ }
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // Game-scoped registry — mirrors `core/seeded-random.ts`'s
365
+ // `registerSeededRandom`/`getSeededRandom` WeakMap pattern exactly, keyed on a
366
+ // bare `object` (not `Game`) so `core/` never imports `runtime/`. `createGame`
367
+ // is the one real registrant, passing the `GameInternal` shell as the key.
368
+ // ---------------------------------------------------------------------------
369
+
370
+ const clockByOwner = new WeakMap<object, SimClockInternal>();
371
+
372
+ /** Called once by `createGame`, right after the clock and the Game shell exist. */
373
+ export function registerSimClock(owner: object, clock: SimClockInternal): void {
374
+ clockByOwner.set(owner, clock);
375
+ }
376
+
377
+ /** The game-scoped clock backing every world's `ctx.clock` — `null` for an
378
+ * owner built without one (a hand-built `Game`-shaped stand-in that never went
379
+ * through `createGame`).
380
+ *
381
+ * Returns the INTERNAL view because this registry is engine-only — but a
382
+ * caller that RESOLVES a clock here is not its owner and must not call
383
+ * `dispose()` on it (module header, ownership). Game code never reaches it at
384
+ * all: `GameContext.clock` is typed as the public {@link SimClock}, which has
385
+ * neither `flush` nor `dispose`, the same way `Game` hides `GameInternal`. */
386
+ export function getSimClock(owner: object): SimClockInternal | null {
387
+ return clockByOwner.get(owner) ?? null;
388
+ }
@@ -12,22 +12,12 @@ import {
12
12
  *
13
13
  * Systems are registered with a phase name. When `run(dt)` is called, all
14
14
  * phases execute in `PHASE_ORDER` via `runPhase(phase, dt)`. Within a phase,
15
- * `runPhase` executes three ordered buckets (GAME-ROOT-DESIGN.md §4, T7.1
16
- * slice 2):
15
+ * `runPhase` executes two ordered buckets (T7.1 slice 2):
17
16
  *
18
17
  * 1. **engine** — everything `add()`/`register()`ed at or before the last
19
18
  * `markEngineBoundary()` call, or EVERYTHING if no boundary has ever
20
- * been marked (the world2d-silo case: it never calls
21
- * `markEngineBoundary`, so its systems are all "engine").
22
- * 2. **componentTick** — the single per-phase slot set via
23
- * `setComponentTick(phase, fn)`. Component ticks are engine
24
- * infrastructure: they always run after every engine system and before
25
- * every game system in that phase, REGARDLESS of when
26
- * `setComponentTick` was called relative to other `add()` calls. This
27
- * is what makes "engine systems run before component ticks" a
28
- * structural guarantee rather than a registration-order convention
29
- * (see `component-manager.ts`, which registers its tick this way).
30
- * 3. **game** — everything `add()`/`register()`ed AFTER the last
19
+ * been marked.
20
+ * 2. **game** everything `add()`/`register()`ed AFTER the last
31
21
  * `markEngineBoundary()` call.
32
22
  *
33
23
  * Within each bucket, systems run in registration order.
@@ -35,14 +25,12 @@ import {
35
25
  export function createSystemRunner(observer?: SystemRunObserver, scope = 'world') {
36
26
  const systems = new Map<SystemPhaseName, SystemFn[]>();
37
27
  const registered: SystemDef[] = [];
38
- const componentTicks = new Map<SystemPhaseName, SystemFn | null>();
39
28
  const labels = new Map<SystemFn, string>();
40
29
  let anonymousId = 0;
41
30
 
42
- // Initialize all phases with empty arrays / no component tick.
31
+ // Initialize all phases with empty arrays.
43
32
  for (const phase of PHASE_ORDER) {
44
33
  systems.set(phase, []);
45
- componentTicks.set(phase, null);
46
34
  }
47
35
 
48
36
  // Membership bookkeeping for `markEngineBoundary()` (T7.2 review fix —
@@ -53,11 +41,7 @@ export function createSystemRunner(observer?: SystemRunObserver, scope = 'world'
53
41
  // specific function/system was present at the last `markEngineBoundary()`
54
42
  // call — membership survives arbitrary removal of any other entry,
55
43
  // regardless of position. `boundaryMarked` is false until the first mark
56
- // (mirrors the old `boundary === null`: everything is "engine" pre-mark
57
- // the world2d-silo case, which never marks a boundary). Component-tick
58
- // slots (`setComponentTick`) are NOT part of this bookkeeping at all — they
59
- // are engine infrastructure and survive `removeAllNonEngine()`
60
- // unconditionally.
44
+ // (mirrors the old `boundary === null`: everything is "engine" pre-mark).
61
45
  let boundaryMarked = false;
62
46
  const engineFns = new Map<SystemPhaseName, Set<SystemFn>>();
63
47
  for (const phase of PHASE_ORDER) engineFns.set(phase, new Set());
@@ -137,29 +121,6 @@ export function createSystemRunner(observer?: SystemRunObserver, scope = 'world'
137
121
  system.dispose?.();
138
122
  },
139
123
 
140
- /**
141
- * Register the ONE component-tick function for a phase (T7.1 slice 2).
142
- * Component ticks are engine infrastructure, not a bare `add()`ed
143
- * system: they always execute after every engine-bucket system and
144
- * before every game-bucket system in that phase (see `runPhase`),
145
- * they are never touched by `removeAllNonEngine()`, and they do not
146
- * count toward `markEngineBoundary()`'s bookkeeping. Throws if a tick
147
- * is already set for this phase — at most one component manager may
148
- * own a phase's tick slot.
149
- */
150
- setComponentTick(phase: SystemPhaseName, fn: SystemFn) {
151
- if (!componentTicks.has(phase)) {
152
- throw new Error(`Unknown phase: ${phase}. Valid phases: ${PHASE_ORDER.join(', ')}`);
153
- }
154
- if (componentTicks.get(phase)) {
155
- throw new Error(
156
- `[system-runner] setComponentTick: phase "${phase}" already has a component tick registered`,
157
- );
158
- }
159
- componentTicks.set(phase, fn);
160
- labels.set(fn, `components.${phase}`);
161
- },
162
-
163
124
  /**
164
125
  * Call init() on all registered lifecycle systems, in phase order.
165
126
  * Call once after scene load, before the first game loop tick.
@@ -189,7 +150,7 @@ export function createSystemRunner(observer?: SystemRunObserver, scope = 'world'
189
150
  },
190
151
 
191
152
  /**
192
- * Run ONE phase's three ordered buckets — engine, componentTick, game
153
+ * Run ONE phase's two ordered buckets — engine, game
193
154
  * (see the module doc comment). Each system call is isolated: a
194
155
  * throwing system is loudly logged (never swallowed) but does not stop
195
156
  * its siblings in the same bucket, a later bucket in this phase, or a
@@ -197,7 +158,7 @@ export function createSystemRunner(observer?: SystemRunObserver, scope = 'world'
197
158
  *
198
159
  * Public — the Game root's frame executor (`runtime/game.ts`) calls this
199
160
  * directly per (phase, world); `run(dt)` below is just a loop over it,
200
- * preserved for the world2d silo and any direct caller.
161
+ * preserved for any direct caller.
201
162
  */
202
163
  runPhase(phase: SystemPhaseName, dt: number) {
203
164
  const list = systems.get(phase);
@@ -205,10 +166,10 @@ export function createSystemRunner(observer?: SystemRunObserver, scope = 'world'
205
166
  throw new Error(`Unknown phase: ${phase}. Valid phases: ${PHASE_ORDER.join(', ')}`);
206
167
  }
207
168
  // Partition by MEMBERSHIP, not position: everything in this phase's
208
- // `engineFns` set (or, if no boundary has ever been marked, everything
209
- // the world2d-silo case) runs immediately, in list order, as it's
169
+ // `engineFns` set (or, if no boundary has ever been marked, everything)
170
+ // runs immediately, in list order, as it's
210
171
  // encountered; everything else is queued into `gameFns` (also in list
211
- // order) and run after the component tick. Because classification is
172
+ // order) and run after them. Because classification is
212
173
  // per-function rather than "index < some remembered length", removing
213
174
  // ANY entry via `remove()` — including a pre-boundary "engine" one —
214
175
  // cannot shift a later game system into the engine bucket.
@@ -222,9 +183,6 @@ export function createSystemRunner(observer?: SystemRunObserver, scope = 'world'
222
183
  }
223
184
  }
224
185
 
225
- const tick = componentTicks.get(phase);
226
- if (tick) runOne(tick, phase, dt);
227
-
228
186
  for (const fn of gameFns) {
229
187
  runOne(fn, phase, dt);
230
188
  }
@@ -245,8 +203,7 @@ export function createSystemRunner(observer?: SystemRunObserver, scope = 'world'
245
203
  * Call once, right after mount finishes wiring engine-level systems and
246
204
  * BEFORE any game (`setup()`/scene load) registers its own. `hotReload`
247
205
  * calls `removeAllNonEngine()` on every restart, which only ever removes
248
- * what was added after this mark. Component-tick slots (`setComponentTick`)
249
- * are unaffected — they are never part of this boundary.
206
+ * what was added after this mark.
250
207
  */
251
208
  markEngineBoundary() {
252
209
  for (const phase of PHASE_ORDER) {
@@ -262,14 +219,13 @@ export function createSystemRunner(observer?: SystemRunObserver, scope = 'world'
262
219
  * call — both bare `add()`ed functions and lifecycle `register()`ed systems
263
220
  * (the latter get `dispose()`d, mirroring `unregister()`). A no-op if no
264
221
  * boundary has been marked. Idempotent: calling it again with nothing new
265
- * registered since is a safe no-op. Component-tick slots are NEVER cleared
266
- * by this — they are engine infrastructure, not game content.
222
+ * registered since is a safe no-op.
267
223
  *
268
224
  * This is what makes a warm restart (`hotReload`) safe to call N times
269
225
  * without accumulating duplicate systems/listeners — each restart's
270
226
  * outgoing game systems are fully removed before the new one registers its
271
- * own, while the engine systems (input/physics/render/the ComponentManager
272
- * tick/...) registered before the boundary are never touched.
227
+ * own, while the engine systems (input/physics/render/...) registered
228
+ * before the boundary are never touched.
273
229
  */
274
230
  removeAllNonEngine() {
275
231
  if (!boundaryMarked) return;
@@ -293,19 +249,17 @@ export function createSystemRunner(observer?: SystemRunObserver, scope = 'world'
293
249
  },
294
250
 
295
251
  /**
296
- * Total system-function count, optionally scoped to one phase. Includes
297
- * component-tick slots (a set slot counts as 1 per phase it occupies).
252
+ * Total system-function count, optionally scoped to one phase.
298
253
  * Test/introspection helper — used to assert a warm restart doesn't
299
254
  * accumulate systems (see `removeAllNonEngine`).
300
255
  */
301
256
  count(phase?: SystemPhaseName): number {
302
257
  if (phase) {
303
- return (systems.get(phase)?.length ?? 0) + (componentTicks.get(phase) ? 1 : 0);
258
+ return systems.get(phase)?.length ?? 0;
304
259
  }
305
260
  let total = 0;
306
- for (const [p, list] of systems) {
261
+ for (const list of systems.values()) {
307
262
  total += list.length;
308
- if (componentTicks.get(p)) total += 1;
309
263
  }
310
264
  return total;
311
265
  },
package/src/core/types.ts CHANGED
@@ -60,24 +60,43 @@ export interface GameLoopConfig {
60
60
  /** Max physics substeps per frame to prevent spiral of death (default: 8) */
61
61
  maxSubSteps?: number;
62
62
  /**
63
- * Called once per consumed fixed substep. Rendering (the `render` phase,
64
- * last in `PHASE_ORDER`) runs *inside* this call rendering is
65
- * deliberately fixed-rate (decided — D1, `docs/DECISIONS-PENDING.md`),
66
- * not a separate per-real-frame step. A real frame whose accumulator
67
- * produces zero substeps calls `update` zero times and renders zero times.
63
+ * Called once per consumed fixed substep the SIM half. A real frame whose
64
+ * accumulator produces zero substeps calls this zero times, which is exactly
65
+ * why {@link GameLoopConfig.render} exists.
66
+ *
67
+ * Whether the `render`/`preRender` phases run inside this call is the
68
+ * CALLER's choice, not this loop's: a host that also passes `render` below
69
+ * asks `Game.runFrame` to skip them (`skipRenderPhases`), a host that does
70
+ * not keeps them here as the tail of `PHASE_ORDER`. See `game-loop.ts`'s
71
+ * module doc.
68
72
  */
69
73
  update: (dt: number) => void;
70
74
  /**
71
- * External-drive mode (I2, `docs/AI-NATIVE-AUTHORING-IMPLEMENTATION-SPEC.md`
72
- * §15 I2): when `true`, `start()` never arms a `requestAnimationFrame`
73
- * chain and never installs the `visibilitychange`/`document.hidden`
74
- * auto-stop handler — so a headless/backgrounded capture page cannot have
75
- * its loop silently killed by the tab-hidden guard, and `config.update`
76
- * is never invoked by wall-clock/RAF timing at all. The caller (a render
77
- * harness) drives frames itself instead — see `runtime/render-control.ts`,
78
- * which calls a world's phase hooks directly rather than going through
79
- * this loop's `update` callback. Default `false` (normal wall-clock RAF
80
- * playback, unchanged).
75
+ * Called EXACTLY ONCE per real (rAF) frame — the PRESENTATION half
76
+ * (WO-P13, `docs/WORK.md` §2 P13; this is the callback whose absence the
77
+ * former D1 decision recorded).
78
+ *
79
+ * - `alpha` `accumulator / fixedDt` after this frame's substeps were
80
+ * consumed, in `[0, 1]`: how far presentation sits past the last
81
+ * completed fixed state. `core/frame-pacing.ts` computes it.
82
+ * - `displayDt` this display frame's own delta in seconds, clamped and
83
+ * `timeScale`d exactly like the sim's time. Integrate per-display-frame
84
+ * motion against this, never against `fixedTimestep`.
85
+ *
86
+ * Optional. Never invoked at all in {@link GameLoopConfig.externalDrive}
87
+ * mode, where `frame()` itself never runs.
88
+ */
89
+ render?: (alpha: number, displayDt: number) => void;
90
+ /**
91
+ * External-drive mode (I2): when `true`, `start()` never arms a
92
+ * `requestAnimationFrame` chain and never installs the
93
+ * `visibilitychange`/`document.hidden` auto-stop handler — so a
94
+ * headless/backgrounded capture page cannot have its loop silently killed by
95
+ * the tab-hidden guard, and `config.update` is never invoked by
96
+ * wall-clock/RAF timing at all. The caller (a render harness) drives frames
97
+ * itself instead — see `runtime/render-control.ts`, which calls a world's
98
+ * phase hooks directly rather than going through this loop's `update`
99
+ * callback. Default `false` (normal wall-clock RAF playback, unchanged).
81
100
  */
82
101
  externalDrive?: boolean;
83
102
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Data assets — W1 of `docs/DATA-TOOLS-DESIGN.md` (§2.3 consumption API).
2
+ * Data assets — W1 (§2.3 consumption API).
3
3
  *
4
4
  * A data asset is designer-tunable game data: a JSON file in the project's
5
5
  * `src/data/`, governed by a Zod schema (`*.schema.ts`, engineer-owned) whose
@@ -71,7 +71,7 @@ export interface DataHandle<T> {
71
71
  * the file, every bad path, and the fix (§2.3, §6.5 "errors teach").
72
72
  *
73
73
  * Schema fields should carry `.min/.max/.default/.describe` — the same
74
- * annotations `GameComponent.static schema` uses — so the emitted JSON Schema
74
+ * annotations every authored schema uses — so the emitted JSON Schema
75
75
  * documents the file and (W2+) the editor renders real widgets.
76
76
  */
77
77
  export function defineData<S extends z.ZodType>(
@@ -94,7 +94,7 @@ export function defineData<S extends z.ZodType>(
94
94
  parsed = parseDataJson(schema, next, sourcePath);
95
95
  } catch (err) {
96
96
  // Keep the last good value alive — see the JSDoc on DataHandle.hotSwap.
97
- // biome-ignore lint/suspicious/noConsole: deliberate, greppable — a rejected live-tune edit must be loud without crashing the running game (mirrors overlay-report.ts's direct console use)
97
+ // biome-ignore lint/suspicious/noConsole: deliberate, greppable — a rejected live-tune edit must be loud without crashing the running game (mirrors achieved-tier.ts's direct console use)
98
98
  console.error(`[data] hot edit rejected, keeping previous values.\n${String(err)}`);
99
99
  return;
100
100
  }
@@ -1,11 +1,10 @@
1
1
  /**
2
- * Data-asset integrity core — the PURE half of W5 enforcement
3
- * (docs/DATA-TOOLS-DESIGN.md §6.6 "dangling `file#key` ref" / §6.7 build-path
4
- * validation). No filesystem, no vite, no zod: plain parsed-JSON in, findings
5
- * out, so BOTH consumers — `vgai doctor`'s data checks
6
- * (`packages/editor/src/doctor/data-checks.ts`) and the build-time plugin
7
- * (`./vite-plugin-data.ts`) share one definition of "what is a ref" and
8
- * "what counts as dangling" and can never drift apart.
2
+ * Data-asset integrity core — the PURE half of W5 enforcement ("dangling
3
+ * `file#key` ref" / §6.7 build-path validation). No filesystem, no vite, no
4
+ * zod: plain parsed-JSON in, findings out, so BOTH consumers — `vgai
5
+ * doctor`'s data checks (`packages/editor/src/doctor/data-checks.ts`) and the
6
+ * build-time plugin (`./vite-plugin-data.ts`) share one definition of "what
7
+ * is a ref" and "what counts as dangling" and can never drift apart.
9
8
  *
10
9
  * What is a ref (§2.2): a cross-asset reference is a plain string
11
10
  * `"file#key"` — optionally with a field path, `"tuning#economy.slotGrowth"`