@volter/editor-game 0.5.66 → 0.5.67

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 (236) hide show
  1. package/contributions/audio-unlock.service.ts +2 -2
  2. package/contributions/autoplay.service.ts +1 -1
  3. package/contributions/bridge.command.ts +3 -3
  4. package/contributions/canvas/component-board.service.ts +16 -0
  5. package/contributions/canvas/design-time-mount.service.ts +45 -0
  6. package/contributions/canvas-story-capture.service.ts +12 -0
  7. package/contributions/edit-mode-audio.service.ts +2 -2
  8. package/contributions/edit-mode-networking.service.ts +1 -1
  9. package/contributions/gameplay.command.ts +4 -4
  10. package/contributions/generation.service.ts +1 -1
  11. package/contributions/godot.style.ts +26 -6
  12. package/contributions/godot.view.ts +6 -0
  13. package/contributions/ingest.service.ts +2 -2
  14. package/contributions/instances.command.ts +1 -1
  15. package/contributions/navmesh.menu.ts +1 -1
  16. package/contributions/network-observer.service.ts +14 -0
  17. package/contributions/play.command.ts +10 -4
  18. package/contributions/react/component-board.service.ts +1 -1
  19. package/contributions/react/design-time-mount.service.ts +1 -1
  20. package/contributions/scene-document.service.ts +3 -3
  21. package/contributions/state-watch.menu.ts +1 -1
  22. package/contributions/state-watch.utility.tsx +1 -1
  23. package/contributions/team-playtest.service.ts +4 -4
  24. package/contributions/three/component-board.service.ts +1 -1
  25. package/contributions/three/component-verbs.command.ts +6 -6
  26. package/contributions/three/three-authoring.service.ts +8 -5
  27. package/contributions/three-story-capture.service.ts +12 -0
  28. package/contributions/unity.style.ts +17 -7
  29. package/contributions/unity.view.ts +6 -0
  30. package/contributions/unreal.style.ts +10 -2
  31. package/contributions/unreal.view.ts +5 -0
  32. package/package.json +20 -10
  33. package/src/asset-budget/AssetBudgetPanel.tsx +1 -1
  34. package/src/asset-budget/asset-budget-model.ts +1 -1
  35. package/src/audio/AudioDebuggerPanel.tsx +1 -1
  36. package/src/bridge/dispatch.ts +14 -14
  37. package/src/bridge/live-frames.ts +1 -1
  38. package/src/bridge/screenshot.ts +3 -3
  39. package/src/build/BuildProfilesPanel.tsx +3 -3
  40. package/src/canvas/canvas-board/CanvasBoardDocument.tsx +676 -0
  41. package/src/canvas/canvas-board/canvas-board-model.ts +407 -0
  42. package/src/canvas/canvas-board/canvas-component-board.ts +56 -0
  43. package/src/canvas/canvas-design-mount.ts +524 -0
  44. package/src/canvas/design-time-canvas-mount.ts +79 -0
  45. package/src/coverage/live-authoring-surface.ts +5 -5
  46. package/src/coverage/live-project-verbs.ts +1 -1
  47. package/src/coverage/native-system-coverage.ts +5 -5
  48. package/src/coverage/root-coverage.ts +1 -1
  49. package/src/coverage/session-coverage.ts +3 -3
  50. package/src/design-system-stories/ApplicationChrome.stories.tsx +5 -5
  51. package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +7 -7
  52. package/src/edit-mode/edit-mode-audio.ts +4 -4
  53. package/src/edit-mode/edit-mode-networking.ts +2 -2
  54. package/src/game-document/GameCaptureFrameButton.tsx +1 -1
  55. package/src/game-document/GameDocument.tsx +2 -2
  56. package/src/game-document/GamePanel.tsx +4 -4
  57. package/src/game-document/InstanceInspectorPicker.tsx +2 -2
  58. package/src/game-document/crowd-debug.ts +1 -1
  59. package/src/game-document/device-preview.ts +10 -11
  60. package/src/game-document/physics-debug.ts +1 -1
  61. package/src/generation/GenerationActivity.tsx +3 -3
  62. package/src/generation/generation-documents.tsx +5 -5
  63. package/src/generation/generation-jobs.ts +1 -1
  64. package/src/host/adapter-runtime-bindings.ts +96 -5
  65. package/src/host/authoring/babylon-authoring-adapter.ts +6 -6
  66. package/src/host/authoring/gesture-persist.ts +1 -1
  67. package/src/host/authoring/ingest-data-writer.ts +1 -1
  68. package/src/host/authoring/ingest-source-persistence.ts +4 -4
  69. package/src/host/authoring/mounted-authoring.ts +4 -4
  70. package/src/host/authoring/phaser-live-authoring-adapter.ts +4 -4
  71. package/src/host/authoring/pixi-authoring-adapter.ts +40 -15
  72. package/src/host/authoring/pixi-creation-site-write-target.ts +2 -2
  73. package/src/host/authoring/pixi-live-write-target.ts +4 -4
  74. package/src/host/authoring/pixi-source-identity.ts +3 -3
  75. package/src/host/authoring/pixi-source-write-target.ts +1614 -0
  76. package/src/host/authoring/pixi-still-presentation.ts +1 -1
  77. package/src/host/authoring/pixi-structure-history.ts +2 -2
  78. package/src/host/authoring/pixi-transform-channels.ts +16 -14
  79. package/src/host/authoring/source-persistence-backend.ts +3 -3
  80. package/src/host/authoring/struct-write-pipe.ts +1 -1
  81. package/src/host/binding-resolver.ts +8 -9
  82. package/src/host/browser-transpile.ts +1 -1
  83. package/src/host/canvas-entry-runtime.ts +58 -47
  84. package/src/host/canvas-preview-frames.ts +482 -0
  85. package/src/host/components/CameraAuthoringOverlay.tsx +1 -1
  86. package/src/host/components/HeaderTelemetry.tsx +4 -4
  87. package/src/host/components/PixiIsolationSceneContent.tsx +10 -10
  88. package/src/host/components/ThreeIsolationSceneContent.tsx +3 -3
  89. package/src/host/components/frame-debugger-model.ts +2 -2
  90. package/src/host/components/header-telemetry-model.ts +2 -2
  91. package/src/host/components/scene-document.tsx +14 -14
  92. package/src/host/components/utility-view-state.ts +1 -1
  93. package/src/host/components/world-root-stage-binding.tsx +12 -12
  94. package/src/host/components/world-root-stage.ts +70 -49
  95. package/src/host/coverage/system-adapter-coverage.ts +3 -4
  96. package/src/host/design-system-stories/fixtures/editor-runtime.tsx +9 -11
  97. package/src/host/document-preview-three.ts +1 -1
  98. package/src/host/entry-adjudication.ts +6 -6
  99. package/src/host/game-css-scope-transform.ts +4 -0
  100. package/src/host/game-realm-page.ts +1 -1
  101. package/src/host/gameplay-export.ts +25 -14
  102. package/src/host/gameplay-recording.ts +5 -5
  103. package/src/host/gated-globals.ts +2 -2
  104. package/src/host/history/json-history-resource.ts +1 -1
  105. package/src/host/projection/pixi.ts +24 -2
  106. package/src/host/r3f-entry-runtime.ts +65 -34
  107. package/src/host/react-mount-runtime.ts +7 -48
  108. package/src/host/realm-services.ts +1 -1
  109. package/src/host/roots/canvas-root.tsx +361 -0
  110. package/src/host/roots/r3f-root.tsx +473 -0
  111. package/src/host/roots/react-root.ts +9 -43
  112. package/src/host/served-bundle-runtime-modules.ts +1 -19
  113. package/src/host/server-log-bridge.ts +2 -2
  114. package/src/host/stories/mounted-story-viewport-source.ts +1 -1
  115. package/src/host/stories/pixi-story-model.ts +30 -0
  116. package/src/host/stories/story-media-captures.ts +46 -0
  117. package/src/host/stories/story-media-presence.ts +3 -3
  118. package/src/host/stories/story-pixi-preview.ts +408 -0
  119. package/src/host/stories/story-three-preview.ts +806 -0
  120. package/src/host/stories/three-story-captures.ts +35 -0
  121. package/src/host/story-three-preview-runtime.ts +56 -0
  122. package/src/host/use-active-performance-source.ts +2 -2
  123. package/src/host/viewport-pose-memory.ts +1 -1
  124. package/src/host/viewport-root-presentation.ts +6 -5
  125. package/src/ingest/active-ingest.ts +1 -1
  126. package/src/ingest/authoring/ingest-dom-surface-authoring.ts +4 -4
  127. package/src/ingest/authoring/ingest-root-adapter.ts +8 -8
  128. package/src/ingest/deferred-ingest-play.ts +6 -6
  129. package/src/ingest/discovery-public-ingest.ts +2 -2
  130. package/src/ingest/ingest-boot-viewport.ts +2 -2
  131. package/src/ingest/ingest-canvas-scene-document.tsx +8 -8
  132. package/src/ingest/ingest-canvas-scene.ts +3 -3
  133. package/src/ingest/ingest-evidence-hook.ts +1 -1
  134. package/src/ingest/ingest-frame-snapshot.ts +1 -1
  135. package/src/ingest/ingest-play-control.ts +1 -1
  136. package/src/ingest/ingest-render-debug.ts +10 -10
  137. package/src/ingest/ingest-siblings.ts +13 -25
  138. package/src/ingest/module-mode.ts +14 -14
  139. package/src/ingest/mount-canvas-ingest-root.ts +21 -21
  140. package/src/ingest/mount-coverage.ts +2 -2
  141. package/src/ingest/mount-dom-ingest-root.ts +11 -11
  142. package/src/ingest/mount-ingest-root.ts +8 -8
  143. package/src/ingest/mount-three-ingest-root.ts +8 -8
  144. package/src/ingest/resolve-canvas.ts +1 -1
  145. package/src/ingest/served-html-boot.ts +1 -1
  146. package/src/ingest/surface-canvas.ts +1 -1
  147. package/src/ingest/unmount-ingest-root.ts +5 -5
  148. package/src/navmesh/navmesh-handler.ts +24 -16
  149. package/src/network/NetworkInspectorPanel.tsx +449 -14
  150. package/src/network/network-inspector-model.ts +14 -2
  151. package/src/play/play-log-events.ts +1 -1
  152. package/src/play/play-mode.ts +76 -133
  153. package/src/play/play-recording.ts +1 -1
  154. package/src/play/react-play-live-authoring.ts +3 -3
  155. package/src/play/run-selection.ts +93 -0
  156. package/src/play-bar/PlayBar.tsx +20 -39
  157. package/src/profiler/FrameDebuggerPanel.tsx +1 -1
  158. package/src/profiler/PerformancePanel.tsx +3 -3
  159. package/src/react/design-time-react-mount.ts +23 -65
  160. package/src/react/dom-authoring-adapter.ts +9 -9
  161. package/src/react/react-inspector-section.tsx +5 -5
  162. package/src/react/react-world-authoring-adapter.ts +11 -11
  163. package/src/react/story-documents/story-documents.tsx +9 -9
  164. package/src/react/ui-board-document.tsx +7 -7
  165. package/src/react/ui-component-board.ts +2 -2
  166. package/src/runtime/adapter/audio-meter.ts +21 -0
  167. package/src/runtime/adapter/first-party-audio-system.ts +230 -0
  168. package/src/runtime/adapter/ingest/contract-debug-adapter.ts +114 -0
  169. package/src/runtime/adapter/ingest/contract-system-adapters.ts +256 -0
  170. package/src/runtime/adapter/ingest/merge-debug-adapters.ts +197 -0
  171. package/src/runtime/adapter/ingest/observation-debug-adapter.ts +162 -0
  172. package/src/runtime/adapter/ingest/upstream-pin.ts +51 -0
  173. package/src/runtime/adapter/native-debug-module.ts +498 -0
  174. package/src/runtime/audio/bus-mixer.ts +161 -0
  175. package/src/runtime/audio/pose-guard.ts +80 -0
  176. package/src/runtime/core/frame-pacing.ts +126 -0
  177. package/src/runtime/core/game-loop.ts +225 -0
  178. package/src/runtime/core/game-scoped-slot.ts +28 -0
  179. package/src/runtime/core/seeded-random.ts +162 -0
  180. package/src/runtime/core/sim-clock.ts +391 -0
  181. package/src/runtime/core/system-runner.ts +269 -0
  182. package/src/runtime/core/types.ts +104 -0
  183. package/src/runtime/create-runtime.ts +1128 -0
  184. package/src/runtime/debug-bridge.ts +570 -0
  185. package/src/runtime/debug-registry.ts +899 -0
  186. package/src/runtime/dev/chrome-trace.ts +153 -0
  187. package/src/runtime/dev/instruments.ts +403 -0
  188. package/src/runtime/dev/logger.ts +119 -0
  189. package/src/runtime/dev/performance-profiler.ts +367 -0
  190. package/src/runtime/dev/register-render-vitals.ts +276 -0
  191. package/src/runtime/dev/render-census.ts +354 -0
  192. package/src/runtime/dev/render-debug-adapter.ts +218 -0
  193. package/src/runtime/dev/render-memory.ts +226 -0
  194. package/src/runtime/dev/render-vitals.ts +338 -0
  195. package/src/runtime/dev/static-batch-advisor.ts +188 -0
  196. package/src/runtime/dev/webgl-frame-capture.ts +366 -0
  197. package/src/runtime/dev/webgl-gpu-timer.ts +53 -0
  198. package/src/runtime/dev-build.ts +47 -0
  199. package/src/runtime/game.ts +1636 -0
  200. package/src/runtime/gameplay-rng-trap.ts +135 -0
  201. package/src/runtime/host-context.ts +64 -0
  202. package/src/runtime/input-router.ts +169 -0
  203. package/src/runtime/mount-manifest.ts +480 -0
  204. package/src/runtime/pixi/authoring.ts +706 -0
  205. package/src/runtime/pixi/ingest.ts +116 -0
  206. package/src/runtime/pixi/physics-registry.ts +49 -0
  207. package/src/runtime/pixi/render-pass-bracket.ts +117 -0
  208. package/src/runtime/pixi/scene-capture.ts +179 -0
  209. package/src/runtime/pixi/system-adapters.ts +69 -0
  210. package/src/runtime/playtest.ts +22 -0
  211. package/src/runtime/presentation.ts +141 -0
  212. package/src/runtime/render-control.ts +642 -0
  213. package/src/runtime/render-seed.ts +77 -0
  214. package/src/runtime/run-ticks-settled.ts +73 -0
  215. package/src/runtime/setup/setup-audio.ts +72 -0
  216. package/src/services/audio-pose-guard.ts +2 -2
  217. package/src/services/game-audio.ts +152 -0
  218. package/src/services/game-network.ts +657 -0
  219. package/src/services/game-physics.ts +334 -0
  220. package/src/state-watch/StateWatchPanel.tsx +1 -1
  221. package/src/three/authoring/camera-runtime-inspector-section.tsx +3 -2
  222. package/src/three/authoring/constraint-inspector-section.tsx +6 -5
  223. package/src/three/authoring/model-asset-inspector-section.tsx +7 -6
  224. package/src/three/authoring/oid-source-persistence.ts +7 -7
  225. package/src/three/authoring/r3f-design-session.ts +58 -54
  226. package/src/three/authoring/r3f-source-authoring-adapter.ts +95 -91
  227. package/src/three/authoring/reflection-probe-inspector-section.tsx +3 -2
  228. package/src/three/authoring/three-authoring-adapter.ts +29 -29
  229. package/src/three/component-verbs/extract-menu.ts +7 -6
  230. package/src/three/component-verbs/fork-menu.ts +7 -6
  231. package/src/three/component-verbs/internals-menu.ts +2 -2
  232. package/src/three/story-documents/three-story-documents.tsx +13 -13
  233. package/src/three/three-board/ThreeBoardDocument.tsx +13 -12
  234. package/src/three/three-board/board-scene.ts +6 -6
  235. package/src/three/three-board/three-component-board.ts +2 -2
  236. package/src/services/game-audio-unlock.ts +0 -48
@@ -0,0 +1,391 @@
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
+ * registered sequence 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** (`editor-game/src/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 — `editor-game/src/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
+ * const ac = new AbortController();
97
+ * clock.after(3, () => spawnWave(), { signal: ac.signal });
98
+ * // on dispose: ac.abort() — cancels timers AND listeners
99
+ * }
100
+ * ```
101
+ *
102
+ * This is the engine-wide "Examples must clean up" contract (CLAUDE.md), not a
103
+ * special rule for timers.
104
+ *
105
+ * ## Deliberately absent
106
+ *
107
+ * - **No `every`/`repeat`/interval.** An `after` that re-arms itself DRIFTS:
108
+ * re-arming schedules the next fire from the moment the callback ran, so
109
+ * every long frame permanently lengthens the interval.
110
+ * - **No end-of-frame command queue.** `after(0, fn)` looks like one and is
111
+ * not: it holds the work forever across a pause and has no subject identity.
112
+ * - **No fiber kernel, no coroutine emulation, no `task.spawn`.** Calling an
113
+ * `async function` *is* `task.spawn` — the language already has it.
114
+ * - **No wall clock.** This module reads no system timer and creates no
115
+ * host timeout; every time value comes from the runtime's own accumulator.
116
+ */
117
+
118
+ import type * as THREE from 'three';
119
+ import { createGameScopedSlot } from './game-scoped-slot';
120
+
121
+ /** Cancel handle returned by {@link SimClock.after}/{@link SimClock.disposeAfter}. */
122
+ export interface SimTimerHandle {
123
+ /** Cancel the timer if it has not fired yet. Idempotent. */
124
+ cancel(): void;
125
+ }
126
+
127
+ /** Options accepted by every scheduling call. */
128
+ export interface SimScheduleOptions {
129
+ /** Cancel/reject when this signal aborts (already-aborted is honored too). */
130
+ readonly signal?: AbortSignal | undefined;
131
+ }
132
+
133
+ /** The sim clock as GAME CODE sees it — the whole public surface. */
134
+ export interface SimClock {
135
+ /** Sim seconds elapsed: the fixed loop's own accumulator. Monotonic. */
136
+ now(): number;
137
+ /** Completed fixed substeps — the same counter `Game`'s `tick` carries. */
138
+ tickNow(): number;
139
+ /**
140
+ * Resolve after `seconds` of SIM time. The ergonomic form; see the module
141
+ * header for why it is not frame-exact under a synchronous driver.
142
+ * Rejects with an `AbortError` `DOMException` if `opts.signal` aborts (or is
143
+ * already aborted), or if the clock is disposed while it is pending.
144
+ */
145
+ delay(seconds: number, opts?: SimScheduleOptions): Promise<void>;
146
+ /**
147
+ * Invoke `fn` inline during the flush of the exact due tick — the exact
148
+ * form. A no-op if `opts.signal` is already aborted.
149
+ */
150
+ after(seconds: number, fn: () => void, opts?: SimScheduleOptions): SimTimerHandle;
151
+ /**
152
+ * Debris: dispose `obj` after `seconds` of sim time. Built on {@link after},
153
+ * so removal lands on an exact tick. The disposal itself is supplied by the
154
+ * runtime (see {@link SimClockOptions.dispose}) — this module knows nothing
155
+ * about physics or rendering.
156
+ */
157
+ disposeAfter(obj: THREE.Object3D, seconds: number): SimTimerHandle;
158
+ }
159
+
160
+ /**
161
+ * The runtime-facing half — NOT for game code. Split off the public
162
+ * {@link SimClock} the same way `editor-game/src/runtime/game.ts` splits `GameInternal` off
163
+ * `Game`: game code only ever sees the public `SimClock`, so it cannot
164
+ * reach `flush`/`dispose`.
165
+ */
166
+ export interface SimClockInternal extends SimClock {
167
+ /**
168
+ * Advance to `simT` and fire everything now due. Called by `runFrameImpl`
169
+ * immediately after the `tick`/`simT` bump, inside the `advanced` guard.
170
+ * One call === one completed substep, which is why {@link SimClock.tickNow}
171
+ * can simply count them.
172
+ */
173
+ flush(simT: number): void;
174
+ /**
175
+ * Drop every pending timer and reject every pending `delay`. Called by
176
+ * whoever CREATED this clock and by nobody else — `GameInternal.dispose()`
177
+ * for the game-scoped one, the owning mount for a mount-local fallback. See
178
+ * the module header's ownership section.
179
+ */
180
+ dispose(): void;
181
+ }
182
+
183
+ export interface SimClockOptions {
184
+ /**
185
+ * How {@link SimClock.disposeAfter} disposes an object. Supplied by the
186
+ * runtime, which is the only layer that knows about physics
187
+ * registries and shared geometry; the clock only knows *when*.
188
+ */
189
+ readonly dispose: (obj: THREE.Object3D) => void;
190
+ }
191
+
192
+ interface SimTimer {
193
+ readonly dueAt: number;
194
+ readonly seq: number;
195
+ readonly fn: () => void;
196
+ cancelled: boolean;
197
+ detachAbort: (() => void) | null;
198
+ }
199
+
200
+ const NOOP_HANDLE: SimTimerHandle = { cancel() {} };
201
+
202
+ /** `fetch`-shaped abort rejection: a `DOMException` named `AbortError`. */
203
+ function abortError(message: string): DOMException {
204
+ return new DOMException(message, 'AbortError');
205
+ }
206
+
207
+ function assertSeconds(method: string, seconds: number): void {
208
+ if (!Number.isFinite(seconds) || seconds < 0) {
209
+ throw new RangeError(
210
+ `SimClock.${method}: seconds must be a finite, non-negative number, got ${seconds}`,
211
+ );
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Build the one sim clock a `Game` owns. See the module header for the
217
+ * contract; `editor-game/src/runtime/game.ts` is the only production caller.
218
+ */
219
+ export function createSimClock(options: SimClockOptions): SimClockInternal {
220
+ const disposeObject = options.dispose;
221
+
222
+ let simT = 0;
223
+ let ticks = 0;
224
+ let seq = 0;
225
+ let disposed = false;
226
+
227
+ const pending = new Set<SimTimer>();
228
+ /** Rejectors for in-flight `delay`s, so `dispose()` can settle them all. */
229
+ const pendingDelays = new Set<(message: string) => void>();
230
+
231
+ function cancelTimer(timer: SimTimer): void {
232
+ if (timer.cancelled) return;
233
+ timer.cancelled = true;
234
+ pending.delete(timer);
235
+ timer.detachAbort?.();
236
+ timer.detachAbort = null;
237
+ }
238
+
239
+ function after(seconds: number, fn: () => void, opts?: SimScheduleOptions): SimTimerHandle {
240
+ assertSeconds('after', seconds);
241
+ const signal = opts?.signal;
242
+ // Already-aborted, or a clock that is gone: a no-op, never a throw.
243
+ if (disposed || signal?.aborted) return NOOP_HANDLE;
244
+
245
+ const timer: SimTimer = {
246
+ dueAt: simT + seconds,
247
+ seq: seq++,
248
+ fn,
249
+ cancelled: false,
250
+ detachAbort: null,
251
+ };
252
+ pending.add(timer);
253
+
254
+ if (signal) {
255
+ const onAbort = (): void => cancelTimer(timer);
256
+ signal.addEventListener('abort', onAbort, { once: true });
257
+ timer.detachAbort = (): void => signal.removeEventListener('abort', onAbort);
258
+ }
259
+
260
+ return {
261
+ cancel(): void {
262
+ cancelTimer(timer);
263
+ },
264
+ };
265
+ }
266
+
267
+ return {
268
+ now: (): number => simT,
269
+ tickNow: (): number => ticks,
270
+ after,
271
+
272
+ delay(seconds: number, opts?: SimScheduleOptions): Promise<void> {
273
+ assertSeconds('delay', seconds);
274
+ const signal = opts?.signal;
275
+ if (signal?.aborted) {
276
+ return Promise.reject(abortError('SimClock.delay: aborted before it was scheduled'));
277
+ }
278
+ if (disposed) {
279
+ return Promise.reject(abortError('SimClock.delay: the sim clock is disposed'));
280
+ }
281
+ return new Promise<void>((resolve, reject) => {
282
+ let handle: SimTimerHandle | null = null;
283
+ let detachAbort: (() => void) | null = null;
284
+ let settled = false;
285
+
286
+ const finish = (): void => {
287
+ settled = true;
288
+ pendingDelays.delete(rejectDelay);
289
+ detachAbort?.();
290
+ detachAbort = null;
291
+ };
292
+ function rejectDelay(message: string): void {
293
+ if (settled) return;
294
+ finish();
295
+ handle?.cancel();
296
+ reject(abortError(message));
297
+ }
298
+
299
+ handle = after(seconds, () => {
300
+ if (settled) return;
301
+ finish();
302
+ resolve();
303
+ });
304
+ pendingDelays.add(rejectDelay);
305
+
306
+ if (signal) {
307
+ const onAbort = (): void => rejectDelay('SimClock.delay: aborted');
308
+ signal.addEventListener('abort', onAbort, { once: true });
309
+ detachAbort = (): void => signal.removeEventListener('abort', onAbort);
310
+ }
311
+ });
312
+ },
313
+
314
+ disposeAfter(obj: THREE.Object3D, seconds: number): SimTimerHandle {
315
+ assertSeconds('disposeAfter', seconds);
316
+ return after(seconds, () => disposeObject(obj));
317
+ },
318
+
319
+ flush(nextSimT: number): void {
320
+ if (disposed) return;
321
+ simT = nextSimT;
322
+ ticks++;
323
+ if (pending.size === 0) return;
324
+
325
+ // Snapshot BEFORE running anything: a timer scheduled by one of these
326
+ // callbacks lands in `pending` but not in this batch, so it can never be
327
+ // due in the same flush (module header, firing rule 4).
328
+ const batch: SimTimer[] = [];
329
+ for (const timer of pending) {
330
+ if (!timer.cancelled && timer.dueAt <= simT) batch.push(timer);
331
+ }
332
+ if (batch.length === 0) return;
333
+ batch.sort((a, b) => a.dueAt - b.dueAt || a.seq - b.seq);
334
+
335
+ for (const timer of batch) {
336
+ // A previously-fired callback may have cancelled this one.
337
+ if (timer.cancelled) continue;
338
+ cancelTimer(timer);
339
+ try {
340
+ timer.fn();
341
+ } catch (err) {
342
+ // 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)
343
+ console.error('[sim-clock] timer callback threw:', err);
344
+ }
345
+ }
346
+ },
347
+
348
+ dispose(): void {
349
+ if (disposed) return;
350
+ disposed = true;
351
+ for (const timer of pending) {
352
+ timer.detachAbort?.();
353
+ timer.detachAbort = null;
354
+ timer.cancelled = true;
355
+ }
356
+ pending.clear();
357
+ const rejectors = [...pendingDelays];
358
+ pendingDelays.clear();
359
+ for (const rejectDelay of rejectors) {
360
+ rejectDelay('SimClock: disposed while a delay was pending');
361
+ }
362
+ },
363
+ };
364
+ }
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // Game-scoped registry — mirrors `core/seeded-random.ts`'s
368
+ // `registerSeededRandom`/`getSeededRandom` slot pattern exactly, keyed on a
369
+ // bare `object` (not `Game`) so `core/` never imports `runtime/`. `createGame`
370
+ // is the one real registrant, passing the `GameInternal` shell as the key.
371
+ // ---------------------------------------------------------------------------
372
+
373
+ const clockByOwner = createGameScopedSlot<SimClockInternal>('sim-clock');
374
+
375
+ /** Called once by `createGame`, right after the clock and the Game shell exist. */
376
+ export function registerSimClock(owner: object, clock: SimClockInternal): void {
377
+ clockByOwner.set(owner, clock);
378
+ }
379
+
380
+ /** The game-scoped clock backing every world's `ctx.clock` — `null` for an
381
+ * owner built without one (a hand-built `Game`-shaped stand-in that never went
382
+ * through `createGame`).
383
+ *
384
+ * Returns the INTERNAL view because this registry is engine-only — but a
385
+ * caller that RESOLVES a clock here is not its owner and must not call
386
+ * `dispose()` on it (module header, ownership). Game code never reaches it at
387
+ * all: it only ever sees the public {@link SimClock}, which has
388
+ * neither `flush` nor `dispose`, the same way `Game` hides `GameInternal`. */
389
+ export function getSimClock(owner: object): SimClockInternal | null {
390
+ return clockByOwner.get(owner) ?? null;
391
+ }
@@ -0,0 +1,269 @@
1
+ import {
2
+ PHASE_ORDER,
3
+ type SystemDef,
4
+ type SystemFn,
5
+ type SystemOptions,
6
+ type SystemPhaseName,
7
+ type SystemRunObserver,
8
+ } from './types';
9
+
10
+ /**
11
+ * Ordered system execution by named phase.
12
+ *
13
+ * Systems are registered with a phase name. When `run(dt)` is called, all
14
+ * phases execute in `PHASE_ORDER` via `runPhase(phase, dt)`. Within a phase,
15
+ * `runPhase` executes two ordered buckets (T7.1 slice 2):
16
+ *
17
+ * 1. **engine** — everything `add()`/`register()`ed at or before the last
18
+ * `markEngineBoundary()` call, or EVERYTHING if no boundary has ever
19
+ * been marked.
20
+ * 2. **game** — everything `add()`/`register()`ed AFTER the last
21
+ * `markEngineBoundary()` call.
22
+ *
23
+ * Within each bucket, systems run in registration order.
24
+ */
25
+ export function createSystemRunner(observer?: SystemRunObserver, scope = 'world') {
26
+ const systems = new Map<SystemPhaseName, SystemFn[]>();
27
+ const registered: SystemDef[] = [];
28
+ const labels = new Map<SystemFn, string>();
29
+ let anonymousId = 0;
30
+
31
+ // Initialize all phases with empty arrays.
32
+ for (const phase of PHASE_ORDER) {
33
+ systems.set(phase, []);
34
+ }
35
+
36
+ // Membership bookkeeping for `markEngineBoundary()` (T7.2 review fix —
37
+ // replaces the old length/index-snapshot bookkeeping, which broke if a
38
+ // pre-boundary "engine" entry was ever removed: shrinking the list would
39
+ // shift a later "game" entry underneath the recorded length, misclassifying
40
+ // it as engine). `engineFns`/`engineRegistered` instead record WHICH
41
+ // specific function/system was present at the last `markEngineBoundary()`
42
+ // call — membership survives arbitrary removal of any other entry,
43
+ // regardless of position. `boundaryMarked` is false until the first mark
44
+ // (mirrors the old `boundary === null`: everything is "engine" pre-mark).
45
+ let boundaryMarked = false;
46
+ const engineFns = new Map<SystemPhaseName, Set<SystemFn>>();
47
+ for (const phase of PHASE_ORDER) engineFns.set(phase, new Set());
48
+ const engineRegistered = new Set<SystemDef>();
49
+
50
+ /** Run one system function, isolating a throw so it never wedges the frame. */
51
+ function runOne(fn: SystemFn, phase: SystemPhaseName, dt: number): void {
52
+ const label = labels.get(fn) ?? fn.name ?? `anonymous-${++anonymousId}`;
53
+ labels.set(fn, label);
54
+ observer?.beginSystem(scope, phase, label);
55
+ try {
56
+ fn(dt);
57
+ } catch (err) {
58
+ const label = fn.name ? `"${fn.name}"` : '(anonymous)';
59
+ console.error(`[system-runner] system ${label} in phase "${phase}" threw:`, err);
60
+ } finally {
61
+ observer?.endSystem(scope, phase, label);
62
+ }
63
+ }
64
+
65
+ return {
66
+ /**
67
+ * Register a bare system function in a specific phase.
68
+ * Systems within the same phase run in the order they were added.
69
+ */
70
+ add(phase: SystemPhaseName, fn: SystemFn, options?: SystemOptions) {
71
+ const list = systems.get(phase);
72
+ if (!list) {
73
+ throw new Error(`Unknown phase: ${phase}. Valid phases: ${PHASE_ORDER.join(', ')}`);
74
+ }
75
+ list.push(fn);
76
+ labels.set(fn, options?.name ?? fn.name ?? `anonymous-${++anonymousId}`);
77
+ },
78
+
79
+ /**
80
+ * Remove a bare system function from a phase. Also drops it from the
81
+ * engine-membership set (if present) — the fix for the length-based
82
+ * bookkeeping bug: removing this has no effect on how any OTHER entry
83
+ * (before or after it) is classified, since classification is now
84
+ * per-function membership, not position.
85
+ */
86
+ remove(phase: SystemPhaseName, fn: SystemFn) {
87
+ const list = systems.get(phase);
88
+ if (!list) return;
89
+ const idx = list.indexOf(fn);
90
+ if (idx !== -1) list.splice(idx, 1);
91
+ engineFns.get(phase)?.delete(fn);
92
+ },
93
+
94
+ /**
95
+ * Register a lifecycle system. Adds its update to the correct phase.
96
+ * Call runInit() after all systems are registered to invoke init hooks.
97
+ */
98
+ register(system: SystemDef) {
99
+ const list = systems.get(system.phase);
100
+ if (!list) {
101
+ throw new Error(`Unknown phase: ${system.phase}. Valid phases: ${PHASE_ORDER.join(', ')}`);
102
+ }
103
+ list.push(system.update);
104
+ labels.set(system.update, system.name ?? system.update.name ?? `anonymous-${++anonymousId}`);
105
+ registered.push(system);
106
+ },
107
+
108
+ /**
109
+ * Unregister a lifecycle system. Removes its update and calls dispose.
110
+ */
111
+ unregister(system: SystemDef) {
112
+ const list = systems.get(system.phase);
113
+ if (list) {
114
+ const idx = list.indexOf(system.update);
115
+ if (idx !== -1) list.splice(idx, 1);
116
+ }
117
+ engineFns.get(system.phase)?.delete(system.update);
118
+ const regIdx = registered.indexOf(system);
119
+ if (regIdx !== -1) registered.splice(regIdx, 1);
120
+ engineRegistered.delete(system);
121
+ system.dispose?.();
122
+ },
123
+
124
+ /**
125
+ * Call init() on all registered lifecycle systems, in phase order.
126
+ * Call once after scene load, before the first game loop tick.
127
+ */
128
+ runInit() {
129
+ const sorted = [...registered].sort(
130
+ (a, b) => PHASE_ORDER.indexOf(a.phase) - PHASE_ORDER.indexOf(b.phase),
131
+ );
132
+ for (const sys of sorted) {
133
+ sys.init?.();
134
+ }
135
+ },
136
+
137
+ /**
138
+ * Call dispose() on all registered lifecycle systems and remove them.
139
+ */
140
+ runDispose() {
141
+ for (const sys of registered) {
142
+ const list = systems.get(sys.phase);
143
+ if (list) {
144
+ const idx = list.indexOf(sys.update);
145
+ if (idx !== -1) list.splice(idx, 1);
146
+ }
147
+ sys.dispose?.();
148
+ }
149
+ registered.length = 0;
150
+ },
151
+
152
+ /**
153
+ * Run ONE phase's two ordered buckets — engine, game
154
+ * (see the module doc comment). Each system call is isolated: a
155
+ * throwing system is loudly logged (never swallowed) but does not stop
156
+ * its siblings in the same bucket, a later bucket in this phase, or a
157
+ * later phase, from running this frame.
158
+ *
159
+ * Public — the Game root's frame executor (`runtime/game.ts`) calls this
160
+ * directly per (phase, world); `run(dt)` below is just a loop over it,
161
+ * preserved for any direct caller.
162
+ */
163
+ runPhase(phase: SystemPhaseName, dt: number) {
164
+ const list = systems.get(phase);
165
+ if (!list) {
166
+ throw new Error(`Unknown phase: ${phase}. Valid phases: ${PHASE_ORDER.join(', ')}`);
167
+ }
168
+ // Partition by MEMBERSHIP, not position: everything in this phase's
169
+ // `engineFns` set (or, if no boundary has ever been marked, everything)
170
+ // runs immediately, in list order, as it's
171
+ // encountered; everything else is queued into `gameFns` (also in list
172
+ // order) and run after them. Because classification is
173
+ // per-function rather than "index < some remembered length", removing
174
+ // ANY entry via `remove()` — including a pre-boundary "engine" one —
175
+ // cannot shift a later game system into the engine bucket.
176
+ const engineSet = engineFns.get(phase)!;
177
+ const gameFns: SystemFn[] = [];
178
+ for (const fn of list) {
179
+ if (boundaryMarked && !engineSet.has(fn)) {
180
+ gameFns.push(fn);
181
+ } else {
182
+ runOne(fn, phase, dt);
183
+ }
184
+ }
185
+
186
+ for (const fn of gameFns) {
187
+ runOne(fn, phase, dt);
188
+ }
189
+ },
190
+
191
+ /**
192
+ * Run all phases in order, via `runPhase`.
193
+ */
194
+ run(dt: number) {
195
+ for (const phase of PHASE_ORDER) {
196
+ this.runPhase(phase, dt);
197
+ }
198
+ },
199
+
200
+ /**
201
+ * Mark everything registered so far (via `add()`/`register()`, in every
202
+ * phase) as "engine" — infrastructure that must survive a warm restart.
203
+ * Call once, right after mount finishes wiring engine-level systems and
204
+ * BEFORE any game (`setup()`/scene load) registers its own. `hotReload`
205
+ * calls `removeAllNonEngine()` on every restart, which only ever removes
206
+ * what was added after this mark.
207
+ */
208
+ markEngineBoundary() {
209
+ for (const phase of PHASE_ORDER) {
210
+ const set = engineFns.get(phase)!;
211
+ for (const fn of systems.get(phase)!) set.add(fn);
212
+ }
213
+ for (const sys of registered) engineRegistered.add(sys);
214
+ boundaryMarked = true;
215
+ },
216
+
217
+ /**
218
+ * Bulk-remove every system registered after the last `markEngineBoundary()`
219
+ * call — both bare `add()`ed functions and lifecycle `register()`ed systems
220
+ * (the latter get `dispose()`d, mirroring `unregister()`). A no-op if no
221
+ * boundary has been marked. Idempotent: calling it again with nothing new
222
+ * registered since is a safe no-op.
223
+ *
224
+ * This is what makes a warm restart (`hotReload`) safe to call N times
225
+ * without accumulating duplicate systems/listeners — each restart's
226
+ * outgoing game systems are fully removed before the new one registers its
227
+ * own, while the engine systems (input/physics/render/...) registered
228
+ * before the boundary are never touched.
229
+ */
230
+ removeAllNonEngine() {
231
+ if (!boundaryMarked) return;
232
+ // Membership-based, same reasoning as runPhase: partition by whether
233
+ // each entry is in the engine set, not by a remembered length/index.
234
+ const keptRegistered = registered.filter((sys) => engineRegistered.has(sys));
235
+ const removedRegistered = registered.filter((sys) => !engineRegistered.has(sys));
236
+ registered.length = 0;
237
+ registered.push(...keptRegistered);
238
+ for (const sys of removedRegistered) {
239
+ sys.dispose?.();
240
+ }
241
+
242
+ for (const phase of PHASE_ORDER) {
243
+ const list = systems.get(phase)!;
244
+ const engineSet = engineFns.get(phase)!;
245
+ const kept = list.filter((fn) => engineSet.has(fn));
246
+ list.length = 0;
247
+ list.push(...kept);
248
+ }
249
+ },
250
+
251
+ /**
252
+ * Total system-function count, optionally scoped to one phase.
253
+ * Test/introspection helper — used to assert a warm restart doesn't
254
+ * accumulate systems (see `removeAllNonEngine`).
255
+ */
256
+ count(phase?: SystemPhaseName): number {
257
+ if (phase) {
258
+ return systems.get(phase)?.length ?? 0;
259
+ }
260
+ let total = 0;
261
+ for (const list of systems.values()) {
262
+ total += list.length;
263
+ }
264
+ return total;
265
+ },
266
+ };
267
+ }
268
+
269
+ export type SystemRunner = ReturnType<typeof createSystemRunner>;