@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,1128 @@
1
+ import { assertNever } from '@volter/editor-project/adapter/adapter-surface';
2
+ import {
3
+ createRootBinding,
4
+ type RootBinding,
5
+ type RootDeclaration,
6
+ } from '@volter/editor-project/adapter/binding';
7
+ import type { DomHostContext } from '@volter/editor-project/adapter/host-context';
8
+ import type {
9
+ GameCanvasHostContext,
10
+ GameDomHostContext,
11
+ GameThreeHostContext,
12
+ } from './host-context';
13
+ import type {
14
+ MountedCanvasRoot,
15
+ MountedReactRoot,
16
+ MountedRoot,
17
+ MountedThreeRoot,
18
+ RootAdapter,
19
+ SurfaceAdapter,
20
+ } from '@volter/editor-project/adapter/root-adapter';
21
+ import { stackOrder } from '@volter/editor-project/adapter/root-stacking';
22
+ import { createAssetCache } from '@volter/threejs-runtime/assets';
23
+ import { createHostRenderer } from '@volter/threejs-runtime/setup/setup-renderer';
24
+ import * as THREE from 'three';
25
+ import { createGameLoop } from './core/game-loop';
26
+ import { getDebugRegistry } from './debug-registry';
27
+ // TYPE-ONLY: `create-runtime.ts` must never value-import `pixi.js` — a canvas
28
+ // `RootMountSpec`'s `adapter` is supplied ALREADY-CONSTRUCTED by the caller, so
29
+ // the host only ever needs the seam's types.
30
+ import {
31
+ createGame,
32
+ createRootInstance,
33
+ type Game,
34
+ type GameInternal,
35
+ type RootInstance,
36
+ } from './game';
37
+ import { createInputRouter, type RouterAdapterRoot } from './input-router';
38
+ import type { PlaytestContext } from './playtest';
39
+ import { installRenderControlHarness, isRenderModeRequested } from './render-control';
40
+
41
+ /**
42
+ * Register a three world onto the Game shell. `opts.id` defaults to
43
+ * `'main'`; multi-root callers pass an explicit id. This is the one
44
+ * registration path for a three world, whatever its id.
45
+ */
46
+ export function registerThreeRoot(
47
+ game: GameInternal,
48
+ adapter: RootAdapter,
49
+ mounted: MountedThreeRoot,
50
+ opts?: RegisterRootOptions,
51
+ ): RootInstance {
52
+ const pausable = opts?.pausable ?? true;
53
+ const world = createRootInstance({
54
+ id: opts?.id ?? 'main',
55
+ kind: 'three',
56
+ pausable,
57
+ adapter,
58
+ mounted,
59
+ scene: mounted.scene as THREE.Scene,
60
+ binding: bindRoot(
61
+ game,
62
+ opts?.declaration,
63
+ {
64
+ surface: 'three',
65
+ adapter: adapter as RootAdapter<'three'>,
66
+ },
67
+ mounted,
68
+ pausable,
69
+ ),
70
+ });
71
+ game.registerRoot(world);
72
+ return world;
73
+ }
74
+
75
+ /** What every `register<Surface>Root` takes beyond the mount itself. */
76
+ export interface RegisterRootOptions {
77
+ readonly id?: string | undefined;
78
+ readonly pausable?: boolean | undefined;
79
+ /**
80
+ * The DECLARATION half of this root's binding, from the resolver that
81
+ * produced `adapter` (`adapter/binding.ts`). Absent for a bare harness
82
+ * registration or a `mountManifestRoots` caller who supplied an
83
+ * already-constructed adapter — see `RootInstance.binding` for why that
84
+ * legitimately yields `null` rather than a fabricated binding.
85
+ */
86
+ readonly declaration?: RootDeclaration | undefined;
87
+ }
88
+
89
+ /**
90
+ * Complete the root's binding at the first moment it CAN be completed: the
91
+ * declaration is what the resolver knew, `mounted`/`adapter`/`pausable` are
92
+ * what mounting produced, and the debug registry is the game's own. Nothing
93
+ * here loads or fabricates — see `createRootBinding`.
94
+ */
95
+ function bindRoot(
96
+ game: GameInternal,
97
+ declaration: RootDeclaration | undefined,
98
+ adapter: SurfaceAdapter,
99
+ mounted: MountedRoot,
100
+ pausable: boolean,
101
+ ): RootBinding | null {
102
+ if (!declaration) return null;
103
+ return createRootBinding({
104
+ ...declaration,
105
+ adapter,
106
+ mounted,
107
+ pausable,
108
+ debugRegistry: getDebugRegistry(game),
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Register a canvas world onto the Game shell — the canvas analog of
114
+ * {@link registerThreeRoot}. `adapter` is the seam's own
115
+ * `RootAdapter<'canvas'>`; `RootInstance.adapter` itself only needs `.id`
116
+ * (`AdapterHandle`, `runtime/game.ts`), so this passes through with zero cast.
117
+ * The substrate root remains opaque here; the editor dispatches from the
118
+ * mount's explicit `substrate.name` declaration.
119
+ */
120
+ export function registerCanvasRoot(
121
+ game: GameInternal,
122
+ adapter: RootAdapter<'canvas'>,
123
+ mounted: MountedCanvasRoot,
124
+ opts?: RegisterRootOptions,
125
+ ): RootInstance {
126
+ const id = opts?.id ?? 'main';
127
+ const pausable = opts?.pausable ?? true;
128
+ const world = createRootInstance({
129
+ id,
130
+ kind: 'canvas',
131
+ pausable,
132
+ adapter,
133
+ mounted,
134
+ canvasRoot: mounted.substrate.root,
135
+ binding: bindRoot(game, opts?.declaration, { surface: 'canvas', adapter }, mounted, pausable),
136
+ });
137
+ game.registerRoot(world);
138
+ return world;
139
+ }
140
+
141
+ /**
142
+ * Register a React world onto the Game shell — the DOM
143
+ * analog of {@link registerThreeRoot}/{@link registerCanvasRoot}. A react
144
+ * world has no `frame` hooks (react's own
145
+ * `createRoot` schedules its commits; `GameInternal.runFrame` correctly
146
+ * leaves a world with no `frame` untouched by its opaque-`update` fallback
147
+ * too, since `MountedReactGame` declares no `update`). `adapter` is typed
148
+ * as the real {@link ReactRootAdapter} shape (same reasoning as
149
+ * `registerCanvasRoot`'s doc comment above) — `RootInstance.adapter` only
150
+ * needs `.id`, so this passes through with zero cast.
151
+ */
152
+ export function registerReactRoot(
153
+ game: GameInternal,
154
+ adapter: RootAdapter<'dom'>,
155
+ mounted: MountedReactRoot,
156
+ container: HTMLElement,
157
+ opts?: RegisterRootOptions,
158
+ ): RootInstance {
159
+ const pausable = opts?.pausable ?? true;
160
+ const world = createRootInstance({
161
+ id: opts?.id ?? 'main',
162
+ kind: 'dom',
163
+ pausable,
164
+ adapter,
165
+ mounted,
166
+ container,
167
+ binding: bindRoot(game, opts?.declaration, { surface: 'dom', adapter }, mounted, pausable),
168
+ });
169
+ game.registerRoot(world);
170
+ return world;
171
+ }
172
+
173
+ /**
174
+ * One world to mount in the universal host — the host-facing mirror of
175
+ * `manifest/load.ts`'s `ResolvedAdapterRoot` (same identity and presentation
176
+ * fields; the
177
+ * manifest-to-host translation itself is the editor's job, not this file's).
178
+ *
179
+ * A `dom` root mounts as a DOM layer `<div>` in the same
180
+ * stack instead of a canvas — see {@link ReactRootMountSpec}/{@link
181
+ * ReactRootAdapter} below.
182
+ */
183
+ export interface RootMountSpecBase {
184
+ /** Manifest id — must be unique within one `roots` array. */
185
+ readonly id: string;
186
+ /** Canvas stacking order (D5 §1); ties broken by array
187
+ * order, mirroring `manifest/load.ts`'s `loadGameManifest` sort. Defaults
188
+ * to `0`. */
189
+ readonly zOrder?: number | undefined;
190
+ /** Whether play-mode pause/step applies to this world (D10). Defaults to `true`. */
191
+ readonly pausable?: boolean | undefined;
192
+ /**
193
+ * Optional claim predicate for the delegating input router (D5 §2a), over
194
+ * a point RELATIVE TO THE CONTAINER. Absent means: this world claims only
195
+ * if it ends up the bottom (lowest zOrder) world — see
196
+ * `input-router.ts`'s `resolveClaimingRoot`.
197
+ */
198
+ readonly hitTest?: ((x: number, y: number) => boolean) | undefined;
199
+ /**
200
+ * The DECLARATION half of this root's binding, from whoever resolved
201
+ * `adapter` — see `adapter/binding.ts`'s {@link RootDeclaration}. Carried on
202
+ * the spec because the resolver knows it and the host is the only one who
203
+ * can complete the binding (it needs `mounted`, which does not exist until
204
+ * this spec is mounted).
205
+ */
206
+ readonly declaration?: RootDeclaration | undefined;
207
+ }
208
+
209
+ export interface ThreeRootMountSpec extends RootMountSpecBase {
210
+ readonly kind: 'three';
211
+ readonly adapter: RootAdapter;
212
+ }
213
+
214
+ /**
215
+ * `adapter` is the SEAM's `RootAdapter<'canvas'>` — a canvas root's mount
216
+ * honestly returns a `MountedCanvasRoot`, and naming the real contract
217
+ * here is what keeps the resolver cast-free.
218
+ */
219
+ export interface CanvasRootMountSpec extends RootMountSpecBase {
220
+ readonly kind: 'canvas';
221
+ readonly adapter: RootAdapter<'canvas'>;
222
+ }
223
+
224
+ /**
225
+ * A live, mounted React world — the DOM analog of a mounted canvas world.
226
+ * React roots render from game state
227
+ * instead of a per-frame `update`, so this shape carries no `update`/`fixedUpdate`/
228
+ * `ctx`/`frame` — `drivesOwnLoop` is always `false` (react's `createRoot`
229
+ * schedules its OWN commits; the host's fixed-step loop never drives it,
230
+ * and it is correctly skipped by `GameInternal.runFrame`'s per-world
231
+ * `frame`-hooks/opaque-`update` dispatch — see `registerReactRoot` below,
232
+ * which registers this `RootInstance` with no `frame`, same as any other
233
+ * opaque mount with nothing to tick).
234
+ *
235
+ * Deliberately carries NO `firstParty: true` brand: that brand specifically
236
+ * a react world has no physics or component runtime at all, so branding it
237
+ * first-party would be a type lie.
238
+ * `Game.registerRoot`'s "no state bridge" console warning explicitly exempts
239
+ * `kind: 'dom'` (§7.1-15): a react world has no `observe` BY DESIGN — its
240
+ * state is its own React tree's.
241
+ *
242
+ * `kind`/`container` satisfy `MountedReactRoot` (`adapter/
243
+ * root-adapter.ts`) — `container` is the SAME `DomHostContext.container` the
244
+ * adapter's `mount` was handed (identity matters, mirroring `threeScene()`/
245
+ * `canvasRoot()`'s "same instance the adapter mounted" contract); every
246
+ * `ReactRootAdapter` implementer echoes it back here so `mounted` alone
247
+ * (with no separately-threaded `container`) satisfies the union
248
+ * `RootInstanceInit.mounted`/`RootInstance.mounted` with zero cast.
249
+ */
250
+ export interface MountedReactGame extends MountedReactRoot {
251
+ readonly drivesOwnLoop: false;
252
+ }
253
+
254
+ /**
255
+ * A react-shaped adapter — the structural contract `RootMountSpec`'s
256
+ * `react` variant requires. Deliberately
257
+ * NOT tied to a concrete implementer here so an editor-resolved adapter (the
258
+ * `default-react` resolver branch) can satisfy
259
+ * this shape without this file importing react-dom or any editor code.
260
+ *
261
+ * A genuine `RootAdapter<'dom'>` refinement (`HostContextFor<'dom'>` =
262
+ * `DomHostContext` = {@link DomHostContext}), narrowing only the return type to
263
+ * {@link MountedReactGame}.
264
+ */
265
+ export interface ReactRootAdapter extends RootAdapter<'dom'> {
266
+ readonly id: string;
267
+ mount(host: DomHostContext): Promise<MountedReactGame>;
268
+ }
269
+
270
+ /** Same reasoning as {@link CanvasRootMountSpec} above: the seam's
271
+ * `RootAdapter<'dom'>` is what a DOM root actually guarantees. */
272
+ export interface ReactRootMountSpec extends RootMountSpecBase {
273
+ readonly kind: 'dom';
274
+ readonly adapter: RootAdapter<'dom'>;
275
+ }
276
+
277
+ export type RootMountSpec = ThreeRootMountSpec | CanvasRootMountSpec | ReactRootMountSpec;
278
+
279
+ /**
280
+ * Mount N adapter roots (three + canvas + react)
281
+ * on ONE `Game`, stacked in `container` per D5 §1.
282
+ * `roots[0]` participates in `Game.defaultRoot`'s existing "first three
283
+ * root, else first root" rule.
284
+ */
285
+ export interface RootsRuntimeConfig {
286
+ /** The host creates one absolutely-positioned surface per world inside
287
+ * this element (D5 §1) — a canvas for three/canvas, a DOM-root `<div>`
288
+ * layer for react — plus one shared UI overlay above all of them. */
289
+ container: HTMLElement;
290
+ roots: RootMountSpec[];
291
+ width?: number | undefined;
292
+ height?: number | undefined;
293
+ /**
294
+ * Mirrors `ThreeHostContext.headless` (Node conformance tests — no GPU): skips
295
+ * real `WebGLRenderer` construction for every three world in this
296
+ * session (a stand-in renderer is used instead; adapters special-case
297
+ * `host.headless` themselves). Pixijs roots are unaffected — Pixi already falls back to
298
+ * a 2D canvas renderer with no GPU. Never set `true` in a real host.
299
+ */
300
+ headless?: boolean | undefined;
301
+ /**
302
+ * `vgai.project.json`'s `rendering.antialias` — whether every three root's
303
+ * WebGL context is built with a multisampled drawing buffer.
304
+ *
305
+ * It is a RUNTIME-CONFIG option rather than a world's own
306
+ * `WorldRendererConfig` field for the reason that file's header gives: a
307
+ * context's sample count is fixed at CREATION, before any world exists, so
308
+ * there is no honest moment at which a world could ask for it. The
309
+ * manifest-aware boot path (`mount-manifest.ts`) is the real reader;
310
+ * omitting it leaves `createHostRenderer`'s own registry default alone.
311
+ */
312
+ antialias?: boolean | undefined;
313
+ /** D15 (T-D15.1) — the root seed `ctx.random` boots from on every world
314
+ * mounted onto this session's Game, forwarded to `createGame` BEFORE any
315
+ * world's `mount()`/`setup()` runs (this function constructs the Game
316
+ * first — see `createRootsGameRuntime`). The manifest-aware boot path
317
+ * (`mount-manifest.ts`'s `mountManifestRoots`) is the real caller that
318
+ * resolves this from `manifest.determinism`/`?vgai-seed=`/its own
319
+ * explicit-config leg; a caller building `RootsRuntimeConfig` by hand
320
+ * (a test, a bespoke host) may also set it directly. Omitting it falls
321
+ * back to `createGame`'s own fixed default. */
322
+ seed?: number | undefined;
323
+ /** Private-play or Team Test identity supplied by the host. Networking
324
+ * remains game-owned; games may use `roomKey` in their direct room join. */
325
+ playtest?: PlaytestContext | null | undefined;
326
+ /**
327
+ * Test-only overrides for the render-control seam this host now wires up
328
+ * for EVERY session (see `createRootsGameRuntime`): `location` is where
329
+ * `?vgai-render=1` is read from (defaults to the real `window.location`;
330
+ * a Node test has no `window` and must inject one to exercise the seam),
331
+ * `target` is where the harness publishes (defaults to the real `window`
332
+ * — override in a unit test to avoid touching the global object; mirrors
333
+ * `RenderControlHarnessOptions.target`/`.location` and the
334
+ * `debugBridge.url` override precedent in `mount-manifest.ts`).
335
+ */
336
+ renderControl?:
337
+ | {
338
+ readonly location?: { readonly search: string } | undefined;
339
+ readonly target?: Record<string, unknown> | undefined;
340
+ }
341
+ | undefined;
342
+ }
343
+
344
+ /**
345
+ * Handle returned by createGameRuntime() for controlling the running game.
346
+ *
347
+ * This is the GENERIC host handle — it has no first-party concepts. A
348
+ * root's own surface is reached through `game.defaultRoot.mounted`, typed
349
+ * as the mounted-root contract (`MountedRoot`).
350
+ *
351
+ * Root access is explicit through `game.roots`, `game.world(id)`, and
352
+ * `game.defaultRoot`; the session does not project a surface-specific alias.
353
+ */
354
+ export interface GameSession {
355
+ stop(): void;
356
+ /** Resolves after every root has finished the cleanup initiated by stop(). */
357
+ readonly stopComplete: Promise<void>;
358
+ pause(): void;
359
+ resume(): void;
360
+ step(): void;
361
+ /** Resize every world's render buffer. `pixelRatio` optionally re-pins the
362
+ * three renderers' DPR in the same pass. */
363
+ resize(width: number, height: number, pixelRatio?: number): void;
364
+ /** The multi-root entry point. Root access stays explicit through
365
+ * `game.roots`, `game.world(id)`, and `game.defaultRoot`. */
366
+ readonly game: Game;
367
+ }
368
+
369
+ /**
370
+ * Bootstrap the game host and mount a game adapter.
371
+ *
372
+ * `createGameRuntime` is the universal HOST: it owns the root surfaces,
373
+ * renderers, loop, and asset cache. Every game — including a one-root game —
374
+ * uses the same explicit adapter-root path.
375
+ */
376
+ export async function createGameRuntime(config: RootsRuntimeConfig): Promise<GameSession> {
377
+ return createRootsGameRuntime(config);
378
+ }
379
+
380
+ // ---------------------------------------------------------------------------
381
+ // Universal root host
382
+ // ---------------------------------------------------------------------------
383
+
384
+ /** A stand-in `THREE.WebGLRenderer` for headless (`headless:true`) three
385
+ * roots: draw submission is inert while native scene/component evaluation
386
+ * still runs. This satisfies Fiber's custom-renderer gate and the calls
387
+ * (`setPixelRatio`/`setClearColor`/`setSize` at mount, `dispose`/
388
+ * `forceContextLoss` at teardown) — never a real GL call. Node conformance
389
+ * tests only; never used when `headless` is left `false`/absent. */
390
+ function createHeadlessRendererStub(canvas: HTMLCanvasElement): THREE.WebGLRenderer {
391
+ return {
392
+ domElement: canvas,
393
+ render() {},
394
+ setPixelRatio() {},
395
+ setClearColor() {},
396
+ setSize() {},
397
+ dispose() {},
398
+ forceContextLoss() {},
399
+ } as unknown as THREE.WebGLRenderer;
400
+ }
401
+
402
+ /** One already-mounted world, tracked for disposal + the router. `element`
403
+ * is the world's stacked surface — an `HTMLCanvasElement` for three/
404
+ * canvas, or the DOM-root layer `<div>` for React — kept
405
+ * under one field name so `fullCleanup`'s disposal loop stays kind-generic
406
+ * (`container.removeChild(entry.element)` needs no branch). */
407
+ interface MountedAdapterRoot {
408
+ readonly id: string;
409
+ readonly kind: 'three' | 'canvas' | 'dom';
410
+ readonly element: HTMLElement;
411
+ readonly mounted: MountedRoot;
412
+ /** Only three roots own a renderer this file constructed. */
413
+ readonly renderer: THREE.WebGLRenderer | undefined;
414
+ }
415
+
416
+ /**
417
+ * Reclaim ONE mounted root: its adapter, the renderer this file constructed for
418
+ * it (including the WebGL context — a browser keeps only a handful alive, so
419
+ * `dispose()` alone is not enough), and its stacked surface element.
420
+ *
421
+ * ONE body, TWO callers, deliberately: the session's `fullCleanup` (a normal
422
+ * stop) and `mountAllRootSpecs`'s partial-mount ROLLBACK (a later root's mount
423
+ * rejected, so the roots that already mounted must not survive the rejection).
424
+ * Those two must never drift — a rollback that reclaims less than a stop is how
425
+ * a failed play leaves a live renderer and a lost GL context behind with no
426
+ * handle anywhere able to reach it.
427
+ *
428
+ * Throws only what the adapter's own `dispose()` throws; every caller wraps it
429
+ * per entry so one bad root cannot abort the rest of the teardown.
430
+ */
431
+ function disposeMountedRoot(entry: MountedAdapterRoot, container: HTMLElement): void {
432
+ try {
433
+ entry.mounted.dispose();
434
+ } finally {
435
+ // Renderer + surface release run even when the adapter's dispose threw:
436
+ // the GL context is the scarce resource, and the caller still sees the
437
+ // error (this rethrows through the `finally`).
438
+ entry.renderer?.dispose();
439
+ entry.renderer?.forceContextLoss();
440
+ // Remove from the element's CURRENT parent, not the mount-time
441
+ // `container`: the editor's Game panel re-parents the live surfaces
442
+ // when its mount element swaps (fill <-> device preset, W2c), and
443
+ // `container.removeChild` would throw NotFoundError after such a move.
444
+ // `container` remains the fallback for hosts whose element stand-ins
445
+ // never wire `parentNode` (headless unit fixtures); an element already
446
+ // detached by such a host is a no-op via the catch.
447
+ try {
448
+ (entry.element.parentNode ?? container).removeChild(entry.element);
449
+ } catch {
450
+ /* already detached — nothing to remove */
451
+ }
452
+ }
453
+ }
454
+
455
+ interface OneRootResult {
456
+ readonly mountedEntry: MountedAdapterRoot;
457
+ readonly routerEntry: RouterAdapterRoot;
458
+ }
459
+
460
+ /** Shared per-world mount inputs, computed once in `createRootsGameRuntime`'s
461
+ * loop (canvas stacking + one dpr) and threaded into whichever of
462
+ * `mountOneThreeRoot`/`mountOneCanvasRoot` this world's `kind` needs — split
463
+ * out so the orchestrating loop itself stays a simple dispatch. */
464
+ interface OneRootContext {
465
+ readonly game: GameInternal;
466
+ readonly canvas: HTMLCanvasElement;
467
+ readonly w: number;
468
+ readonly h: number;
469
+ readonly dpr: number;
470
+ readonly isBottom: boolean;
471
+ readonly headless: boolean;
472
+ /** `RootsRuntimeConfig.antialias` — see that field. Undefined leaves
473
+ * `createHostRenderer`'s own registry default alone. */
474
+ readonly antialias: boolean | undefined;
475
+ readonly assets: ReturnType<typeof createAssetCache>;
476
+ }
477
+
478
+ /** Mount one three `RootMountSpec` (canvas + renderer construction, per
479
+ * D5 §1/§4, then `registerThreeRoot`). Split out of
480
+ * `createRootsGameRuntime` purely to keep that function's own branching
481
+ * simple — see `mountOneCanvasRoot` for the canvas sibling. */
482
+ async function mountOneThreeRoot(
483
+ spec: ThreeRootMountSpec,
484
+ ctx: OneRootContext,
485
+ ): Promise<OneRootResult> {
486
+ const { game, canvas, w, h, dpr, isBottom, headless, assets, antialias } = ctx;
487
+ const renderer = headless
488
+ ? createHeadlessRendererStub(canvas)
489
+ : createHostRenderer(
490
+ canvas,
491
+ w,
492
+ h,
493
+ // The manifest's `rendering.antialias` reaches the WebGL context here and NOWHERE else:
494
+ // `createHostRenderer` passes it straight to the constructor, and the sample count is
495
+ // fixed from that moment. Omitted → the render-settings registry's own default.
496
+ antialias === undefined ? undefined : { antialias },
497
+ {
498
+ alpha: !isBottom,
499
+ preserveDrawingBuffer: true,
500
+ },
501
+ );
502
+ renderer.setSize(w, h, false); // backing resolution only — CSS stacking owns layout size
503
+ // Defect E4.R1 (Fable review): `createHostRenderer`'s OWN construction-time
504
+ // `setSize` call (`setup-renderer.ts`, `updateStyle` defaulting `true` —
505
+ // unchanged there on purpose, see below) stamps `canvas.style.width`/
506
+ // `.height` to literal `${w}px`/`${h}px` the moment the renderer is built,
507
+ // OVERWRITING the container-relative `100%`/`100%` the surface-stacking
508
+ // loop above just set. The `renderer.setSize(w, h, false)` line right above
509
+ // this comment does NOT undo that stamp (updateStyle:false only skips
510
+ // TOUCHING style, it can't un-stamp a previous call) — so without this
511
+ // re-assertion, every roots-path three canvas' on-screen size was
512
+ // permanently pinned to whatever `w`/`h` it happened to mount at (usually
513
+ // the manifest's `resolution`, since standalone builds mount before a real
514
+ // container size is known — see `mount-manifest.ts`). Re-asserting here
515
+ // makes the CSS layout size and the render-buffer resolution fully
516
+ // independent, exactly like the react DOM world layer already is: this
517
+ // fixes the template-standalone bug (broken at any viewport other than the
518
+ // manifest's `resolution`, which is also Playwright's DEFAULT viewport —
519
+ // why it went unnoticed) and, for free, tri-world's pre-existing
520
+ // resize-staleness (the roots-path `resize()` below always calls
521
+ // `setSize(rw, rh, false)`, so nothing else was ever going to update this
522
+ // canvas' CSS after mount). `createHostRenderer`'s own default
523
+ // (`updateStyle:true`) is intentionally left alone; the host owns CSS layout
524
+ // and reasserts the container-relative size after renderer construction.
525
+ canvas.style.width = '100%';
526
+ canvas.style.height = '100%';
527
+ renderer.setPixelRatio(dpr);
528
+ if (!isBottom) renderer.setClearColor(0x000000, 0); // D5 §1: alpha-clear above the bottom layer
529
+
530
+ const host: GameThreeHostContext = {
531
+ three: THREE,
532
+ surface: { canvas, width: w, height: h },
533
+ renderer,
534
+ assets,
535
+ headless,
536
+ game,
537
+ };
538
+ const mounted = await spec.adapter.mount(host);
539
+ registerThreeRoot(game, spec.adapter, mounted, {
540
+ id: spec.id,
541
+ pausable: spec.pausable,
542
+ declaration: spec.declaration,
543
+ });
544
+ return {
545
+ mountedEntry: { id: spec.id, kind: 'three', element: canvas, mounted, renderer },
546
+ routerEntry: { id: spec.id, zOrder: spec.zOrder ?? 0, canvas, hitTest: spec.hitTest },
547
+ };
548
+ }
549
+
550
+ /** Mount one canvas `RootMountSpec` (via its own `CanvasHostContext`, per D5
551
+ * §1/§3/§4, then `registerCanvasRoot`) — the canvas sibling of
552
+ * `mountOneThreeRoot` above.
553
+ *
554
+ * Unlike `mountOneThreeRoot`, this needs no explicit `canvas.style.width`/
555
+ * `.height` re-assertion. The canvas surface constructs its
556
+ * `Application` with `autoDensity: true`, which makes PIXI ITSELF re-stamp
557
+ * `canvas.style.width`/`.height` (real CSS px, matching the LOGICAL
558
+ * width/height passed to `resize()`) on every `app.renderer.resize()` call —
559
+ * including the one this world's `mounted.resize?.()` triggers from the
560
+ * roots-path `resize()` below. So while a pixi world's canvas can start
561
+ * pinned to the mount-time `w`/`h` (same as three, until PIXI's own
562
+ * construction-time resize runs), it self-heals the moment ANY real
563
+ * `session.resize(rw, rh)` fires — every shipped standalone entry
564
+ * (`packages/editor/template/src/main.ts`, `examples/tri-world/src/main.ts`)
565
+ * already calls `session.resize()` unconditionally right after mount, so
566
+ * this never surfaces as a lasting bug the way three' buffer-only resize
567
+ * did (never self-healing, by design — see above). */
568
+ async function mountOneCanvasRoot(
569
+ spec: CanvasRootMountSpec,
570
+ ctx: Omit<OneRootContext, 'assets'>,
571
+ ): Promise<OneRootResult> {
572
+ const { game, canvas, w, h, dpr, headless, isBottom } = ctx;
573
+ const pixiHost: GameCanvasHostContext = {
574
+ canvas,
575
+ width: w,
576
+ height: h,
577
+ game,
578
+ headless,
579
+ dpr,
580
+ transparent: !isBottom,
581
+ preserveDrawingBuffer: true,
582
+ };
583
+ const mounted = await spec.adapter.mount(pixiHost);
584
+ registerCanvasRoot(game, spec.adapter, mounted, {
585
+ id: spec.id,
586
+ pausable: spec.pausable,
587
+ declaration: spec.declaration,
588
+ });
589
+ return {
590
+ mountedEntry: {
591
+ id: spec.id,
592
+ kind: 'canvas',
593
+ element: canvas,
594
+ mounted,
595
+ renderer: undefined,
596
+ },
597
+ routerEntry: {
598
+ id: spec.id,
599
+ zOrder: spec.zOrder ?? 0,
600
+ canvas,
601
+ hitTest: spec.hitTest,
602
+ },
603
+ };
604
+ }
605
+
606
+ /**
607
+ * Mount one React `RootMountSpec` — the DOM sibling of
608
+ * `mountOneThreeRoot`/`mountOneCanvasRoot`. Unlike its canvas-backed siblings
609
+ * this returns NO `routerEntry`: a react world's DOM-root layer participates in
610
+ * D5's z-order/box stacking (the caller still creates and positions its `<div>`
611
+ * exactly like a canvas — see `createRootsGameRuntime`'s stack-building loop)
612
+ * but needs no entry in the delegating router's hit-test loop (§1.C — "DOM
613
+ * layers need no entry in the router's hit-test loop"): the layer's own
614
+ * `pointer-events` discipline (this file sets `none` on the layer by default;
615
+ * the mounted React tree opts specific elements back in with
616
+ * `pointer-events:auto`) is what lets its interactive elements claim events
617
+ * NATIVELY, via the real DOM, with zero router involvement — and lets a click
618
+ * over its non-interactive (transparent) area fall through to the canvas below
619
+ * it via the SAME native DOM hit-testing (a `pointer-events:none` element is
620
+ * invisible to hit-testing entirely, so the click lands on whatever real DOM
621
+ * element is beneath it — the router's normal canvas-vs-canvas forwarding,
622
+ * unaffected by this layer's presence).
623
+ */
624
+ async function mountOneReactRoot(
625
+ spec: ReactRootMountSpec,
626
+ game: GameInternal,
627
+ /** The ALREADY-created, already-stacked (position/z-index set, appended to
628
+ * `container`) DOM-root layer for this world — see
629
+ * `createRootsGameRuntime`'s surface-stack loop, which builds a `<div>`
630
+ * for every react-kind spec up front, in the SAME pass that builds every
631
+ * other world's canvas. This function must reuse that exact element (never
632
+ * create its own) so `RootInstance.reactRoot()` returns the SAME node
633
+ * that is actually positioned in the stack. */
634
+ layer: HTMLElement,
635
+ ): Promise<{ mountedEntry: MountedAdapterRoot }> {
636
+ layer.style.pointerEvents = 'none';
637
+ // An absolutely-positioned layer with no width/height collapses to zero
638
+ // content size, so a child's
639
+ // own position:absolute offsets resolve against a degenerate containing
640
+ // block; otherwise clicks land outside the game.
641
+ layer.style.width = '100%';
642
+ layer.style.height = '100%';
643
+ const reactHost: GameDomHostContext = { container: layer, game };
644
+ const mounted = await spec.adapter.mount(reactHost);
645
+ registerReactRoot(game, spec.adapter, mounted, layer, {
646
+ id: spec.id,
647
+ pausable: spec.pausable,
648
+ declaration: spec.declaration,
649
+ });
650
+ return {
651
+ mountedEntry: {
652
+ id: spec.id,
653
+ kind: 'dom',
654
+ element: layer,
655
+ mounted,
656
+ renderer: undefined,
657
+ },
658
+ };
659
+ }
660
+
661
+ /**
662
+ * The universal host implementer behind {@link createGameRuntime}. It builds
663
+ * one surface per world — a
664
+ * canvas for three/canvas, a DOM-root `<div>` layer for react — stacked
665
+ * per D5 §1, z-order/ties exactly matching
666
+ * `manifest/load.ts`'s sort, ONE `Game`, and registers every world onto it
667
+ * via `registerThreeRoot`/`registerCanvasRoot`/`registerReactRoot` — the
668
+ * SAME wiring `test/create-runtime-worlds.test.ts` proves for the
669
+ * three/canvas pair. Worlds MOUNT in `roots` ARRAY order ("manifest
670
+ * declaration order" — the frame/registration axis), independent of
671
+ * `zOrder` (the canvas-stacking/rendering axis) — the two orders can differ
672
+ * and both are honored correctly.
673
+ */
674
+
675
+ /**
676
+ * Dev/e2e-only `window.__vgaiScene`/`__vgaiCamera` exposure for the roots
677
+ * path's default world — split out of `createRootsGameRuntime` purely
678
+ * to keep that function's own cyclomatic complexity down. It uses the
679
+ * "first three world, else none" rule: a non-Three default world publishes
680
+ * neither global. Returns an identity-guarded retraction callback so an
681
+ * older session's stop cannot clobber a newer session's globals. It is a no-op when nothing was
682
+ * published (non-DEV build, or non-threejs default world).
683
+ */
684
+ function installDefaultRootDevGlobals(game: GameInternal): () => void {
685
+ if (!import.meta.env?.DEV) return () => {};
686
+ const defaultMounted = game.defaultRoot.mounted;
687
+ if (defaultMounted.kind !== 'three') return () => {};
688
+ const scene = defaultMounted.scene;
689
+ const w = window as unknown as Record<string, unknown>;
690
+ w['__vgaiScene'] = scene;
691
+ // An ACCESSOR, not a value read once: a world may replace its camera after
692
+ // mount (fiber's `set({ camera })` — drei's `makeDefault`, or a translated
693
+ // Godot world installing the camera its `.tscn` authors), and a snapshot
694
+ // here reads as authoritative to the probe that reaches for it while naming
695
+ // a camera the frame no longer uses. The retraction guard compares the
696
+ // GETTER's identity, which is the same "don't clobber a newer session"
697
+ // rule the value comparison was.
698
+ const readCamera = (): THREE.PerspectiveCamera =>
699
+ defaultMounted.camera as THREE.PerspectiveCamera;
700
+ Object.defineProperty(w, '__vgaiCamera', {
701
+ get: readCamera,
702
+ configurable: true,
703
+ enumerable: true,
704
+ });
705
+ return () => {
706
+ if (w['__vgaiScene'] === scene) delete w['__vgaiScene'];
707
+ if (Object.getOwnPropertyDescriptor(w, '__vgaiCamera')?.get === readCamera)
708
+ delete w['__vgaiCamera'];
709
+ };
710
+ }
711
+
712
+ /** Shared inputs `mountAllRootSpecs` needs beyond each individual spec —
713
+ * everything `OneRootContext` needs except the per-world `canvas`/
714
+ * `isBottom`, plus the surface lookup and bottom-id needed to derive them. */
715
+ interface MountAllRootsInputs extends Omit<OneRootContext, 'canvas' | 'isBottom'> {
716
+ readonly surfacesById: Map<string, HTMLElement>;
717
+ /** The host element the surfaces were stacked into — needed only by the
718
+ * partial-mount rollback, which reclaims them through the same
719
+ * {@link disposeMountedRoot} body a normal stop uses. */
720
+ readonly container: HTMLElement;
721
+ }
722
+
723
+ interface MountAllRootsResult {
724
+ readonly mountedEntries: MountedAdapterRoot[];
725
+ readonly routerEntries: RouterAdapterRoot[];
726
+ }
727
+
728
+ /**
729
+ * Mount + register every world spec, in ARRAY (declaration) order — split
730
+ * out of `createRootsGameRuntime` purely to keep that function's own
731
+ * cyclomatic complexity down (E4, same reason `mountOneThreeRoot`/
732
+ * `mountOneCanvasRoot` are already split out). React roots contribute NO
733
+ * router entry ("DOM layers need no entry in the router's hit-test loop"):
734
+ * their layer's own `pointer-events` discipline handles claim/fall-through
735
+ * natively, with zero router involvement (see `mountOneReactRoot`'s doc
736
+ * comment).
737
+ */
738
+ async function mountAllRootSpecs(
739
+ mountSpecs: (ThreeRootMountSpec | CanvasRootMountSpec | ReactRootMountSpec)[],
740
+ bottomId: string | undefined,
741
+ inputs: MountAllRootsInputs,
742
+ ): Promise<MountAllRootsResult> {
743
+ const { surfacesById, container, ...shared } = inputs;
744
+ const mountedEntries: MountedAdapterRoot[] = [];
745
+ const routerEntries: RouterAdapterRoot[] = [];
746
+
747
+ try {
748
+ for (const spec of mountSpecs) {
749
+ const isBottom = spec.id === bottomId;
750
+ if (spec.kind === 'dom') {
751
+ const layer = surfacesById.get(spec.id)!;
752
+ const { mountedEntry } = await mountOneReactRoot(spec, shared.game, layer);
753
+ mountedEntries.push(mountedEntry);
754
+ continue;
755
+ }
756
+ const canvas = surfacesById.get(spec.id)! as HTMLCanvasElement;
757
+ const oneCtx: OneRootContext = { ...shared, canvas, isBottom };
758
+ let mountedEntry: MountedAdapterRoot;
759
+ let routerEntry: RouterAdapterRoot;
760
+ if (spec.kind === 'three') {
761
+ ({ mountedEntry, routerEntry } = await mountOneThreeRoot(spec, oneCtx));
762
+ } else if (spec.kind === 'canvas') {
763
+ ({ mountedEntry, routerEntry } = await mountOneCanvasRoot(spec, oneCtx));
764
+ } else {
765
+ // Exhaustiveness guard (§7.4-2): 'react' was already handled by the
766
+ // early `continue` above, so only a hypothetical 4th `AdapterSurface` can
767
+ // reach here — fail loudly rather than silently defaulting. `spec`
768
+ // itself (not `spec.kind`) is what TS has narrowed to `never`, since
769
+ // `RootMountSpec` is a discriminated union at the object level.
770
+ assertNever(spec, 'create-runtime mount loop');
771
+ }
772
+ mountedEntries.push(mountedEntry);
773
+ routerEntries.push(routerEntry);
774
+ }
775
+ } catch (err) {
776
+ // PARTIAL MOUNT ROLLBACK. Roots mount sequentially, so a rejection from
777
+ // root N leaves roots 1..N-1 fully live — renderer, WebGL context, R3F
778
+ // tree, adapter registrations — and NOTHING ever gets a handle to them:
779
+ // this function throws instead of returning, so no `GameSession` (and
780
+ // therefore no `stop()`) is ever constructed. Reclaim them here, in
781
+ // REVERSE mount order (the inverse of the order they were built in), then
782
+ // rethrow the ORIGINAL error — the mount failure is what the caller must
783
+ // see, never a teardown error raised while cleaning up after it.
784
+ for (let i = mountedEntries.length - 1; i >= 0; i--) {
785
+ const entry = mountedEntries[i]!;
786
+ try {
787
+ disposeMountedRoot(entry, container);
788
+ } catch (disposeErr) {
789
+ // biome-ignore lint/suspicious/noConsole: a rollback failure must be visible; the original mount error is still what we rethrow
790
+ console.error(
791
+ `createGameRuntime: rolling back root "${entry.id}" after a mount failure threw:`,
792
+ disposeErr,
793
+ );
794
+ }
795
+ }
796
+ throw err;
797
+ }
798
+ return { mountedEntries, routerEntries };
799
+ }
800
+
801
+ /**
802
+ * G3/FT-11 — publish the render-control harness (`render-control.ts`) for a
803
+ * live session, returning its retraction (wired into the session's own
804
+ * `stop()`, same leave-nothing-live rule as the debug bridge's `uninstall()`
805
+ * in mount-manifest.ts — render-control has no uninstall surface of its own,
806
+ * a render page being a single-load host by design, so the retraction is
807
+ * the publication's inverse). Split out of `createRootsGameRuntime` purely
808
+ * to keep that function's cyclomatic complexity down (the same reason
809
+ * `mountAllRootSpecs`/`installDefaultRootDevGlobals` are split out).
810
+ *
811
+ * The clock is a documented no-op: a manifest-mounted game has no global
812
+ * cinematic AnimationClock to seek (a cinematic fixture that HAS one
813
+ * installs its own harness with the real clock, e.g.
814
+ * packages/engine/e2e/render-cinematic/main.ts) — the meaningful advance
815
+ * path for a plain game is `simulateSubsteps` (I5 `--simulate`), which
816
+ * drives `game.runFrame`.
817
+ */
818
+ function installSessionRenderHarness(
819
+ game: GameInternal,
820
+ location: { readonly search: string },
821
+ targetOverride: Record<string, unknown> | undefined,
822
+ ): (() => void) | undefined {
823
+ const target =
824
+ targetOverride ??
825
+ (typeof window !== 'undefined' ? (window as unknown as Record<string, unknown>) : undefined);
826
+ if (target === undefined) return undefined;
827
+ installRenderControlHarness({
828
+ game,
829
+ clock: {
830
+ seek() {
831
+ /* no global cinematic clock on a manifest-mounted game — see above */
832
+ },
833
+ seekFrame() {
834
+ /* intentional no-op, as seek() */
835
+ },
836
+ },
837
+ readiness: {
838
+ // Mount is complete by the time this harness exists (the install site
839
+ // in `createRootsGameRuntime` is after `mountAllRootSpecs`
840
+ // resolved) — the hook resolving immediately IS the readiness
841
+ // statement, not an assumption.
842
+ scene() {},
843
+ },
844
+ location,
845
+ target,
846
+ });
847
+ return () => {
848
+ delete target['__vgaiRender'];
849
+ };
850
+ }
851
+
852
+ /**
853
+ * Reclaim what `createRootsGameRuntime` built for ITSELF when the mount
854
+ * rejects. `mountAllRootSpecs` has already rolled back every root that DID
855
+ * mount, so what is left is the Game shell (holding whatever registrations
856
+ * those roots made into it) and the stacked surface elements of the roots that
857
+ * never got to mount. Neither is reachable afterwards: the mount throws instead
858
+ * of returning, so no `GameSession` — and therefore no `stop()` — ever exists.
859
+ *
860
+ * Never throws: the MOUNT error is what the caller must see, so a failure while
861
+ * cleaning up after it is reported and swallowed.
862
+ */
863
+ function reclaimAfterMountFailure(
864
+ game: GameInternal,
865
+ surfaces: Iterable<HTMLElement>,
866
+ container: HTMLElement,
867
+ ): void {
868
+ try {
869
+ game.dispose();
870
+ } catch (disposeErr) {
871
+ // biome-ignore lint/suspicious/noConsole: a failed rollback must be visible; the original mount error is what the caller gets
872
+ console.error('createGameRuntime: disposing the game after a mount failure threw:', disposeErr);
873
+ }
874
+ for (const surface of surfaces) {
875
+ try {
876
+ (surface.parentNode ?? container).removeChild(surface);
877
+ } catch {
878
+ /* already detached by the per-root rollback */
879
+ }
880
+ }
881
+ }
882
+
883
+ async function createRootsGameRuntime(config: RootsRuntimeConfig): Promise<GameSession> {
884
+ const { container, roots: specs, width, height, headless = false, seed, playtest } = config;
885
+ if (specs.length === 0) {
886
+ throw new Error('createGameRuntime: `roots` must contain at least one root.');
887
+ }
888
+ const mountSpecs = specs as (ThreeRootMountSpec | CanvasRootMountSpec | ReactRootMountSpec)[];
889
+
890
+ const w = Math.max(
891
+ 1,
892
+ width ?? (container as HTMLElement & { clientWidth?: number }).clientWidth ?? 0,
893
+ );
894
+ const h = Math.max(
895
+ 1,
896
+ height ?? (container as HTMLElement & { clientHeight?: number }).clientHeight ?? 0,
897
+ );
898
+ const dpr = headless
899
+ ? 1
900
+ : Math.min(typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1, 2);
901
+
902
+ if (!container.style.position) container.style.position = 'relative';
903
+
904
+ // --- Surface stack (D5 §1): DOM/z-order follows zOrder, ties -> array order
905
+ // — computed FIRST (bottom -> top) so both the z-index assignment below and
906
+ // the router's default-claim rule share one definition. A react world's
907
+ // DOM-root layer shares this SAME stacking pass even though it is a `<div>`, not a
908
+ // canvas, and carries no `hitTest` (the router never sees react entries at
909
+ // all — see the dispatch loop below). ---
910
+ const claimEntries = mountSpecs.map((spec) => ({
911
+ id: spec.id,
912
+ zOrder: spec.zOrder ?? 0,
913
+ hitTest: spec.hitTest,
914
+ }));
915
+ const stacked = stackOrder(claimEntries);
916
+ const bottomId = stacked[0]?.id;
917
+ const kindById = new Map(mountSpecs.map((spec) => [spec.id, spec.kind] as const));
918
+
919
+ const surfacesById = new Map<string, HTMLElement>();
920
+ stacked.forEach((entry, i) => {
921
+ const isReact = kindById.get(entry.id) === 'dom';
922
+ const surface = document.createElement(isReact ? 'div' : 'canvas') as HTMLElement;
923
+ surface.dataset['vgaiRootSurface'] = 'true';
924
+ // WHICH ROOT THIS SURFACE PRESENTS — the host mounted it, so the host
925
+ // knows, and stamping it here is the difference between a declaration and
926
+ // the guess every late reader had to make instead ("the first <canvas> in
927
+ // DOM order is the game's"). Read by `packages/editor/src/presentation-
928
+ // surface.ts`, which is the one door the capture/staleness/screenshot
929
+ // sites now ask (ARCHITECTURE-CORE §The editor protocol, zero inference).
930
+ surface.dataset['vgaiRootId'] = entry.id;
931
+ if (!isReact) {
932
+ const canvas = surface as unknown as HTMLCanvasElement;
933
+ canvas.width = w;
934
+ canvas.height = h;
935
+ }
936
+ // Defect E4.R1 (Fable review): layout size is ALWAYS container-relative
937
+ // (`width:100%;height:100%`), fully decoupled from the surface's
938
+ // intrinsic buffer resolution (`w`/`h`, set on the canvas element's
939
+ // `width`/`height` ATTRIBUTES above — a device-pixel/render-resolution
940
+ // concern only). Without this, a canvas with no CSS size falls back to
941
+ // its `width`/`height` attribute as its CSS layout size too, so whatever
942
+ // stamped those attributes (`createHostRenderer`'s construction-time
943
+ // `setSize`, `mountOneThreeRoot` below) pins the ON-SCREEN size — see
944
+ // that function's doc comment for the concrete bug this caused (template
945
+ // standalone at any viewport ≠ the manifest resolution). Setting it HERE
946
+ // too (not just in `mountOneThreeRoot`) means every surface, whatever
947
+ // kind, starts container-relative from its very first paint, before any
948
+ // per-kind mount work has even run.
949
+ // `contain:layout paint` makes each surface the containing block for
950
+ // `position:fixed` descendants (and clips overflow to the world's
951
+ // rectangle): full-screen game UI written the natural way (`fixed;
952
+ // inset:0`) then fills the WORLD, not the page. Without it, `fixed` UI
953
+ // looks correct standalone (surface == window) but escapes over the
954
+ // editor chrome whenever the surface is a sub-rectangle of the page —
955
+ // and toggles behavior when any ancestor gains a transform (e.g. the
956
+ // editor's space-pan). Dogfooded 2026-07-12 via a react shell world.
957
+ surface.style.cssText = `position:absolute;top:0;left:0;width:100%;height:100%;z-index:${i + 1};contain:layout paint;`;
958
+ container.appendChild(surface);
959
+ surfacesById.set(entry.id, surface);
960
+ });
961
+
962
+ const assets = createAssetCache();
963
+ // Every manifest/host-mounted game is a deterministic-capture candidate —
964
+ // the render-control seam (`?vgai-render=1`, render-control.ts) is wired
965
+ // HERE, at the one host every boot path shares (mountManifestRoots and
966
+ // direct createGameRuntime callers both reach this function), instead of asking every project's entry page to install it
967
+ // the way the e2e fixtures do. Production-protected twice over:
968
+ // `isRenderModeRequested` gates on the query param, and
969
+ // `installRenderControlHarness` re-checks it internally (its AC 4), so a
970
+ // normal gameplay page sees zero change.
971
+ const renderModeLocation =
972
+ config.renderControl?.location ?? (typeof window !== 'undefined' ? window.location : undefined);
973
+ const renderMode = renderModeLocation !== undefined && isRenderModeRequested(renderModeLocation);
974
+ let started = false;
975
+ const loop = createGameLoop({
976
+ fixedTimestep: 1 / 60,
977
+ maxSubSteps: 8,
978
+ // Render mode runs the loop in EXTERNAL-DRIVE mode (game-loop.ts, I2):
979
+ // no RAF is ever armed, so nothing advances gameplay between the capture
980
+ // host's explicit `simulateSubsteps`/`renderOnce` calls — wall-clock time
981
+ // passing while a screenshot is taken must not move the world.
982
+ externalDrive: renderMode,
983
+ // Sim and presentation are wired to different callbacks here:
984
+ // `update` runs the gameplay phases once per consumed fixed substep with
985
+ // `preRender`/`render` withheld, and `render` runs those two once per real
986
+ // display frame with the interpolation alpha. Both closures are invoked
987
+ // only from the loop's own rAF arm, which `externalDrive` never arms — so
988
+ // a capture/offline-export page (`renderMode`) reaches neither, and its
989
+ // `simulateSubsteps` → `game.runFrame(fixedDt)` path stays frame-exact,
990
+ // rendering inside the substep.
991
+ update: (dt) => {
992
+ if (started) game.runFrame(dt, { skipRenderPhases: true });
993
+ },
994
+ render: (alpha, displayDt) => {
995
+ if (started) game.runRenderFrame(alpha, displayDt);
996
+ },
997
+ });
998
+ const game = createGame({ loop, assets, seed, playtest });
999
+
1000
+ // --- Mount + register every world, in ARRAY (declaration) order. ---
1001
+ // Split out into its own top-level function purely to keep
1002
+ // `createRootsGameRuntime`'s own cyclomatic complexity down (E4) — same
1003
+ // reason `mountOneThreeRoot`/`mountOneCanvasRoot` are already split out
1004
+ // below.
1005
+ let mounted: MountAllRootsResult;
1006
+ try {
1007
+ mounted = await mountAllRootSpecs(mountSpecs, bottomId, {
1008
+ game,
1009
+ surfacesById,
1010
+ container,
1011
+ w,
1012
+ h,
1013
+ dpr,
1014
+ headless,
1015
+ assets,
1016
+ antialias: config.antialias,
1017
+ });
1018
+ } catch (err) {
1019
+ reclaimAfterMountFailure(game, surfacesById.values(), container);
1020
+ throw err;
1021
+ }
1022
+ const { mountedEntries, routerEntries } = mounted;
1023
+
1024
+ // --- Delegating input router (D5 §2a) ---
1025
+ const router = createInputRouter(container, routerEntries);
1026
+
1027
+ started = true;
1028
+ loop.start();
1029
+
1030
+ // Expose the default Three root's scene and camera for dev/e2e tooling.
1031
+ const retractDevGlobals = installDefaultRootDevGlobals(game);
1032
+
1033
+ // G3/FT-11 — publish `window.__vgaiRender` for the deterministic capture
1034
+ // host. Installed at the TAIL of the mount (same position as
1035
+ // mount-manifest.ts's debug bridge): every world's `mount()`/`setup()` has
1036
+ // resolved by now, so the harness's `scene` readiness hook reporting ready
1037
+ // is truthful. See `installSessionRenderHarness` below.
1038
+ const retractRenderHarness =
1039
+ renderMode && renderModeLocation !== undefined
1040
+ ? installSessionRenderHarness(game, renderModeLocation, config.renderControl?.target)
1041
+ : undefined;
1042
+
1043
+ let resolveStopComplete!: () => void;
1044
+ const stopComplete = new Promise<void>((resolve) => {
1045
+ resolveStopComplete = resolve;
1046
+ });
1047
+ let stopping = false;
1048
+
1049
+ /**
1050
+ * Reclaim EVERYTHING this session owns, and let no single failure stop that.
1051
+ *
1052
+ * Teardown used to be one `try` over the whole body, so a throwing
1053
+ * `entry.mounted.dispose()` on root 1 skipped root 2's disposal, both
1054
+ * renderers' `forceContextLoss`, `game.dispose()` and the two global
1055
+ * retractions — while `stopComplete` still resolved and the editor still
1056
+ * reported the instance reclaimed. Every step is now isolated: a failure is
1057
+ * COLLECTED and reported, never allowed to abort the steps after it.
1058
+ */
1059
+ function fullCleanup(): void {
1060
+ if (stopping) return;
1061
+ stopping = true;
1062
+ const failures: { readonly step: string; readonly error: unknown }[] = [];
1063
+ /** Run one teardown step; record its failure and keep going. */
1064
+ const step = (name: string, run: () => void): void => {
1065
+ try {
1066
+ run();
1067
+ } catch (error) {
1068
+ failures.push({ step: name, error });
1069
+ }
1070
+ };
1071
+ try {
1072
+ step('loop.stop', () => loop.stop());
1073
+ step('router.dispose', () => router.dispose());
1074
+ for (const entry of mountedEntries) {
1075
+ step(`root "${entry.id}"`, () => disposeMountedRoot(entry, container));
1076
+ }
1077
+ step('game.dispose', () => game.dispose());
1078
+ step('dev globals', () => retractDevGlobals());
1079
+ step('render harness', () => retractRenderHarness?.());
1080
+ } finally {
1081
+ void Promise.allSettled(
1082
+ mountedEntries.map((entry) => entry.mounted.disposeComplete ?? Promise.resolve()),
1083
+ ).then(() => resolveStopComplete());
1084
+ }
1085
+ if (failures.length > 0) {
1086
+ // LOUD, and after everything else has been reclaimed. `stop()` is called
1087
+ // from hosts that must not be left half-torn-down by an early throw, so
1088
+ // the report comes last — but it IS a report: a silent partial teardown
1089
+ // is the failure this whole restructure exists to end. The editor's
1090
+ // `exitPlayMode` catches it and prints it to the editor console.
1091
+ throw new AggregateError(
1092
+ failures.map((f) => f.error),
1093
+ `Game session teardown failed in ${failures.length} step(s): ${failures
1094
+ .map((f) => f.step)
1095
+ .join(', ')}. Every other step still ran.`,
1096
+ );
1097
+ }
1098
+ }
1099
+
1100
+ return {
1101
+ stop: fullCleanup,
1102
+ stopComplete,
1103
+ // `Game.play` owns per-world pause, loop-gate, and audio-gate semantics.
1104
+ pause() {
1105
+ game.play.pause();
1106
+ },
1107
+ resume() {
1108
+ game.play.resume();
1109
+ },
1110
+ step() {
1111
+ game.play.step();
1112
+ },
1113
+ resize(rw: number, rh: number, pixelRatio?: number) {
1114
+ const safeWidth = Math.max(1, rw);
1115
+ const safeHeight = Math.max(1, rh);
1116
+ for (const entry of mountedEntries) {
1117
+ // W2c device preview: re-pin DPR before setSize so the drawing
1118
+ // buffer (and the first-party composer, whose `mounted.resize` reads
1119
+ // `getDrawingBufferSize`) picks it up in this same pass. Pixi/react
1120
+ // worlds have no `entry.renderer` and keep their own resolution.
1121
+ if (pixelRatio !== undefined) entry.renderer?.setPixelRatio(pixelRatio);
1122
+ entry.renderer?.setSize(safeWidth, safeHeight, false);
1123
+ entry.mounted.resize?.(safeWidth, safeHeight);
1124
+ }
1125
+ },
1126
+ game,
1127
+ };
1128
+ }