@volter/editor-game 0.5.65

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 (302) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +23 -0
  3. package/contributions/asset-budget-asset.menu.ts +26 -0
  4. package/contributions/asset-budget.action.ts +18 -0
  5. package/contributions/asset-budget.document.tsx +21 -0
  6. package/contributions/asset-budget.menu.ts +22 -0
  7. package/contributions/audio-unlock.service.ts +17 -0
  8. package/contributions/audio.utility.tsx +15 -0
  9. package/contributions/autoplay.service.ts +48 -0
  10. package/contributions/bridge.command.ts +172 -0
  11. package/contributions/build-profiles.document.tsx +21 -0
  12. package/contributions/build-progress.status.tsx +45 -0
  13. package/contributions/build.action.ts +26 -0
  14. package/contributions/build.command.ts +27 -0
  15. package/contributions/build.header.tsx +37 -0
  16. package/contributions/build.menu.ts +31 -0
  17. package/contributions/build.service.ts +31 -0
  18. package/contributions/connection.status.tsx +43 -0
  19. package/contributions/coverage.service.ts +118 -0
  20. package/contributions/edit-mode-audio.service.ts +23 -0
  21. package/contributions/edit-mode-networking.service.ts +30 -0
  22. package/contributions/game-document.service.ts +25 -0
  23. package/contributions/game-eval.command.ts +210 -0
  24. package/contributions/game.layout.ts +19 -0
  25. package/contributions/gameplay.command.ts +255 -0
  26. package/contributions/generation.service.ts +96 -0
  27. package/contributions/generations.status.tsx +53 -0
  28. package/contributions/ingest.service.ts +80 -0
  29. package/contributions/instances.command.ts +75 -0
  30. package/contributions/navmesh.menu.ts +39 -0
  31. package/contributions/navmesh.service.ts +19 -0
  32. package/contributions/network.utility.tsx +17 -0
  33. package/contributions/play.command.ts +355 -0
  34. package/contributions/profiler.action.ts +16 -0
  35. package/contributions/profiler.menu.ts +22 -0
  36. package/contributions/profiler.utility.tsx +17 -0
  37. package/contributions/react/component-board.service.ts +21 -0
  38. package/contributions/react/design-time-mount.service.ts +60 -0
  39. package/contributions/react/pasteboard.action.ts +41 -0
  40. package/contributions/react/react-inspector.service.ts +85 -0
  41. package/contributions/react/story-documents.service.ts +44 -0
  42. package/contributions/scene-document.service.ts +32 -0
  43. package/contributions/state-watch.action.ts +17 -0
  44. package/contributions/state-watch.menu.ts +23 -0
  45. package/contributions/state-watch.utility.tsx +20 -0
  46. package/contributions/team-playtest.service.ts +124 -0
  47. package/contributions/three/camera-runtime.inspector.tsx +34 -0
  48. package/contributions/three/component-board.service.ts +24 -0
  49. package/contributions/three/component-verbs.command.ts +110 -0
  50. package/contributions/three/component-verbs.service.ts +92 -0
  51. package/contributions/three/constraints.inspector.tsx +34 -0
  52. package/contributions/three/model-asset-sections.service.ts +26 -0
  53. package/contributions/three/reflection-probe-capture.inspector.tsx +32 -0
  54. package/contributions/three/story-documents.service.ts +31 -0
  55. package/contributions/three/three-authoring.service.ts +66 -0
  56. package/contributions/transport.header.tsx +19 -0
  57. package/contributions/xstate-behavior.action.ts +42 -0
  58. package/contributions/xstate-behavior.document.tsx +73 -0
  59. package/contributions/xstate-behavior.inspector.tsx +28 -0
  60. package/contributions/xstate-behavior.menu.ts +23 -0
  61. package/package.json +144 -0
  62. package/src/asset-budget/AssetBudgetPanel.tsx +1172 -0
  63. package/src/asset-budget/asset-budget-model.ts +799 -0
  64. package/src/asset-budget/basis-encoder.ts +165 -0
  65. package/src/asset-budget/gltf-io.ts +154 -0
  66. package/src/asset-budget/gltf-optimize.ts +384 -0
  67. package/src/asset-budget/image-dims.ts +78 -0
  68. package/src/asset-budget/optimize-apply.ts +221 -0
  69. package/src/audio/AudioDebuggerPanel.tsx +457 -0
  70. package/src/audio/audio-debugger-model.ts +87 -0
  71. package/src/bridge/call.ts +60 -0
  72. package/src/bridge/dispatch.ts +459 -0
  73. package/src/bridge/live-frames.ts +25 -0
  74. package/src/bridge/screenshot.ts +316 -0
  75. package/src/build/BuildProfilesPanel.tsx +476 -0
  76. package/src/build/build-session.ts +247 -0
  77. package/src/build/format-bytes.ts +14 -0
  78. package/src/command-results.ts +36 -0
  79. package/src/coverage/live-authoring-surface.ts +30 -0
  80. package/src/coverage/live-project-verbs.ts +162 -0
  81. package/src/coverage/native-system-coverage.ts +143 -0
  82. package/src/coverage/root-coverage.ts +79 -0
  83. package/src/coverage/session-coverage.ts +193 -0
  84. package/src/design-system-stories/ApplicationChrome.stories.tsx +100 -0
  85. package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +406 -0
  86. package/src/edit-mode/edit-mode-audio.ts +56 -0
  87. package/src/edit-mode/edit-mode-networking.ts +112 -0
  88. package/src/game-document/DevicePresetPicker.tsx +84 -0
  89. package/src/game-document/GameCaptureFrameButton.tsx +45 -0
  90. package/src/game-document/GameDocument.tsx +215 -0
  91. package/src/game-document/GamePanel.tsx +662 -0
  92. package/src/game-document/InstanceInspectorPicker.tsx +183 -0
  93. package/src/game-document/crowd-debug.ts +183 -0
  94. package/src/game-document/device-preview.ts +336 -0
  95. package/src/game-document/game-view-store.ts +107 -0
  96. package/src/game-document/physics-debug.ts +187 -0
  97. package/src/generation/GenerationActivity.tsx +421 -0
  98. package/src/generation/GenerationGallery.css +231 -0
  99. package/src/generation/generation-documents.tsx +338 -0
  100. package/src/generation/generation-jobs.ts +128 -0
  101. package/src/generation/generation-presentation.ts +257 -0
  102. package/src/host/adapter-reach.ts +445 -0
  103. package/src/host/adapter-runtime-bindings.ts +303 -0
  104. package/src/host/after-paint.ts +112 -0
  105. package/src/host/api/configurations.ts +66 -0
  106. package/src/host/authoring/babylon-authoring-adapter.ts +703 -0
  107. package/src/host/authoring/canvas-runtime-recognition.ts +50 -0
  108. package/src/host/authoring/contract-hierarchy-authoring.ts +144 -0
  109. package/src/host/authoring/contract-scenes-stories.ts +204 -0
  110. package/src/host/authoring/creation-site-related.ts +55 -0
  111. package/src/host/authoring/ephemeral-persistence.ts +29 -0
  112. package/src/host/authoring/gesture-persist.ts +84 -0
  113. package/src/host/authoring/ingest-data-writer.ts +232 -0
  114. package/src/host/authoring/ingest-source-persistence.ts +826 -0
  115. package/src/host/authoring/mount-isolated-pixi-screen.ts +250 -0
  116. package/src/host/authoring/mounted-authoring.ts +41 -0
  117. package/src/host/authoring/owned-pixi-ticker-listeners.ts +96 -0
  118. package/src/host/authoring/phaser-live-authoring-adapter.ts +265 -0
  119. package/src/host/authoring/pixi-authoring-adapter.ts +1554 -0
  120. package/src/host/authoring/pixi-creation-site-write-target.ts +60 -0
  121. package/src/host/authoring/pixi-isolation-assets.ts +25 -0
  122. package/src/host/authoring/pixi-live-write-target.ts +979 -0
  123. package/src/host/authoring/pixi-source-identity.ts +141 -0
  124. package/src/host/authoring/pixi-still-presentation.ts +71 -0
  125. package/src/host/authoring/pixi-structure-history.ts +237 -0
  126. package/src/host/authoring/pixi-transform-channels.ts +205 -0
  127. package/src/host/authoring/selection-remount-handoff.ts +23 -0
  128. package/src/host/authoring/source-persistence-backend.ts +373 -0
  129. package/src/host/authoring/source-refresh-revisions.ts +81 -0
  130. package/src/host/authoring/struct-write-pipe.ts +143 -0
  131. package/src/host/auto-frame-window.ts +89 -0
  132. package/src/host/binding-resolver.ts +393 -0
  133. package/src/host/browser-transpile.ts +631 -0
  134. package/src/host/canvas-entry-runtime.ts +95 -0
  135. package/src/host/components/CameraAuthoringOverlay.tsx +216 -0
  136. package/src/host/components/HeaderTelemetry.tsx +341 -0
  137. package/src/host/components/PixiIsolationSceneContent.tsx +280 -0
  138. package/src/host/components/ResolutionPicker.tsx +89 -0
  139. package/src/host/components/ThreeIsolationSceneContent.tsx +180 -0
  140. package/src/host/components/frame-debugger-model.ts +579 -0
  141. package/src/host/components/header-telemetry-model.ts +74 -0
  142. package/src/host/components/scene-document.tsx +447 -0
  143. package/src/host/components/utility-view-state.ts +87 -0
  144. package/src/host/components/world-root-stage-binding.tsx +133 -0
  145. package/src/host/components/world-root-stage.ts +1046 -0
  146. package/src/host/coverage/authoring-read-probe.ts +583 -0
  147. package/src/host/coverage/capability-coverage.ts +1587 -0
  148. package/src/host/coverage/coverage-accounting.ts +261 -0
  149. package/src/host/coverage/game-contract-seam-evidence.ts +82 -0
  150. package/src/host/coverage/project-verb-coverage.ts +148 -0
  151. package/src/host/coverage/system-adapter-coverage.ts +373 -0
  152. package/src/host/design-system-stories/StoryLayout.tsx +104 -0
  153. package/src/host/design-system-stories/fixtures/authoring.ts +247 -0
  154. package/src/host/design-system-stories/fixtures/editor-runtime.tsx +115 -0
  155. package/src/host/document-preview-three.ts +110 -0
  156. package/src/host/entry-adjudication.ts +89 -0
  157. package/src/host/game-location-guard.ts +150 -0
  158. package/src/host/game-module-access.ts +196 -0
  159. package/src/host/game-realm-page.ts +358 -0
  160. package/src/host/game-realm-reclaim.ts +55 -0
  161. package/src/host/game-realm-storage.ts +104 -0
  162. package/src/host/gameplay-export.ts +288 -0
  163. package/src/host/gameplay-recording.ts +717 -0
  164. package/src/host/gated-globals.ts +1511 -0
  165. package/src/host/history/json-history-resource.ts +254 -0
  166. package/src/host/ingest/registry.ts +261 -0
  167. package/src/host/instance-extract-actions.ts +120 -0
  168. package/src/host/instance-fork-actions.ts +120 -0
  169. package/src/host/play-control-hook.ts +26 -0
  170. package/src/host/playwright-shim.ts +479 -0
  171. package/src/host/projection/dom.ts +295 -0
  172. package/src/host/projection/pixi.ts +288 -0
  173. package/src/host/r3f-entry-runtime.ts +77 -0
  174. package/src/host/react-mount-runtime.ts +162 -0
  175. package/src/host/realm-services.ts +148 -0
  176. package/src/host/recording-preview.ts +106 -0
  177. package/src/host/roots/module-root.ts +203 -0
  178. package/src/host/roots/react-root.ts +181 -0
  179. package/src/host/same-realm-loop-gate.ts +544 -0
  180. package/src/host/scene-view-drawability.ts +69 -0
  181. package/src/host/sdk/tools.ts +31 -0
  182. package/src/host/served-bundle-runtime-modules.ts +331 -0
  183. package/src/host/server-log-bridge.ts +60 -0
  184. package/src/host/staged-projects.ts +24 -0
  185. package/src/host/stories/mounted-story-viewport-source.ts +64 -0
  186. package/src/host/stories/story-arg-descriptors.ts +50 -0
  187. package/src/host/stories/story-media-presence.ts +152 -0
  188. package/src/host/surface-content.ts +87 -0
  189. package/src/host/take-named-export.ts +23 -0
  190. package/src/host/three-ingest-runtime.ts +76 -0
  191. package/src/host/types-fastnoise-lite.d.ts +7 -0
  192. package/src/host/types-mikktspace.d.ts +20 -0
  193. package/src/host/types-troika-three-text.d.ts +7 -0
  194. package/src/host/use-active-performance-source.ts +53 -0
  195. package/src/host/viewport-pose-memory.ts +48 -0
  196. package/src/host/viewport-root-presentation.ts +40 -0
  197. package/src/ingest/active-ingest.ts +251 -0
  198. package/src/ingest/active-scene-navigation.ts +43 -0
  199. package/src/ingest/authoring/ingest-dom-surface-authoring.ts +191 -0
  200. package/src/ingest/authoring/ingest-root-adapter.ts +897 -0
  201. package/src/ingest/capture-wait-report.ts +122 -0
  202. package/src/ingest/deferred-ingest-play.ts +216 -0
  203. package/src/ingest/deferred-ingest-session.ts +23 -0
  204. package/src/ingest/discovery-public-ingest.ts +242 -0
  205. package/src/ingest/dom-stub-mark.ts +7 -0
  206. package/src/ingest/entry-load.ts +56 -0
  207. package/src/ingest/game-contract-realm.ts +34 -0
  208. package/src/ingest/game-pointer-lock.ts +89 -0
  209. package/src/ingest/held-scene-repaint.ts +73 -0
  210. package/src/ingest/host-surface-box.ts +174 -0
  211. package/src/ingest/ingest-boot-viewport.ts +54 -0
  212. package/src/ingest/ingest-canvas-scene-document.tsx +177 -0
  213. package/src/ingest/ingest-canvas-scene.ts +50 -0
  214. package/src/ingest/ingest-evidence-hook.ts +131 -0
  215. package/src/ingest/ingest-frame-snapshot.ts +183 -0
  216. package/src/ingest/ingest-play-commands.ts +134 -0
  217. package/src/ingest/ingest-play-control.ts +299 -0
  218. package/src/ingest/ingest-render-debug.ts +320 -0
  219. package/src/ingest/ingest-siblings.ts +487 -0
  220. package/src/ingest/ingest-status.ts +62 -0
  221. package/src/ingest/live-ingest-facet.ts +46 -0
  222. package/src/ingest/module-mode.ts +229 -0
  223. package/src/ingest/mount-canvas-ingest-root.ts +899 -0
  224. package/src/ingest/mount-coverage.ts +296 -0
  225. package/src/ingest/mount-dom-ingest-root.ts +282 -0
  226. package/src/ingest/mount-ingest-root.ts +744 -0
  227. package/src/ingest/mount-three-ingest-root.ts +366 -0
  228. package/src/ingest/resolve-canvas.ts +47 -0
  229. package/src/ingest/resolve-three.ts +123 -0
  230. package/src/ingest/served-bundle.ts +109 -0
  231. package/src/ingest/served-html-boot.ts +292 -0
  232. package/src/ingest/surface-canvas.ts +84 -0
  233. package/src/ingest/surface-dom.ts +84 -0
  234. package/src/ingest/surface-three.ts +128 -0
  235. package/src/ingest/types.ts +76 -0
  236. package/src/ingest/unmount-ingest-root.ts +224 -0
  237. package/src/navmesh/navmesh-actions.ts +27 -0
  238. package/src/navmesh/navmesh-handler.ts +237 -0
  239. package/src/navmesh/navmesh-workflow-store.ts +78 -0
  240. package/src/network/NetworkInspectorPanel.tsx +644 -0
  241. package/src/network/network-inspector-model.ts +225 -0
  242. package/src/play/play-boot-stall.ts +118 -0
  243. package/src/play/play-log-events.ts +26 -0
  244. package/src/play/play-mode.ts +2553 -0
  245. package/src/play/play-recording.ts +335 -0
  246. package/src/play/react-play-live-authoring.ts +165 -0
  247. package/src/play-bar/PlayBar.tsx +488 -0
  248. package/src/play-bar/PlayerCountPicker.tsx +100 -0
  249. package/src/profiler/FrameDebuggerPanel.tsx +458 -0
  250. package/src/profiler/PerformancePanel.tsx +1068 -0
  251. package/src/profiler/ProfilerPanel.tsx +53 -0
  252. package/src/profiler/frame-debugger-store.ts +97 -0
  253. package/src/profiler/main-thread-busy.ts +90 -0
  254. package/src/react/design-time-react-mount.ts +782 -0
  255. package/src/react/dom-authoring-adapter.ts +764 -0
  256. package/src/react/pasteboard-materialize.ts +154 -0
  257. package/src/react/react-inspector-section.tsx +2496 -0
  258. package/src/react/react-world-authoring-adapter.ts +3795 -0
  259. package/src/react/story-documents/story-args-section.ts +19 -0
  260. package/src/react/story-documents/story-documents.tsx +1380 -0
  261. package/src/react/story-paint-bounds.ts +94 -0
  262. package/src/react/ui-board-document.tsx +101 -0
  263. package/src/react/ui-board-title.ts +9 -0
  264. package/src/react/ui-component-board.ts +72 -0
  265. package/src/services/audio-pose-guard.ts +81 -0
  266. package/src/services/game-audio-unlock.ts +48 -0
  267. package/src/state-watch/StateWatchPanel.tsx +535 -0
  268. package/src/three/authoring/camera-runtime-inspector-section.tsx +135 -0
  269. package/src/three/authoring/constraint-inspector-section.tsx +189 -0
  270. package/src/three/authoring/design-time-renderer.ts +189 -0
  271. package/src/three/authoring/model-asset-inspector-section.css +41 -0
  272. package/src/three/authoring/model-asset-inspector-section.tsx +869 -0
  273. package/src/three/authoring/oid-source-persistence.ts +557 -0
  274. package/src/three/authoring/r3f-design-session.ts +1174 -0
  275. package/src/three/authoring/r3f-source-authoring-adapter.ts +5949 -0
  276. package/src/three/authoring/reflection-probe-inspector-section.tsx +76 -0
  277. package/src/three/authoring/spatial-audio-handles.ts +215 -0
  278. package/src/three/authoring/spatial-collider-handles.ts +229 -0
  279. package/src/three/authoring/spatial-joint-handles.ts +114 -0
  280. package/src/three/authoring/spatial-light-handles.ts +178 -0
  281. package/src/three/authoring/spatial-lod-handles.ts +93 -0
  282. package/src/three/authoring/spatial-particle-handles.ts +368 -0
  283. package/src/three/authoring/three-authoring-adapter.ts +1953 -0
  284. package/src/three/authoring/three-scene-identity.ts +19 -0
  285. package/src/three/authoring/three-spatial-handles.ts +161 -0
  286. package/src/three/authoring/typed-three-inspector.ts +528 -0
  287. package/src/three/component-verbs/extract-menu.ts +71 -0
  288. package/src/three/component-verbs/fork-menu.ts +78 -0
  289. package/src/three/component-verbs/internals-menu.ts +91 -0
  290. package/src/three/story-documents/three-story-documents.tsx +607 -0
  291. package/src/three/three-board/ThreeBoardDocument.tsx +888 -0
  292. package/src/three/three-board/board-framing.ts +412 -0
  293. package/src/three/three-board/board-layout.ts +401 -0
  294. package/src/three/three-board/board-scene.ts +901 -0
  295. package/src/three/three-board/three-component-board.ts +62 -0
  296. package/src/xstate/XStateBehaviorSection.tsx +130 -0
  297. package/src/xstate/XStateMachineInspector.tsx +566 -0
  298. package/src/xstate/character-animation-machine.fixture.ts +74 -0
  299. package/src/xstate/live-behaviors.ts +106 -0
  300. package/src/xstate/use-live-actor-state.ts +44 -0
  301. package/src/xstate/xstate-graph.ts +235 -0
  302. package/src/xstate/xstate-layout.ts +76 -0
@@ -0,0 +1,2553 @@
1
+ import { onAssetReload } from '@volter/editor-core/project-asset-refresh';
2
+ import { editorHost } from '@volter/editor-sdk/host';
3
+
4
+ /**
5
+ * Play-mode orchestrator.
6
+ *
7
+ * Editor and game are fully isolated — separate canvas, renderer, and scene.
8
+ * Play mode creates a new game canvas, launches a standalone game session,
9
+ * and tears it all down on stop. The editor scene is never touched.
10
+ *
11
+ * Flow:
12
+ * enterPlayMode → create canvas → createGameRuntime() → game loop
13
+ * exitPlayMode → game stop → remove canvas → re-enable editor
14
+ */
15
+
16
+ import { installAdapterRuntimeBindings } from '../host/adapter-runtime-bindings';
17
+ import { getAuthoringOverride, setActiveAuthoring } from '@volter/editor-core/authoring/active-adapter';
18
+ import {
19
+ getActiveNetworking,
20
+ inspectedInstanceId,
21
+ setActiveSystems,
22
+ setInspectedInstance,
23
+ updateInstanceSystems,
24
+ } from '@volter/editor-core/authoring/active-systems';
25
+ import { BoundaryAuthoringAdapter } from '@volter/editor-core/authoring/boundary-authoring-adapter';
26
+ import {
27
+ CompositeAuthoringAdapter,
28
+ type CompositeChild,
29
+ } from '@volter/editor-core/authoring/composite-authoring-adapter';
30
+ import { createEphemeralPersistence } from '../host/authoring/ephemeral-persistence';
31
+ import {
32
+ EPHEMERAL_DESTINATION,
33
+ resolvesLiveOnly,
34
+ runWritePipe,
35
+ type WriteAck,
36
+ } from '@volter/editor-core/authoring/write-pipe';
37
+ import { resolveAllRootEntries } from '../host/binding-resolver';
38
+ import type { LogEntry } from '@volter/editor-core/editor-api';
39
+ import { endLogSession, flushLogEntries, startLogSession } from '@volter/editor-core/editor-api';
40
+ import type { ConsoleEntry } from '@volter/editor-core/editor-console';
41
+ import {
42
+ editorConsole,
43
+ formatConsoleArgs,
44
+ resumeEditorConsoleCapture,
45
+ suspendEditorConsoleCapture,
46
+ } from '@volter/editor-core/editor-console';
47
+ import { EDITOR_PARTICIPANT_ID, sendControl } from '@volter/editor-core/editor-presence';
48
+ import {
49
+ isEditorPresentationActive,
50
+ subscribeEditorPresentationActivity,
51
+ } from '@volter/editor-core/editor-presentation-activity';
52
+ import type { EditorShellStore } from '@volter/editor-core/editor-shell-store';
53
+ import { GAME_SURFACE_CONTAINMENT_CSS } from '../host/game-realm-page';
54
+ import { reclaimGameRealm } from '../host/game-realm-reclaim';
55
+ import { toolContributionRecording } from '@volter/editor-core/gameplay-sessions';
56
+ import {
57
+ clearGameSurface,
58
+ currentGameRealmMountId,
59
+ installGatedGameGlobals,
60
+ setGameInputGate,
61
+ setGameSurface,
62
+ } from '../host/gated-globals';
63
+ import { hierarchyProjectionFromProjectConfig } from '@volter/editor-core/hierarchy-projection';
64
+ import { type JournalSubject, playJournal } from '../host/history/json-history-resource';
65
+ import { isEditableTarget, setActiveScope } from '@volter/editor-core/hotkeys';
66
+ import { projectBootstrapSettled } from '@volter/editor-core/initial-project';
67
+ import { registerGameNullSubject } from '@volter/editor-core/inspection/game-subject';
68
+ import { fetchGameManifest } from '@volter/editor-core/manifest-project';
69
+ import { registerPerformanceSource } from '@volter/editor-core/performance-sources';
70
+ import {
71
+ beginPlayBoot,
72
+ endPlayBoot,
73
+ markPlayBootPhase,
74
+ type PlayBootPhase,
75
+ } from '@volter/editor-core/play-boot-phase';
76
+ import { presentationSurface } from '@volter/editor-core/presentation-surface';
77
+ import { getCurrentProject } from '@volter/editor-core/project-manager';
78
+ import {
79
+ beginProjectModuleSplitWatch,
80
+ clearProjectModuleSplitReports,
81
+ endProjectModuleSplitWatch,
82
+ formatProjectModuleSplitMessage,
83
+ } from '@volter/editor-core/project-module-split';
84
+ import { clearRootReadiness, recordRootReadiness } from '@volter/editor-core/readiness';
85
+ import { onShellStore } from '@volter/editor-core/shell-store-door';
86
+ import { mountedStoryHasPixiContent } from '@volter/editor-core/stories/pixi-story-model';
87
+ import { domHasRenderableContent, threeSceneHasRenderableContent } from '../host/surface-content';
88
+ import { subscribeSurfaceKeyboard, surfaceHoldsKeyboard } from '@volter/editor-core/surface-keyboard';
89
+ import { publishToolContributionPlay } from '@volter/editor-core/tool-contribution-play';
90
+ import { liveWorldId } from '../host/viewport-root-presentation';
91
+ import {
92
+ cancelPendingWorkspacePlayUtilities,
93
+ revealWorkspacePlayUtilities,
94
+ } from '@volter/editor-core/workspace-play-utilities';
95
+ import { markGameCssScope } from '@volter/editor-sdk/session/game-css-scope';
96
+ import type { EntrypointSelectionOverride } from '@volter/editor-sdk/session/project-module-url';
97
+ import { isEditorLanePath } from '@volter/editor-sdk/session/tool-contribution-convention';
98
+ import { getSeededRandom, type SeededRandom } from '@volter/game-runtime/core/seeded-random';
99
+ import { _engineLogActive } from '@volter/game-runtime/dev/logger';
100
+ import type { PerformanceProfiler } from '@volter/game-runtime/dev/performance-profiler';
101
+ import type { GameSession } from '@volter/game-runtime/runtime/create-runtime';
102
+ import {
103
+ type DebugVirtualInputTarget,
104
+ getDebugRegistry,
105
+ type RunTicksOptions,
106
+ } from '@volter/game-runtime/runtime/debug-registry';
107
+ import type { GameLoop, RootInstance } from '@volter/game-runtime/runtime/game';
108
+ import type { PlaytestContext } from '@volter/game-runtime/runtime/playtest';
109
+ import { runTicksWhenSettled } from '@volter/game-runtime/runtime/run-ticks-settled';
110
+ import {
111
+ type AuthoringAdapter,
112
+ type InspectorProvider,
113
+ nodeKeyedPhysics,
114
+ type TransformProvider,
115
+ } from '@volter/editor-project/adapter';
116
+ import { assertNever } from '@volter/editor-project/adapter/adapter-surface';
117
+ import { declaredRoots, rootById } from '@volter/editor-project/adapter/manifest-interpreter';
118
+ import { readOidSourceAnchors } from '../three/authoring/oid-source-persistence';
119
+ import { oidThree, structuralThree } from '../three/authoring/three-authoring-adapter';
120
+ import type * as THREE from 'three';
121
+ import { deviceEmulatedPixelRatio } from '../game-document/device-preview';
122
+ import { exitDeferredIngestPlay, mountDeferredIngestForPlay } from '../ingest/deferred-ingest-play';
123
+ import { getIngestPlayControl } from '../ingest/ingest-play-control';
124
+ import { withPlayBootStallGuard } from './play-boot-stall';
125
+ import { debugEventsToLogEntries } from './play-log-events';
126
+ import { bindPlayRecordingStop, endPlayRecording } from './play-recording';
127
+ import { createReactPlayAuthoringAdapter } from './react-play-live-authoring';
128
+
129
+ /** Context needed by the orchestrator (passed from the world root's stage). */
130
+ export interface PlayModeContext {
131
+ store: EditorShellStore;
132
+ /** Container for the game canvas (the Game tab panel). */
133
+ gameContainer: HTMLElement;
134
+ }
135
+
136
+ /**
137
+ * THE MOUNTED INSTANCE of this project — the thing that used to be a scatter
138
+ * of module-level `let`s all silently meaning "the one game".
139
+ *
140
+ * This layer knows about mounts, and about nothing a game means by them. An
141
+ * instance is one mount: its own module graph, realm, renderer and session.
142
+ * Two of them is how a multiplayer game gets verified, but equally how you A/B
143
+ * two seeds or watch one scene from two camera rigs — so the vocabulary is the
144
+ * one the rest of the codebase already uses (`active-systems.ts`'s
145
+ * `_byInstance`/`systemsForInstance`), and naming this after any single
146
+ * application of it would hardcode that application into a layer that has no
147
+ * such concept in it.
148
+ *
149
+ * ITS IDENTITY IS THE MOUNT ID, and there is only ever one id for it.
150
+ * `resolveAllRootEntries` opens a mount epoch; every project module of this
151
+ * instance is served under it as `?vgai-mount=<id>`; browser module identity
152
+ * is per-url, so that id IS the module-graph boundary; and `gated-globals.ts`
153
+ * resolves this instance's realm and input gate by reading the same id back
154
+ * off the url. Registering it under any second name would be two identities
155
+ * for one thing, and they would drift.
156
+ *
157
+ * WHAT IS NOT HERE IS THE POINT. Console patching, the log session and its
158
+ * flush chain, the Escape listener, the enter queue, `playState` and the play
159
+ * epoch stay module-scope, because they belong to the play SESSION and not to
160
+ * an instance in it. Moving the log machinery in here would give N instances N
161
+ * interleaved log streams and read, later, as a game bug.
162
+ */
163
+ interface PlayInstance {
164
+ /** The mount id — see above. What `?vgai-mount=` carries, what the realm and
165
+ * input gate are keyed by, what `setActiveSystems` registers under and what
166
+ * the session wire addresses. Empty until a composition has resolved. */
167
+ /** The element this instance mounted into (the primary's is the live
168
+ * document's — `live-document.ts` owns it; read it there). */
169
+ container: HTMLElement | null;
170
+ id: string;
171
+ /** A human-readable label for this instance — a HINT, never the mechanism.
172
+ * Defaults to "Instance 1"/"Instance 2"/… so a split view and its drivers read
173
+ * legibly; the ADDRESS is still the opaque mount id. A multiplayer game may
174
+ * choose to read it (e.g. as its own display name when it joins a room), but
175
+ * nothing here couples the instance to any player/network concept. */
176
+ name: string;
177
+ /** Container for this instance's root surfaces (the Game tab panel today). */
178
+ session: GameSession | null;
179
+ /** Native authored-subject presentation owned by the viewport host. */
180
+ presentation: { readonly worldId: string; dispose(): void } | null;
181
+ unregisterPerformanceSource: (() => void) | null;
182
+ unsubscribeSystemAdapters: (() => void) | null;
183
+ /** D15/T-D15.6 — whether this instance's manifest declares
184
+ * `determinism.seededRandom`. */
185
+ determinismDeclared: boolean;
186
+ resizeObserver: ResizeObserver | null;
187
+ /** Per-world adapters built for this instance, retained so teardown can
188
+ * detach their history resources. */
189
+ rootResources: AuthoringAdapter[];
190
+ /**
191
+ * THIS RUN's journal session — the id every adapter below journals into, and
192
+ * the one thing `exitPlayRootAuthoring` is allowed to expire.
193
+ *
194
+ * Play OWNS this session (`history/json-history-resource.ts`'s ownership
195
+ * block): a play-time edit is session-local by architecture, so pressing ■
196
+ * must leave nothing undoable in the edit-mode stack. Edit-mode's held
197
+ * surfaces own a DIFFERENT session (`AUTHORING_SESSION`) that no mount ends,
198
+ * which is what lets their undo survive a remount. Empty while not playing.
199
+ */
200
+ journalSession: string;
201
+ }
202
+
203
+ function createPlayInstance(): PlayInstance {
204
+ return {
205
+ id: '',
206
+ journalSession: '',
207
+ name: '',
208
+ container: null,
209
+ session: null,
210
+ presentation: null,
211
+ unregisterPerformanceSource: null,
212
+ unsubscribeSystemAdapters: null,
213
+ determinismDeclared: false,
214
+ resizeObserver: null,
215
+ rootResources: [],
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Editor play mounts one instance. Everything this file exports means THIS
221
+ * one, which is why nothing above it has to know an instance has a name at
222
+ * all — editor focus and addressed-instance stay different questions
223
+ * (`active-systems.ts`).
224
+ */
225
+ const _instance: PlayInstance = createPlayInstance();
226
+
227
+ /**
228
+ * ADDITIONAL instances mounted beside the primary one.
229
+ *
230
+ * The primary (`_instance`) owns everything singular about a play session —
231
+ * the store scene it adopts, the authoring composite, the console patch, the
232
+ * camera transition, editor focus. An additional instance owns none of that:
233
+ * it is a second full mount of the SAME project (its own mount id, module
234
+ * graph, renderer and session) rendering into its own container, registered
235
+ * under its id so the session wire can address it (`systemsForInstance(id)`),
236
+ * and touching no singular focus state. That is what makes N instances a
237
+ * property of the MOUNT and not of the game — two seats of a multiplayer
238
+ * match, or one scene A/B'd under two seeds, are the same mechanism. Torn
239
+ * down with the session by `exitPlayMode`.
240
+ */
241
+ const _additional: PlayInstance[] = [];
242
+
243
+ /** Root ids THIS play run recorded host-mount readiness for (`readiness.ts`),
244
+ * so its teardown drops exactly those and never a sibling's. Empty while
245
+ * stopped. */
246
+ let _hostMountedReadyRootIds: readonly string[] = [];
247
+
248
+ // KEYBOARD FOCUS across split-screen instances. With one editor keyboard only
249
+ // ONE instance can be driven at a time; this is which. `null` (and any stale
250
+ // id) resolves to the primary, so the default — and the single-instance case —
251
+ // is "the primary has the keyboard", exactly as before split screen existed.
252
+ // A click on an instance's viewport routes the keyboard to it
253
+ // (`setFocusedInstance`); the input gate + InputManager for every instance read
254
+ // this, so exactly the focused one is live and the rest are inert.
255
+ let _focusedInstanceId: string | null = null;
256
+ const focusListeners = new Set<() => void>();
257
+
258
+ /** The instance the shared keyboard currently drives — the focused id when it
259
+ * names a LIVE instance, else the primary (an unset focus, or one whose
260
+ * instance was torn down, falls back so the keyboard is never orphaned). */
261
+ export function focusedInstanceId(): string {
262
+ if (
263
+ _focusedInstanceId &&
264
+ (_focusedInstanceId === _instance.id ||
265
+ _additional.some((inst) => inst.id === _focusedInstanceId))
266
+ ) {
267
+ return _focusedInstanceId;
268
+ }
269
+ return _instance.id;
270
+ }
271
+
272
+ /** The PRIMARY instance's mount id — the click target for focusing the primary
273
+ * viewport (`''` before a composition has resolved). */
274
+ export function primaryInstanceId(): string {
275
+ return _instance.id;
276
+ }
277
+
278
+ export function subscribeFocusedInstance(listener: () => void): () => void {
279
+ focusListeners.add(listener);
280
+ return () => focusListeners.delete(listener);
281
+ }
282
+
283
+ function notifyFocusedInstance(): void {
284
+ for (const listener of focusListeners) listener();
285
+ }
286
+
287
+ /** Route the shared editor keyboard to instance `id` (the primary or any
288
+ * additional), and follow the ordinary engine convention that clicking a
289
+ * viewport also makes its runtime the one shown by diagnostic instruments.
290
+ * Re-gates every instance so only the focused one takes input. */
291
+ export function setFocusedInstance(id: string): void {
292
+ setInspectedInstance(id);
293
+ if (focusedInstanceId() === id) return;
294
+ _focusedInstanceId = id;
295
+ resyncInstanceInputs();
296
+ notifyFocusedInstance();
297
+ }
298
+
299
+ /** Whether instance `id` should receive input right now: play is running, the
300
+ * Game tab is active, this instance holds keyboard focus, AND our surface
301
+ * holds the keyboard.
302
+ *
303
+ * The fourth term is U2's, and it exists for the engine's `InputManager`
304
+ * specifically. `gated-globals.ts` already ANDs the same predicate into every
305
+ * raw `window`/`document` listener a PROJECT module registers, but
306
+ * `InputManager` is `@volter/game-runtime`'s — a dependency, not a project module, so
307
+ * the dev server's lexical shadow never covers it and it attaches to the real
308
+ * `window`. Under the Code-OSS frame that window also carries Monaco, so
309
+ * without this a keystroke meant for the source file beside the running game
310
+ * moves the game too. Standalone it is a constant true and nothing changes.
311
+ * See `@editor/surface-keyboard`. */
312
+ function instanceInputActive(id: string): boolean {
313
+ if (!_ctx) return false;
314
+ const { store } = _ctx;
315
+ return (
316
+ store.playState === 'playing' &&
317
+ store.activeViewportTab === 'play' &&
318
+ focusedInstanceId() === id &&
319
+ surfaceHoldsKeyboard()
320
+ );
321
+ }
322
+
323
+ /** The first-party `InputManager` for an instance, or `undefined` — a session's
324
+ * game handle may lack one (an ingest mount, a partial double), so resolve it
325
+ * defensively. */
326
+ function instanceInput(inst: PlayInstance): { setEnabled(on: boolean): void } | undefined {
327
+ try {
328
+ return inst.session?.game?.input;
329
+ } catch {
330
+ return undefined;
331
+ }
332
+ }
333
+
334
+ /** Re-apply the enabled/disabled state of every live instance's InputManager
335
+ * from the current play/tab/focus predicate. Called whenever any of those
336
+ * change (store subscription, focus switch, an instance mounting). The raw
337
+ * window/document gates are closures over `instanceInputActive`, so they need
338
+ * no re-registration — they re-read focus on every event. */
339
+ function resyncInstanceInputs(): void {
340
+ if (_instance.id) instanceInput(_instance)?.setEnabled(instanceInputActive(_instance.id));
341
+ for (const inst of _additional) instanceInput(inst)?.setEnabled(instanceInputActive(inst.id));
342
+ }
343
+
344
+ /**
345
+ * THE SURFACE TERM'S OWN EDGE. The store subscription re-gates on play/tab
346
+ * changes and `setFocusedInstance` on focus changes, but the fourth term above
347
+ * moves on neither: a person clicks into Monaco and nothing in the editor's own
348
+ * state has changed. `InputManager` is a LATCHED `setEnabled`, so unlike the
349
+ * raw gates (closures re-read per event) it has to be told. Module scope and
350
+ * never unsubscribed on purpose — the notification is a no-op with no instances
351
+ * mounted, and a lane-scoped subscription would have to be rebuilt on every
352
+ * mount for a predicate that is process-wide.
353
+ */
354
+ subscribeSurfaceKeyboard(resyncInstanceInputs);
355
+
356
+ let _ctx: PlayModeContext | null = null;
357
+ const playModeBindingWaiters = new Set<() => void>();
358
+
359
+ /** The command listener can attach before the layout binds Play. */
360
+ function waitForPlayModeBinding(): Promise<void> {
361
+ if (_ctx) return Promise.resolve();
362
+ return new Promise<void>((resolve, reject) => {
363
+ const bound = () => {
364
+ clearTimeout(timer);
365
+ playModeBindingWaiters.delete(bound);
366
+ resolve();
367
+ };
368
+ const timer = setTimeout(() => {
369
+ playModeBindingWaiters.delete(bound);
370
+ reject(
371
+ new Error('Play mode failed to start: the editor shell did not bind within 15 seconds.'),
372
+ );
373
+ }, 15_000);
374
+ playModeBindingWaiters.add(bound);
375
+ });
376
+ }
377
+ const sessionListeners = new Set<() => void>();
378
+ const restartRequiredListeners = new Set<() => void>();
379
+ let restartRequiredReason: string | null = null;
380
+
381
+ export function getRestartRequiredReason(): string | null {
382
+ return restartRequiredReason;
383
+ }
384
+
385
+ export function subscribeRestartRequired(listener: () => void): () => void {
386
+ restartRequiredListeners.add(listener);
387
+ return () => restartRequiredListeners.delete(listener);
388
+ }
389
+
390
+ export function markRestartRequired(reason: string): void {
391
+ restartRequiredReason = reason;
392
+ for (const listener of restartRequiredListeners) listener();
393
+ editorHost().live.notifyChanged();
394
+ }
395
+
396
+ /**
397
+ * The exact `[play-mode] Restart required: …` warnings this session raised.
398
+ *
399
+ * They are kept because the unresolved-console ledger names conditions BY
400
+ * THEIR TEXT, and a remount is the event that resolves them: measured
401
+ * 2026-08-29, `vgai restart` reported "↻ Restarted — session ready" while its
402
+ * own named warning stayed in `vgai console` forever, because the ledger's
403
+ * automatic clearing rule is a PAGE LOAD and a remount is not one — only
404
+ * `game.reloadPage()` could silence a warning the named verb had already
405
+ * fixed. `clearRestartRequired` now reports them resolved (ledger clearing
406
+ * rule (c), `server/console-ledger.ts`), so the verb clears its own condition.
407
+ */
408
+ const restartRequiredWarnings = new Set<string>();
409
+
410
+ /** Warn that a restart is required AND remember the sentence, so the restart
411
+ * that resolves it can retire exactly this condition. */
412
+ function warnRestartRequired(message: string): void {
413
+ restartRequiredWarnings.add(message);
414
+ editorConsole.warn(message, 'play-mode');
415
+ }
416
+
417
+ function clearRestartRequired(): void {
418
+ if (restartRequiredWarnings.size > 0) {
419
+ // The bare sentence, exactly as `editorConsole.warn` reported it — the
420
+ // `[play-mode]` the CLI prints is rendered from `source`, not stored text.
421
+ const conditions = [...restartRequiredWarnings].map((message) => ({
422
+ severity: 'warn' as const,
423
+ message,
424
+ }));
425
+ restartRequiredWarnings.clear();
426
+ void sendControl('console-resolved', { conditions, by: 'play-mode' });
427
+ }
428
+ if (restartRequiredReason === null) return;
429
+ restartRequiredReason = null;
430
+ for (const listener of restartRequiredListeners) listener();
431
+ editorHost().live.notifyChanged();
432
+ }
433
+
434
+ function notifySessionListeners(): void {
435
+ syncPlayPresentationActivity();
436
+ for (const listener of sessionListeners) listener();
437
+ }
438
+
439
+ export function subscribeGameSession(listener: () => void): () => void {
440
+ sessionListeners.add(listener);
441
+ return () => sessionListeners.delete(listener);
442
+ }
443
+
444
+ export function getGameProfiler(): PerformanceProfiler | null {
445
+ return _instance.session?.game.profiler ?? null;
446
+ }
447
+
448
+ /**
449
+ * Play-mode generation counter. Bumped on every enterPlayMode and every
450
+ * exitPlayMode. enterPlayMode captures the value before its async boot and
451
+ * re-checks it around `createGameRuntime` — if exitPlayMode (Stop/Escape) ran
452
+ * while the runtime was still booting, the freshly-created session belongs to
453
+ * an already-exited play and must be stopped and abandoned, NOT adopted.
454
+ * Without this, a stop-during-boot left the store swapped onto the orphaned
455
+ * live game scene (empty hierarchy in edit mode) with the session leaked
456
+ * (RAF loop, Rapier world, WebGL context never released).
457
+ */
458
+ let _playEpoch = 0;
459
+ // #146 — when the MOST RECENT play run began (ms epoch), or null before any
460
+ // run. The relay snapshot's `pageErrors` uses this as its freshness fence.
461
+ //
462
+ // PD-1: this used to be nulled by `exitPlayMode()`, which meant the errors of
463
+ // a run that FAILED became invisible the instant the failed run rolled back —
464
+ // `vgai status` reported `pageErrors: []` for a play that had just thrown, the
465
+ // exact "every diagnostic says healthy" symptom. The fence's job is to exclude
466
+ // a PREVIOUS run's noise, and the next `enterPlayMode` re-stamping it does
467
+ // that; dropping it on exit only ever hid the evidence of the last run.
468
+ let _playStartedAtMs: number | null = null;
469
+ // The CLOSING half of the same fence: when the most recent run's teardown
470
+ // finished, or null while a run is live (and before the first run).
471
+ //
472
+ // Why an end and not just a start: with an open-ended window every editor error
473
+ // logged AFTER a run stopped still counted as "during the play run", so it fell
474
+ // into the play-fenced `consoleErrors` facet — which `vgai status` renders only
475
+ // while play is live. One play run, and every later editor-frame error went
476
+ // invisible again, which is the exact defect the session-lifetime facets exist
477
+ // to close. Stamped at the END of `exitPlayMode`, so a FAILED run's errors (all
478
+ // logged before its rollback completes) stay inside the window and PD-1 above
479
+ // still holds.
480
+ let _playEndedAtMs: number | null = null;
481
+
482
+ // T6.3: install the gated window/document proxies once so the dev server's/
483
+ // browser-transpile's GAME_GLOBALS_PRELUDE (prepended to project modules) has
484
+ // something to resolve to. No-op outside a browser (headless unit tests).
485
+ installGatedGameGlobals();
486
+
487
+ /**
488
+ * The warm-restart HMR bracket's pause/resume wrapper (§7.1 item 1 /
489
+ * probe3-warm-restart-unpauses: the bracket used to call
490
+ * `_instance.session.pause(); await hotReload(...); _instance.session.resume();`
491
+ * unconditionally, so it silently un-paused a user-paused game — the UI kept
492
+ * saying "paused" while the simulation resumed running). Fix: capture
493
+ * whether the game was ALREADY paused from the SAME source of truth
494
+ * `pause()`/`resume()` drive (`session.game.play.paused`, `game.ts:713`)
495
+ * BEFORE unconditionally pausing for the reload, and only resume afterward
496
+ * if it was not already paused — `pause()`/`resume()` are idempotent, so an
497
+ * unconditional `pause()` up front is always safe. try/finally so a throw
498
+ * mid-`reload()` still restores the correct pre-bracket state (a throw must
499
+ * never leave a previously-RUNNING game stuck paused). Exported for direct
500
+ * unit testing without any `import.meta.hot`/Vite HMR event machinery — see
501
+ * `packages/editor/test/play-mode-warm-restart-pause.test.ts`.
502
+ */
503
+ export async function runWarmRestartPauseBracket(
504
+ session: GameSession,
505
+ reload: () => Promise<void>,
506
+ ): Promise<void> {
507
+ const wasPaused = session.game.play.paused;
508
+ session.pause();
509
+ try {
510
+ await reload();
511
+ } finally {
512
+ if (!wasPaused) session.resume();
513
+ }
514
+ }
515
+ let _unsubStore: (() => void) | null = null;
516
+ /** Browser-mode component-source watch (Phase A2); null in server mode / stopped. */
517
+ /**
518
+ * The authoring override that was active before THIS play session installed its
519
+ * own (normally `null` — nothing else authors while playing today, but this
520
+ * restores whatever was there rather than assuming null. `undefined` ⇒ this play
521
+ * session never installed one (e.g. it bailed before adopting the scene) — exit
522
+ * must then leave the authoring override untouched.
523
+ */
524
+ let _priorAuthoringOverride: AuthoringAdapter | null | undefined;
525
+
526
+ /**
527
+ * Replace an adapter's persistence surface without changing its live authoring
528
+ * providers. Play changes are session-local for every world count (D19).
529
+ *
530
+ * EPHEMERAL IS A PIPE DESTINATION, not a parallel stack: the wrapped providers
531
+ * still perform their live writes, and each one then runs the SAME
532
+ * `resolve → write → record` pipe every other lane runs — resolving to the
533
+ * ephemeral destination, which has no writer arm. So "discarded on stop" is an
534
+ * ack of exactly the shape "written to src/world.tsx" is, produced the same
535
+ * way. Letting the underlying adapter's own ack through would name a file this
536
+ * session's edits are structurally barred from reaching.
537
+ */
538
+ function withEphemeralPersistence(base: AuthoringAdapter): AuthoringAdapter {
539
+ const capabilities = { ...base.capabilities, persist: false };
540
+ const persistence = createEphemeralPersistence();
541
+ // No `report`: a play session refusing to persist is the architecture, not a
542
+ // surprise, and saying so on every edit would be noise. No `record` either —
543
+ // the base adapter already closed the gesture, and a play session's history
544
+ // is its own.
545
+ const ephemeralAck = (): Promise<WriteAck> =>
546
+ runWritePipe({
547
+ resolve: () =>
548
+ resolvesLiveOnly('play edits are session-local by architecture', EPHEMERAL_DESTINATION),
549
+ record: () => undefined,
550
+ });
551
+ const baseTransforms = base.transforms;
552
+ const transforms: TransformProvider | undefined = baseTransforms && {
553
+ ...baseTransforms,
554
+ get: (id) => baseTransforms.get(id),
555
+ beginEdit: (id) => baseTransforms.beginEdit(id),
556
+ apply: (id, t) => baseTransforms.apply(id, t),
557
+ endEdit: (id) => {
558
+ baseTransforms.endEdit(id);
559
+ return ephemeralAck();
560
+ },
561
+ // A REMOVAL IS A WRITE, and a play session's writes are session-local by
562
+ // architecture — so the base's door is shadowed rather than spread through.
563
+ // Inherited unchanged, `{...baseTransforms}` would hand the running game's
564
+ // revert straight to the source lane's attribute deleter, and a play-mode
565
+ // gesture would delete a line of the game's own TSX. There is nothing to
566
+ // apply live either: the value in force once a channel is absent is the
567
+ // one the component declares, which only a remount can report.
568
+ ...(baseTransforms.remove ? { remove: () => ephemeralAck() } : {}),
569
+ };
570
+ const baseInspector = base.inspector;
571
+ const inspector: InspectorProvider | undefined = baseInspector && {
572
+ ...baseInspector,
573
+ properties: (id) => baseInspector.properties(id),
574
+ get: (id, path) => baseInspector.get(id, path),
575
+ set: (id, path, value) => {
576
+ baseInspector.set(id, path, value);
577
+ return ephemeralAck();
578
+ },
579
+ // Same reason as `transforms.remove` above: spread unchanged, the base's
580
+ // revert arrow would delete a JSX attribute from the game's source while
581
+ // the game is PLAYING.
582
+ ...(baseInspector.remove ? { remove: () => ephemeralAck() } : {}),
583
+ };
584
+ return new Proxy(base, {
585
+ get(target, property) {
586
+ if (property === 'capabilities') return capabilities;
587
+ if (property === 'persistence') return persistence;
588
+ if (property === 'transforms' && transforms) return transforms;
589
+ if (property === 'inspector' && inspector) return inspector;
590
+ const value = Reflect.get(target, property, target) as unknown;
591
+ // Preserve the original class receiver for prototype methods while the
592
+ // Proxy itself preserves `instanceof` identity for inspector routing.
593
+ return typeof value === 'function' ? value.bind(target) : value;
594
+ },
595
+ });
596
+ }
597
+
598
+ /** Restore whatever authoring override (if any) was active before play started. */
599
+ function restorePriorAuthoring(): void {
600
+ if (_priorAuthoringOverride === undefined) return;
601
+ setActiveAuthoring(_priorAuthoringOverride);
602
+ _priorAuthoringOverride = undefined;
603
+ }
604
+
605
+ /**
606
+ * D19's one play authoring path for N >= 1. Every mounted root is dispatched
607
+ * by its native surface tag to its direct live adapter. The viewport host may
608
+ * separately present one of those native subjects; presentation never decides
609
+ * whether the other roots exist or remain authorable. Ordinary child edits
610
+ * stay in the LIVE world — a dom root included (`react-play-live-authoring.ts`).
611
+ * A presented OID Three child may additionally expose the explicit
612
+ * `transforms.sourceCommit` verb; that one user gesture is the only route from
613
+ * this Play adapter back to source.
614
+ *
615
+ * THIS FUNCTION AWAITS (the canvas branch dynamic-imports five modules), and
616
+ * `exitPlayMode` is synchronous — so a Stop landing mid-await runs the whole
617
+ * play teardown, INCLUDING `restorePriorAuthoring` (which consumes
618
+ * `_priorAuthoringOverride`), and then this function resumes and installs play
619
+ * authoring over the just-restored edit authoring, with nothing left able to
620
+ * undo it. `isCurrentGeneration` is the guard: it is re-read after the awaits
621
+ * and before anything is committed, and the adapters built so far are disposed
622
+ * on the abort path rather than stranded.
623
+ */
624
+ async function installPlayRootAuthoring(
625
+ store: EditorShellStore,
626
+ roots: readonly RootInstance[],
627
+ manifest: Awaited<ReturnType<typeof fetchGameManifest>>,
628
+ presentedWorldId: string | null,
629
+ isCurrentGeneration: () => boolean,
630
+ ): Promise<void> {
631
+ _priorAuthoringOverride = getAuthoringOverride();
632
+ const children: CompositeChild[] = [];
633
+ const resources: AuthoringAdapter[] = [];
634
+ /** Drop everything built so far — this play is over, so these adapters have
635
+ * no session to belong to and no teardown path that would ever reach them
636
+ * (`exitPlayRootAuthoring` walks `_instance.rootResources`, which this run
637
+ * never gets to assign). */
638
+ const abandon = (): void => {
639
+ for (const adapter of resources) {
640
+ if ('dispose' in adapter && typeof adapter.dispose === 'function') adapter.dispose();
641
+ }
642
+ };
643
+ for (const world of roots) {
644
+ const declaration = manifest ? rootById(manifest, world.id) : undefined;
645
+ const composition = declaration
646
+ ? {
647
+ zOrder: declaration.zOrder,
648
+ pausable: declaration.pausable,
649
+ ...(declaration.entry ? { content: `Entry · ${declaration.entry}` } : {}),
650
+ }
651
+ : {};
652
+ // D-N4/D-N8: an ingested React game's DOM is disclosed as a read-only
653
+ // boundary. Normalizing composition must never silently grant JSX/DOM
654
+ // authoring to vendored source; the universal tree and write authority
655
+ // are independent concerns.
656
+ if (declaration?.adapter.identity === 'ingest-react') {
657
+ children.push({
658
+ worldId: world.id,
659
+ kind: world.kind,
660
+ adapter: new BoundaryAuthoringAdapter(store, {
661
+ id: world.id,
662
+ kind: world.kind,
663
+ adapter: declaration.adapter.identity,
664
+ entryOrScenePath: declaration.entry,
665
+ zOrder: declaration.zOrder,
666
+ pausable: declaration.pausable,
667
+ }),
668
+ ...composition,
669
+ });
670
+ continue;
671
+ }
672
+ const mounted = world.mounted;
673
+ if (mounted.kind === 'three') {
674
+ // The presented subject uses OID identity so Edit→Play selection stays
675
+ // continuous. A headless or non-Three host can omit presentation; its
676
+ // mounted tree still gets an honest structural live projection.
677
+ // `frameControl: 'host'` on the structural branch follows from the same
678
+ // tick-ownership fact: play's own loop drives the root and this adapter
679
+ // holds no handle that can stop it for a gesture.
680
+ const isPresentedSubject = world.id === presentedWorldId && mounted.scene === store.scene;
681
+ const sourceAnchor = isPresentedSubject ? await readOidSourceAnchors() : undefined;
682
+ if (!isCurrentGeneration()) {
683
+ abandon();
684
+ return;
685
+ }
686
+ const adapter = isPresentedSubject
687
+ ? oidThree(store, mounted.scene, liveWorldId(world.id), playRunJournal(world.id), {
688
+ explicitSourceCommit: true,
689
+ sourceAnchor,
690
+ })
691
+ : structuralThree(store, mounted.scene, {
692
+ frameControl: 'host',
693
+ journal: playRunJournal(world.id),
694
+ });
695
+ resources.push(adapter);
696
+ children.push({
697
+ worldId: world.id,
698
+ kind: world.kind,
699
+ adapter: withEphemeralPersistence(adapter),
700
+ ...composition,
701
+ });
702
+ } else if (mounted.kind === 'canvas') {
703
+ if (mounted.substrate.name === 'babylon') {
704
+ const { BabylonAuthoringAdapter } = await import(
705
+ '../host/authoring/babylon-authoring-adapter'
706
+ );
707
+ if (!isCurrentGeneration()) {
708
+ abandon();
709
+ return;
710
+ }
711
+ const adapter = new BabylonAuthoringAdapter(
712
+ mounted.substrate
713
+ .root as import('../host/authoring/babylon-authoring-adapter').BabylonEngineLike,
714
+ store,
715
+ {
716
+ canvas: mounted.canvas,
717
+ api: mounted.substrate.api as never,
718
+ provenance: {
719
+ source: 'live',
720
+ label: 'live',
721
+ detail: 'Native Babylon.js play scene; ordinary Play edits are ephemeral.',
722
+ },
723
+ },
724
+ );
725
+ resources.push(adapter);
726
+ children.push({
727
+ worldId: world.id,
728
+ kind: world.kind,
729
+ adapter: withEphemeralPersistence(adapter),
730
+ ...composition,
731
+ });
732
+ continue;
733
+ }
734
+ if (mounted.substrate.name !== 'pixi') {
735
+ const adapter = new BoundaryAuthoringAdapter(
736
+ store,
737
+ {
738
+ id: world.id,
739
+ kind: world.kind,
740
+ adapter: mounted.substrate.name,
741
+ entryOrScenePath: declaration?.entry,
742
+ zOrder: declaration?.zOrder ?? 0,
743
+ pausable: declaration?.pausable ?? true,
744
+ },
745
+ `No authoring adapter is registered for canvas substrate "${mounted.substrate.name}".`,
746
+ );
747
+ resources.push(adapter);
748
+ children.push({ worldId: world.id, kind: world.kind, adapter, ...composition });
749
+ continue;
750
+ }
751
+ const stage = mounted.substrate.root as import('pixi.js').Container;
752
+ const [physicsRegistry, physicsAdapters, pixiAuthoring, pixiWriteTarget, canvasRuntime] =
753
+ await Promise.all([
754
+ import('@volter/game-runtime/pixi/physics-registry'),
755
+ import('@volter/game-runtime/pixi/system-adapters'),
756
+ import('../host/authoring/pixi-authoring-adapter'),
757
+ import('../host/authoring/pixi-live-write-target'),
758
+ import('../host/canvas-entry-runtime'),
759
+ ]);
760
+ // Stop landed while those imports were in flight — see this function's
761
+ // doc comment. Bail before building (and before the `await` below).
762
+ if (!isCurrentGeneration()) {
763
+ abandon();
764
+ return;
765
+ }
766
+ const { createPhysics2DRegistry } = physicsRegistry;
767
+ const { createPhysicsAdapter2D } = physicsAdapters;
768
+ const { PixiAuthoringAdapter } = pixiAuthoring;
769
+ const { createLiveCanvasWriteTarget } = pixiWriteTarget;
770
+ const physics = createPhysicsAdapter2D(world.physics2d ?? createPhysics2DRegistry());
771
+ const adapter = new PixiAuthoringAdapter(stage, store, {
772
+ // A played canvas world mounts through the SAME adjudicator Edit uses
773
+ // (`resolveCanvasEntryAdapterForEditor`), so its stage is the project
774
+ // graph's under the packaged runtime and this adapter's namespace has
775
+ // to be too — see `../vite-plugin-module-doorways.ts`.
776
+ pixi: await canvasRuntime.resolveCanvasPixiForEditor(),
777
+ target: createLiveCanvasWriteTarget({ physics }),
778
+ journal: playRunJournal(world.id),
779
+ // Every root surface fills the game container exactly, so the
780
+ // container's own rect IS this world's canvas rect.
781
+ surface: getGameContainer,
782
+ });
783
+ resources.push(adapter);
784
+ children.push({
785
+ worldId: world.id,
786
+ kind: world.kind,
787
+ adapter: withEphemeralPersistence(adapter),
788
+ ...composition,
789
+ });
790
+ } else if (mounted.kind === 'dom') {
791
+ // Same regime as the three/pixi children above: edits apply to the LIVE
792
+ // world and die with the session. The adapter is Edit mode's (so OID node
793
+ // identity — and Edit→Play selection continuity — is unchanged); only its
794
+ // write DESTINATION is swapped to the running DOM. See
795
+ // `authoring/react-play-live-authoring.ts`.
796
+ const adapter = createReactPlayAuthoringAdapter(mounted.container, store);
797
+ resources.push(adapter);
798
+ children.push({
799
+ worldId: world.id,
800
+ kind: world.kind,
801
+ adapter: withEphemeralPersistence(adapter),
802
+ ...composition,
803
+ });
804
+ } else {
805
+ // Exhaustiveness guard (§7.4-2): the pre-existing if/else-if chain over
806
+ // `RootInstance.kind` had no trailing else — a hypothetical 4th kind
807
+ // would silently get NO authoring child (no error, just missing
808
+ // authoring for that world) rather than failing loudly/at compile time.
809
+ assertNever(mounted, 'installPlayRootAuthoring');
810
+ }
811
+ }
812
+ // THE COMMIT GATE. Everything below writes editor-wide state that only a live
813
+ // play run may own — the instance's resource list, the active authoring
814
+ // override, the dev handle. Re-read the generation here, after every await
815
+ // above, so a Stop that landed mid-install cannot have its restored edit
816
+ // authoring overwritten by this resumed one.
817
+ if (!isCurrentGeneration()) {
818
+ abandon();
819
+ return;
820
+ }
821
+ _instance.rootResources = resources;
822
+ const projection = hierarchyProjectionFromProjectConfig(getCurrentProject()?.config);
823
+ const composite = new CompositeAuthoringAdapter(children, undefined, projection);
824
+ setActiveAuthoring(composite);
825
+ // `setActiveAuthoring` is a plain module-level variable, not React state —
826
+ // the left hierarchy panel only re-checks `hasAuthoringOverride()` when the
827
+ // store notifies (same reason `ingest/mount-ingest-root.ts` calls this right after
828
+ // installing its own override).
829
+ store.notifyIngestEdit();
830
+
831
+ // Dev/e2e diagnostic handle — the SAME pattern `ingest/mount-ingest-root.ts`'s
832
+ // `window.__vgaiIngest`/`window.__vgaiIngest2D` use:
833
+ // `store.saveNow()` (Ctrl/Cmd+S) and `_autoSave()` are both structurally
834
+ // guarded OFF while ANY authoring override is active (`hasAuthoringOverride()`
835
+ // / the play-state check) — the adapter's OWN `persistence.save()` is the
836
+ // only way an override session's edits reach disk, so tests/tooling need a
837
+ // handle to call it directly, exactly as the ingest sessions expose.
838
+ if (import.meta.env.DEV) {
839
+ (window as unknown as Record<string, unknown>)['__vgaiMultiRoot'] = {
840
+ adapter: composite,
841
+ worldIds: children.map((c) => c.worldId),
842
+ store,
843
+ };
844
+ }
845
+ }
846
+
847
+ /** This run's journal for one world — see {@link PlayInstance.journalSession}. */
848
+ function playRunJournal(worldId: string): JournalSubject {
849
+ return playJournal(_instance.id, worldId);
850
+ }
851
+
852
+ /**
853
+ * Teardown every per-world authoring resource owned by the play session, and
854
+ * END THE RUN'S JOURNAL.
855
+ *
856
+ * This is the ONE teardown path allowed to expire a journal session
857
+ * (`history/json-history-resource.ts`'s ownership block), and it may expire
858
+ * only the session play itself minted. An adapter's own `dispose()` merely
859
+ * detaches: it is a SHARER of whatever session it was handed, and a sharer that
860
+ * could end a session is how a held surface's undo stack came to be emptied by
861
+ * every remount.
862
+ */
863
+ function exitPlayRootAuthoring(store: EditorShellStore): void {
864
+ for (const adapter of _instance.rootResources) {
865
+ if ('dispose' in adapter && typeof adapter.dispose === 'function') adapter.dispose();
866
+ }
867
+ _instance.rootResources = [];
868
+ // Play edits are session-local by architecture: nothing this run journaled
869
+ // may still be undoable once the run is over.
870
+ if (_instance.journalSession) {
871
+ store.projectHistory?.expireSession(_instance.journalSession);
872
+ _instance.journalSession = '';
873
+ }
874
+ if (import.meta.env.DEV) {
875
+ delete (window as unknown as Record<string, unknown>)['__vgaiMultiRoot'];
876
+ }
877
+ }
878
+ /**
879
+ * W5: decides whether a keydown Escape should stop play mode. Escape-to-stop
880
+ * is an EDITOR command (unlike other play input, it must fire regardless of
881
+ * `activeViewportTab` — Escape from the Scene tab is expected to stop play
882
+ * too), so it is gated separately from the game-input predicate. Escape
883
+ * consumed by an overlay (menu/dialog/popover called preventDefault) or
884
+ * aimed at a text edit (input/textarea/select/contenteditable — the user
885
+ * means "cancel this edit", not "stop play") must not stop play.
886
+ * Exported for unit testing with synthetic KeyboardEvents.
887
+ */
888
+ export function shouldEscapeStopPlay(e: KeyboardEvent): boolean {
889
+ if (e.defaultPrevented) return false;
890
+ if (isEditableTarget(e.target)) return false;
891
+ return true;
892
+ }
893
+
894
+ let _escapeListener: ((e: KeyboardEvent) => void) | null = null;
895
+ let _originalConsoleLog: typeof console.log | null = null;
896
+ let _originalConsoleInfo: typeof console.info | null = null;
897
+ let _originalConsoleWarn: typeof console.warn | null = null;
898
+ let _originalConsoleError: typeof console.error | null = null;
899
+ let _logFlushChain: Promise<unknown> = Promise.resolve();
900
+ let _flushInterval: ReturnType<typeof setInterval> | null = null;
901
+ let _pendingEntries: LogEntry[] = [];
902
+ let _lastPersistedDebugEventSeq = 0;
903
+
904
+ /**
905
+ * The per-entry half of a play log's identity (`@volter/editor-sdk`'s
906
+ * `play/log-format.ts` owns the format; the run's session/project/name/start
907
+ * are a header line the server writes once, and are deliberately NOT repeated
908
+ * here).
909
+ *
910
+ * Both fields are stamped ONLY when this writer genuinely knows them, and
911
+ * absence is a real answer:
912
+ * - `simSpeed` is the live loop's `timeScale` — an instrument can change it
913
+ * mid-run, so it is a genuine per-entry fact. Absent before the game exists.
914
+ * - `world` is the world the run PRESENTS (the adopted three root), or the
915
+ * game's single root when it has exactly one and attribution is therefore
916
+ * unambiguous. A multi-root run with no presented world gets nothing rather
917
+ * than a guess — the anti-shim rule applies to evidence too.
918
+ */
919
+ function playLogRunStamp(): { world?: string; simSpeed?: number } {
920
+ const game = _instance.session?.game;
921
+ if (!game) return {};
922
+ const roots = game.roots;
923
+ const world = _instance.presentation?.worldId ?? (roots.length === 1 ? roots[0]?.id : undefined);
924
+ const timeScale = game.loop.timeScale;
925
+ return {
926
+ ...(world !== undefined ? { world } : {}),
927
+ ...(typeof timeScale === 'number' ? { simSpeed: timeScale } : {}),
928
+ };
929
+ }
930
+
931
+ function drainDebugEventsToPlayLog(): void {
932
+ const debug = _instance.session?.game?.systemAdapters?.debug;
933
+ if (!debug) return;
934
+ try {
935
+ const converted = debugEventsToLogEntries(
936
+ debug.events(_lastPersistedDebugEventSeq),
937
+ _lastPersistedDebugEventSeq,
938
+ );
939
+ _lastPersistedDebugEventSeq = converted.lastSeq;
940
+ const stamp = playLogRunStamp();
941
+ for (const entry of converted.entries) _pendingEntries.push({ ...entry, ...stamp });
942
+ } catch {
943
+ // Logging is evidence, never a reason to break the game loop.
944
+ }
945
+ }
946
+
947
+ // Asset bytes have changed, but a running game retains its own instances.
948
+ const stopAssetReload = onAssetReload(() => {
949
+ if (!_instance.session) return;
950
+ markRestartRequired(
951
+ 'Project assets changed — restart play to load the revised models or textures.',
952
+ );
953
+ });
954
+ if (import.meta.hot) import.meta.hot.dispose(stopAssetReload);
955
+
956
+ // --- Script HMR ---
957
+ if (import.meta.hot) {
958
+ import.meta.hot.on('vgai:restart-required', (data: { file: string }) => {
959
+ if (!_instance.session) return;
960
+ const file = data.file.split('/').pop() ?? data.file;
961
+ markRestartRequired(`${file} changed and cannot be applied safely while the game is running.`);
962
+ warnRestartRequired(`Restart required: ${file} changed.`);
963
+ });
964
+ // An R3F-dialect entry file changed while the game is RUNNING. Play mode
965
+ // deliberately does NOT full-reload for `r3f-entry` files (the
966
+ // dev server swallows them from stock HMR and fires this custom event; the
967
+ // design session that normally absorbs it is suspended during play) — but
968
+ // stale must never be SILENT. Mark the session restart-required: the
969
+ // PlayBar's Restart button lights up (variant 'primary') carrying this
970
+ // reason, and ONE click remounts every root from fresh source and
971
+ // re-enters play (`enterPlayMode` re-imports the entry with a
972
+ // cache-busting query — see `loadProjectScripts`). `vgai status` reports
973
+ // the same pending-restart state via `collectState().restartRequired`, so
974
+ // agents get the signal humans get. EDIT-mode behavior is unchanged
975
+ // (`_instance.session` is null there; absorb-by-remount stays as landed — see
976
+ // r3f-design-session.ts).
977
+ import.meta.hot.on('vgai:r3f-entry-update', (data: { file: string }) => {
978
+ if (!_instance.session) return;
979
+ const file = data.file.split('/').pop() ?? data.file;
980
+ markRestartRequired(
981
+ `${file} changed while playing — the running R3F world is stale until play restarts.`,
982
+ );
983
+ warnRestartRequired(`Restart required: ${file} changed while playing.`);
984
+ });
985
+ import.meta.hot.on('vgai:script-update', async (data: { file: string }) => {
986
+ // Project-tool source belongs to editor chrome. The tool contribution
987
+ // store re-imports and remounts it; no game root can become stale from an
988
+ // editor-only document changing.
989
+ if (isEditorLanePath(data.file)) return;
990
+ const project = getCurrentProject();
991
+ if (!project) return;
992
+ if (!_instance.session || !_ctx) return;
993
+ markRestartRequired(
994
+ `${data.file.split('/').pop() ?? data.file} changed and requires remounting all roots.`,
995
+ );
996
+ });
997
+ }
998
+
999
+ /** The live document's container element (`live-document.ts` owns it). */
1000
+ export function getGameContainer(): HTMLElement | null {
1001
+ return editorHost().workspace.liveDocument.container();
1002
+ }
1003
+
1004
+ /** Move keyboard focus and the hotkey scope onto the live game pane. */
1005
+ function focusGameSurface(): void {
1006
+ const take = (): void => {
1007
+ const pane = editorHost().workspace.liveDocument.container();
1008
+ if (!pane) return;
1009
+ // The transport button keeps focus through a plain `pane.focus()` when
1010
+ // the pane is not yet focusable or the state flip re-renders it —
1011
+ // measured on preview build 72: activeElement stayed BUTTON[play-control]
1012
+ // and Space still stopped play. Drop the button's focus explicitly, make
1013
+ // the pane focusable, then focus it.
1014
+ const active = typeof document !== 'undefined' ? document.activeElement : null;
1015
+ if (active instanceof HTMLElement && active !== pane && active.tagName === 'BUTTON') {
1016
+ active.blur();
1017
+ }
1018
+ if (!pane.hasAttribute('tabindex')) pane.tabIndex = -1;
1019
+ try {
1020
+ pane.focus({ preventScroll: true });
1021
+ } catch {
1022
+ // a detached pane cannot take focus; the scope hand-off below still stands
1023
+ }
1024
+ setActiveScope('viewport');
1025
+ };
1026
+ take();
1027
+ // The play-state flip re-renders the Game document; the pane the first
1028
+ // attempt focused may be replaced by the commit. Take it again after it.
1029
+ setTimeout(take, 150);
1030
+ setTimeout(take, 600);
1031
+ }
1032
+
1033
+ /** Bind the play-mode orchestrator to editor context. Call once at init. */
1034
+ /**
1035
+ * Play binds to the shell store on its arrival (`shell-store-door.ts`), so no
1036
+ * workspace panel names Play to hand it the store; the authored viewport is
1037
+ * read through `viewport-door.ts` when a session presents its roots.
1038
+ */
1039
+ onShellStore((store) => bindPlayMode(store));
1040
+
1041
+ export function bindPlayMode(store: EditorShellStore): void {
1042
+ _ctx = { store, gameContainer: null! }; // set dynamically from the live document's container
1043
+ // THE BARE-KEY YIELD IS THE FRAME'S. While Play runs the game's keys are the
1044
+ // game's, and what decides is the workbench: our stage actions carry a
1045
+ // `when` clause over `vgai.stage.focused`/`vgai.play`, so a bare key reaches
1046
+ // the game rather than a shell binding, and a ⌘-chord stays the workbench's.
1047
+ // The engine's own `InputManager` gate tracks the same predicate
1048
+ // independently (`gated-globals.ts` and `surface-keyboard.ts`), which is what
1049
+ // keeps a game's raw `window.addEventListener('keydown')` gated too.
1050
+ // The idle auto-stop needs a way to end play without `play-recording.ts`
1051
+ // importing the play lifecycle it is driven BY. Handed over here rather than
1052
+ // at module scope so the two directions of the edge stay one-way.
1053
+ bindPlayRecordingStop(exitPlayMode);
1054
+ for (const bound of playModeBindingWaiters) bound();
1055
+ }
1056
+
1057
+ /** True if play mode is currently active (playing or paused). */
1058
+ export function isPlayModeActive(): boolean {
1059
+ return _instance.session !== null;
1060
+ }
1061
+
1062
+ /**
1063
+ * Narrow relay accessor — the live play session's `InputManager`(s)
1064
+ * and the Game root's loop, for `command-listener.ts`'s
1065
+ * `inject-input`/`set-time-scale`/`set-seed` cases. This is exactly the
1066
+ * accessor the vgai-sdk honest-gap jsdocs prescribed
1067
+ * (`play/input-operations.ts`, `play/control-operations.ts`): `_instance.session` is
1068
+ * module-private, so the relay needs this one exported read. Everything else
1069
+ * the relay reads (state providers, debug commands) flows through
1070
+ * `setActiveSystems`/`getActiveSystems` instead.
1071
+ *
1072
+ * `getInputTarget(worldId?)` (D15/T-D15.5 — review objection 2's fix)
1073
+ * REPLACES what used to be a plain `input: Game['input'] | null` field —
1074
+ * `Game.input` always resolves to the DEFAULT world's `InputManager` only,
1075
+ * while the debug bridge's `window.__vgai.input.*` reached whichever world's
1076
+ * `InputManager` last called `DebugRegistry.setVirtualInputTarget` (a
1077
+ * SEPARATE, last-writer-wins slot). In a multi-world project those two could
1078
+ * name DIFFERENT roots — the exact closed-PR review objection. Now both
1079
+ * doors call the SAME `getDebugRegistry(game).getVirtualInputTarget(worldId)`
1080
+ * — one resolution function (`debug-registry.ts`'s `resolveInputRootId`),
1081
+ * so `inject-input` (this accessor) and `window.__vgai.input.*`
1082
+ * (`debug-bridge.ts`) can never disagree about which world an unqualified
1083
+ * actuation targets again. `null` when nothing is registered for the
1084
+ * resolved id (or no `Game` is running at all); throws the registry's own
1085
+ * `DEBUG_INPUT_WORLD_NOT_FOUND` for an explicit, unregistered `worldId`.
1086
+ *
1087
+ * `runTicks` (D15/T-D15.4) is added the SAME way: reached via
1088
+ * `getDebugRegistry(game).getRunTicksTarget()` — the identical accessor
1089
+ * `runtime/debug-bridge.ts`'s `window.__vgai.runTicks` (door a) goes
1090
+ * through, so the editor relay's `run-ticks` case (door b, → `play.runTicks`)
1091
+ * calls byte-identical behavior (D17). `null` only when no `Game` is running
1092
+ * at all (`_instance.session` is `null`) — a live `Game` always wires a run-ticks
1093
+ * target immediately at construction (`createGame`, `runtime/game.ts`), so
1094
+ * `runTicks` is non-null whenever `_instance.session` is non-null. `runTicks` itself
1095
+ * is game-global (one shared tick loop across every world by design), so —
1096
+ * unlike the input target — it never needed per-world routing.
1097
+ *
1098
+ * `random`/`determinismDeclared` (D15/T-D15.6) back the `set-seed` relay
1099
+ * case and `collectState`'s `seed`/`deterministic` fields: `random` is
1100
+ * `getSeededRandom(game)` — the SAME game-scoped `SeededRandom` every
1101
+ * world's `ctx.random` reads, non-null for every real `createGame` call
1102
+ * (see `seeded-random.ts`) — and `determinismDeclared` is
1103
+ * `_instance.determinismDeclared`, set from the CURRENT session's manifest in
1104
+ * `enterPlayMode`.
1105
+ */
1106
+ export function getPlayRuntimeAccess(): {
1107
+ getInputTarget(worldId?: string): DebugVirtualInputTarget | null;
1108
+ loop: GameLoop;
1109
+ runTicks: ((n: number, opts?: RunTicksOptions) => void) | null;
1110
+ /** The SETTLED-AWARE driver over the same target (`runtime/run-ticks-settled.ts`) — the
1111
+ * relay's `run-ticks` case awaits this so a tick never races a scene remount's async
1112
+ * commit, byte-identical with the bridge's `runTicksSettled` door (D17). */
1113
+ runTicksSettled: ((n: number, opts?: RunTicksOptions) => Promise<void>) | null;
1114
+ random: SeededRandom | null;
1115
+ determinismDeclared: boolean;
1116
+ } | null {
1117
+ const game = _instance.session?.game;
1118
+ if (!game) return null;
1119
+ const registry = getDebugRegistry(game);
1120
+ const runTicksTarget = registry?.getRunTicksTarget() ?? null;
1121
+ return {
1122
+ getInputTarget: (worldId?: string) => registry?.getVirtualInputTarget(worldId) ?? null,
1123
+ loop: game.loop,
1124
+ runTicks: runTicksTarget ? runTicksTarget.runTicks.bind(runTicksTarget) : null,
1125
+ runTicksSettled:
1126
+ registry && runTicksTarget ? (n, opts) => runTicksWhenSettled(registry, n, opts) : null,
1127
+ random: getSeededRandom(game),
1128
+ determinismDeclared: _instance.determinismDeclared,
1129
+ };
1130
+ }
1131
+
1132
+ /** Get the running game's scene (for editor Scene tab rendering). */
1133
+ export function getGameScene(): THREE.Scene | null {
1134
+ const mounted = _instance.session?.game.defaultRoot.mounted;
1135
+ return mounted?.kind === 'three' ? mounted.scene : null;
1136
+ }
1137
+
1138
+ export interface GameRootSurfaceFact {
1139
+ readonly rootId: string;
1140
+ readonly kind: 'three' | 'canvas' | 'dom';
1141
+ readonly hasRenderableContent: boolean;
1142
+ }
1143
+
1144
+ /** Exact mounted-root content facts for the Game document's empty-state UI. */
1145
+ export function gameRootSurfaceFacts(): readonly GameRootSurfaceFact[] {
1146
+ const roots = _instance.session?.game?.roots;
1147
+ if (!roots) return [];
1148
+ return roots.map((root) => {
1149
+ const mounted = root.mounted;
1150
+ let hasRenderableContent = false;
1151
+ if (mounted.kind === 'three') {
1152
+ hasRenderableContent = threeSceneHasRenderableContent(mounted.scene);
1153
+ } else if (mounted.kind === 'canvas') {
1154
+ try {
1155
+ hasRenderableContent =
1156
+ mounted.substrate.name === 'pixi'
1157
+ ? mountedStoryHasPixiContent(mounted.substrate.root as import('pixi.js').Container)
1158
+ : mounted.substrate.name === 'babylon'
1159
+ ? (
1160
+ (mounted.substrate.root as { scenes?: Array<{ rootNodes?: unknown[] }> })
1161
+ .scenes ?? []
1162
+ ).some((scene) => (scene.rootNodes?.length ?? 0) > 0)
1163
+ : false;
1164
+ } catch {
1165
+ hasRenderableContent = false;
1166
+ }
1167
+ } else {
1168
+ hasRenderableContent = domHasRenderableContent(mounted.container);
1169
+ }
1170
+ return { rootId: root.id, kind: mounted.kind, hasRenderableContent };
1171
+ });
1172
+ }
1173
+
1174
+ /**
1175
+ * #140 — the live play-mode game canvas, for `command-listener.ts`'s
1176
+ * `bridge-screenshot` relay op (`RelayTransport.screenshot` in `@volter/editor-live`).
1177
+ * The universal host mounts its root surfaces into `editorHost().workspace.liveDocument.container()`;
1178
+ * querying it for the bottom canvas avoids a surface-specific session alias.
1179
+ * `null` when not in play mode or no canvas surface is mounted.
1180
+ */
1181
+ export function getPlayCanvas(): HTMLCanvasElement | null {
1182
+ if (!_instance.session) return null;
1183
+ // DECLARATION FIRST (ARCHITECTURE-CORE §The editor protocol, zero
1184
+ // inference). The roots path stamps each surface with the root it presents
1185
+ // (`create-runtime.ts`), and a self-booting game may name its own canvas on
1186
+ // its contract — so `presentationSurface` READS which canvas is the game's
1187
+ // picture. "Bottom-most `<canvas>` in DOM order" survives as its measured
1188
+ // fallback, unchanged, for a container carrying neither declaration.
1189
+ return presentationSurface(editorHost().workspace.liveDocument.container()).canvas;
1190
+ }
1191
+
1192
+ /** The game container for a SPECIFIC instance — the primary when `id` is omitted
1193
+ * (or names it), else the addressed additional seat. This is what per-instance
1194
+ * screenshot capture targets, so `game.instance(id).screenshot()` grabs THAT
1195
+ * seat's game stack and a plain screenshot can capture every seat in turn. */
1196
+ export function getInstanceContainer(id?: string): HTMLElement | null {
1197
+ if (!id || id === _instance.id) return editorHost().workspace.liveDocument.container();
1198
+ return _additional.find((inst) => inst.id === id)?.container ?? null;
1199
+ }
1200
+
1201
+ /** The canvas for a SPECIFIC instance — the fallback capture leg when the
1202
+ * composite path is unavailable. Primary when `id` is omitted/names it. */
1203
+ export function getInstanceCanvas(id?: string): HTMLCanvasElement | null {
1204
+ if (!id || id === _instance.id) return getPlayCanvas();
1205
+ const inst = _additional.find((i) => i.id === id);
1206
+ if (!inst?.session) return null;
1207
+ // Same declaration-first read as the primary seat's — an addressed seat is a
1208
+ // second mount of the SAME project, so it carries the same stamps.
1209
+ return presentationSurface(inst.container).canvas;
1210
+ }
1211
+
1212
+ /** #146 — when the most recent play run began (ms epoch), or `null` before
1213
+ * any run in this page. `command-listener.ts`'s relay `snapshot` uses this to
1214
+ * fence `pageErrors` to errors from that run (the editor console accumulates
1215
+ * across runs; a session client reading "what went wrong during my run" must not
1216
+ * see an OLDER session's stale failures). PD-1: it deliberately survives
1217
+ * `exitPlayMode()` — a failed run's errors must still be readable after the
1218
+ * failure tears the run down, which is the only moment anyone asks. */
1219
+ export function getPlayStartedAt(): number | null {
1220
+ return _playStartedAtMs;
1221
+ }
1222
+
1223
+ /** When the most recent play run's teardown finished (ms epoch), or `null`
1224
+ * while a run is live / before the first run. Together with
1225
+ * `getPlayStartedAt()` this is the CLOSED window `command-listener.ts` uses to
1226
+ * split "this run's errors" (`pageErrors`/`consoleErrors`) from "the rest of
1227
+ * the session's" (`sessionErrors`/`sessionWarnings`) — see `_playEndedAtMs`. */
1228
+ export function getPlayEndedAt(): number | null {
1229
+ return _playEndedAtMs;
1230
+ }
1231
+
1232
+ /** Every live instance (primary first). Lifecycle operations that act on "the
1233
+ * running game" — resize, pause, resume, step — must cover the WHOLE split, or
1234
+ * the extra seats keep running while the primary freezes, ignore resizes, and
1235
+ * so on. Single-instance play is just the one-element case. */
1236
+ function allLiveInstances(): PlayInstance[] {
1237
+ return [_instance, ..._additional].filter((inst) => inst.session);
1238
+ }
1239
+
1240
+ // Suspending presentation parks the RAF, not the session or its user-selected
1241
+ // Play/Pause state. Resume only loops this gate stopped, with start() resetting
1242
+ // the frame clock so hidden time never becomes a simulation catch-up burst.
1243
+ const presentationSuspendedLoops = new Set<GameLoop>();
1244
+ function syncPlayPresentationActivity(): void {
1245
+ const liveLoops = new Set(allLiveInstances().map((inst) => inst.session!.game.loop));
1246
+ for (const loop of presentationSuspendedLoops) {
1247
+ if (!liveLoops.has(loop)) presentationSuspendedLoops.delete(loop);
1248
+ }
1249
+ for (const loop of liveLoops) {
1250
+ if (isEditorPresentationActive()) {
1251
+ if (presentationSuspendedLoops.delete(loop)) loop.start();
1252
+ } else if (loop.liveness !== 'stopped') {
1253
+ presentationSuspendedLoops.add(loop);
1254
+ loop.stop();
1255
+ }
1256
+ }
1257
+ }
1258
+ const unsubscribePlayPresentation = subscribeEditorPresentationActivity(
1259
+ syncPlayPresentationActivity,
1260
+ );
1261
+ import.meta.hot?.dispose(unsubscribePlayPresentation);
1262
+
1263
+ /**
1264
+ * The seat the editor's RUNTIME INSTRUMENTS are pointed at — the Inspect
1265
+ * selector's answer (`active-systems.ts`), never editor authoring focus and
1266
+ * never the addressed-instance wire. A stale/unset selection falls back to the
1267
+ * first live seat, mirroring `resolvedInspectedInstanceId`, so the instruments
1268
+ * are never orphaned.
1269
+ */
1270
+ function inspectedInstance(): PlayInstance | null {
1271
+ const live = allLiveInstances();
1272
+ const id = inspectedInstanceId();
1273
+ return live.find((inst) => inst.id === id) ?? live[0] ?? null;
1274
+ }
1275
+
1276
+ /**
1277
+ * The two things play publishes for the LIFETIME OF ONE SESSION: the play
1278
+ * surface's inspection subject (`inspection/game-subject.ts`), and the live
1279
+ * game every project contribution's props carry
1280
+ * (`tool-contribution-play.ts`).
1281
+ *
1282
+ * RESOURCE OWNERSHIP: play owns both outright. They are created by the one
1283
+ * `enterPlayMode` that adopts a session and dropped by the one `exitPlayMode`
1284
+ * that ends it — additional seats never publish their own, because the subject
1285
+ * is THE game and which seat it reads is resolved LIVE from the inspected
1286
+ * instance, on every read.
1287
+ *
1288
+ * Both are published as READERS rather than values for that reason: switching
1289
+ * the Inspect selector must move the subject and the contribution props
1290
+ * together, without a republish.
1291
+ */
1292
+ let _gameSubjectRegistration: (() => void) | null = null;
1293
+
1294
+ function publishPlaySessionReaders(): void {
1295
+ _gameSubjectRegistration?.();
1296
+ _gameSubjectRegistration = registerGameNullSubject(() => ({
1297
+ projectName: getCurrentProject()?.config.name ?? null,
1298
+ // Only when a split makes "which of these games?" a real question.
1299
+ instanceName: allLiveInstances().length > 1 ? (inspectedInstance()?.name ?? null) : null,
1300
+ }));
1301
+ publishToolContributionPlay(() => {
1302
+ const inst = inspectedInstance();
1303
+ return inst?.session
1304
+ ? { game: inst.session.game, instanceId: inst.id, recording: toolContributionRecording }
1305
+ : null;
1306
+ });
1307
+ }
1308
+
1309
+ function dropPlaySessionReaders(): void {
1310
+ _gameSubjectRegistration?.();
1311
+ _gameSubjectRegistration = null;
1312
+ publishToolContributionPlay(null);
1313
+ }
1314
+
1315
+ /** Resize the running game to a specific resolution. `pixelRatio` (W2c
1316
+ * device preview) optionally re-pins the renderers' DPR in the same pass.
1317
+ * Applies to EVERY seat — under an even split every viewport is the same slot
1318
+ * size, so the extra canvases resize with the primary instead of staying
1319
+ * pinned at their mount size. */
1320
+ export function resizeGame(width: number, height: number, pixelRatio?: number): void {
1321
+ for (const inst of allLiveInstances()) inst.session?.resize(width, height, pixelRatio);
1322
+ }
1323
+
1324
+ /**
1325
+ * Serializes every `enterPlayMode()` invocation (the ghost-runtime bug:
1326
+ * `vgai play` on an already-playing/still-booting session left the
1327
+ * PREVIOUS runtime alive, ticking and rendering alongside the new one).
1328
+ *
1329
+ * Root cause: `enterPlayModeInner`'s own `if (_instance.session) exitPlayMode()`
1330
+ * guard (below) only protects the case where a PRIOR play has already fully
1331
+ * finished booting and assigned `_instance.session`. While a call is still mid-boot
1332
+ * (between this function being entered and `_instance.session` being assigned —
1333
+ * awaiting script loads / manifest resolution / asset loads / mount),
1334
+ * `_instance.session` reads as `null`, so a SECOND `enterPlayMode()` call landing in
1335
+ * that window sees no session to tear down and proceeds to run its own
1336
+ * entire prologue (`patchConsole()`, `startLogSession()`, canvas creation,
1337
+ * script loading, mount) CONCURRENTLY with the first call's. Both calls
1338
+ * mutate shared module-level/global state with no reentrancy guard —
1339
+ * `patchConsole()`/`unpatchConsole()` are the sharpest example (proven by a
1340
+ * red-before-green regression test: two overlapping calls double-wrap
1341
+ * `console.log` and the inner wrapper recurses into itself via the shared
1342
+ * `_originalConsoleLog` variable, a real `RangeError: Maximum call stack
1343
+ * size exceeded` — not a hypothetical). In the field this window is entered
1344
+ * whenever a caller re-issues `play` before the browser has acked the first
1345
+ * (a slow scene boot outliving the relay's/SDK's own `play.start` timeout is
1346
+ * the documented trigger — `packages/vgai-sdk/src/play/transport.ts`'s
1347
+ * `PLAY_START_TIMEOUT_MS`/`editor-server.ts`'s `PLAY_COMMAND_TIMEOUT_MS` —
1348
+ * and `play.start`'s own contract is explicitly "start (or restart)", so a
1349
+ * caller retrying after a timeout is using the API as documented, not
1350
+ * misusing it), or whenever the SSE `editor-command` listener
1351
+ * (`command-listener.ts`) dispatches a burst of commands without awaiting
1352
+ * the previous `handleCommand()` to settle.
1353
+ *
1354
+ * The existing `_playEpoch` generation guard decides, AFTER THE FACT, which
1355
+ * of two overlapping boots gets adopted into `_instance.session` and stops the loser
1356
+ * — but it does nothing to stop both boots from RUNNING (and their
1357
+ * prologues from clobbering each other) in the first place. Epoch-checking
1358
+ * is an adjudication mechanism, not a mutex.
1359
+ *
1360
+ * The fix is a FIFO queue, not a smarter epoch check: every call chains onto
1361
+ * the tail of `_enterQueue`, so a second call's ENTIRE body — prologue
1362
+ * included — never starts until the first call's entire invocation (success
1363
+ * or failure, including any self-teardown it performs) has fully settled.
1364
+ * This makes "play again while already playing/booting" a genuine, clean,
1365
+ * awaited restart for every caller (CLI, SDK, UI Play button, HMR's warm
1366
+ * restart) with no code path capable of leaving a booted session
1367
+ * unreferenced ("orphaned") — by the time any enterPlayMode() body runs,
1368
+ * it is provably the only one running.
1369
+ */
1370
+ let _enterQueue: Promise<void> = Promise.resolve();
1371
+ let _activePlaytest: PlaytestContext | null = null;
1372
+
1373
+ /** Host remount of a native swap-slot scene — the entrypoint's selection
1374
+ * const is rewritten at serve time for THIS play run only. */
1375
+ export type PlaySelectionOverride = EntrypointSelectionOverride & {
1376
+ readonly regionId: string;
1377
+ };
1378
+
1379
+ let _playSelectionOverride: PlaySelectionOverride | null = null;
1380
+
1381
+ export function activePlaytest(): PlaytestContext | null {
1382
+ return _activePlaytest;
1383
+ }
1384
+
1385
+ function privatePlaytest(): PlaytestContext {
1386
+ const id = globalThis.crypto?.randomUUID?.() ?? `private-${Date.now()}`;
1387
+ return {
1388
+ mode: 'private',
1389
+ id,
1390
+ roomKey: `private:${id}`,
1391
+ revision: null,
1392
+ participantId: EDITOR_PARTICIPANT_ID,
1393
+ };
1394
+ }
1395
+
1396
+ export function enterPlayMode(
1397
+ explicitSeed?: number,
1398
+ playtest: PlaytestContext = privatePlaytest(),
1399
+ runName?: string | null,
1400
+ selectionOverride?: PlaySelectionOverride | null,
1401
+ ): Promise<void> {
1402
+ const override = selectionOverride ?? null;
1403
+ const run = _enterQueue.then(() => {
1404
+ // Publish the boot's phases for as long as it runs, and clear them on
1405
+ // EVERY exit — success, throw, and the early returns that hand the run to
1406
+ // ingest/module mode. A phase left standing after a boot finished would
1407
+ // make the next stuck command blame a step that ended minutes ago.
1408
+ beginPlayBoot();
1409
+ return enterPlayModeInner(explicitSeed, playtest, runName ?? null, override).finally(() => {
1410
+ endPlayBoot();
1411
+ });
1412
+ });
1413
+ // Advance the queue unconditionally so one caller's rejection can never
1414
+ // wedge every subsequent play attempt — each caller still observes its
1415
+ // OWN failure via the `run` promise this function returns.
1416
+ _enterQueue = run.then(
1417
+ () => undefined,
1418
+ () => undefined,
1419
+ );
1420
+ return run;
1421
+ }
1422
+
1423
+ /**
1424
+ * Let the editor's one auto-launch coordinator finish before generic Play
1425
+ * chooses a runtime. Returns true when that coordinator already owns the run.
1426
+ */
1427
+ async function startAutoLaunchedAdapterPlay(store: EditorShellStore): Promise<boolean> {
1428
+ const { autoLaunchIngest } = await import('../ingest/mount-ingest-root');
1429
+ await autoLaunchIngest(store);
1430
+ const bootIngest = getIngestPlayControl();
1431
+ if (bootIngest) {
1432
+ bootIngest.play();
1433
+ return true;
1434
+ }
1435
+ // `autoLaunchIngest` also owns the standalone `{ module }` route. If that
1436
+ // route claimed the project, its live module session is already the run.
1437
+ const { isModuleModeActive } = await import('../ingest/module-mode');
1438
+ return isModuleModeActive();
1439
+ }
1440
+
1441
+ /**
1442
+ * Enter play mode: create a game canvas, start game, disable editor controls.
1443
+ *
1444
+ * `explicitSeed` (D15/T-D15.6, objection-4 fix — `vgai play --seed <n>`)
1445
+ * — the CLI's `play` command relays it through as `cmd['seed']`
1446
+ * (`command-listener.ts`'s `'play'` case); it is the "explicit config"
1447
+ * leg of `resolveDeterminismSeed`'s precedence (highest — beats
1448
+ * `manifest.determinism.defaultSeed`/`?vgai-seed=` on the editor's own
1449
+ * page URL), threaded into whichever mount path this play resolves to
1450
+ * below, exactly like `mountManifestRoots`'s own `opts.seed` already is
1451
+ * for a standalone boot.
1452
+ *
1453
+ * `runName` (optional — `vgai play --name <text>`) is FINDABILITY and nothing
1454
+ * else: the server slugifies it into this run's `logs/play-*.jsonl` filename
1455
+ * and its session-journal line, so "the run where I tested the boss fight" is
1456
+ * a grep instead of timestamp archaeology. No registry, no uniqueness check —
1457
+ * two runs sharing a name are two files with different stamps.
1458
+ *
1459
+ * Not exported directly — always call {@link enterPlayMode}, which
1460
+ * serializes invocations of this function so overlapping callers can never
1461
+ * run concurrently. See that wrapper's doc comment for why.
1462
+ */
1463
+ /**
1464
+ * Every network-shaped step of the boot below runs through this. See
1465
+ * `play-boot-stall.ts` for the measurement it exists for: on a BACKGROUNDED
1466
+ * tab these fetches are deprioritized by the browser and can sit for minutes,
1467
+ * which the relay could only report as a generic 120s "editor connected but
1468
+ * did not respond". The runtime MOUNT deliberately does not go through here —
1469
+ * an abandoned mount would leak a live session, and it is not the starved step.
1470
+ */
1471
+ function playBootStep<T>(step: PlayBootPhase, work: Promise<T>): Promise<T> {
1472
+ // Publish the phase BEFORE the work — the whole point is that a step which
1473
+ // never returns is still named. See `play-boot-phase.ts`.
1474
+ markPlayBootPhase(step);
1475
+ return withPlayBootStallGuard(step, work, {
1476
+ isHidden: () => typeof document !== 'undefined' && document.hidden,
1477
+ editorUrl: typeof window !== 'undefined' ? window.location.href : undefined,
1478
+ });
1479
+ }
1480
+
1481
+ async function enterPlayModeInner(
1482
+ explicitSeed: number | undefined,
1483
+ playtest: PlaytestContext,
1484
+ runName: string | null,
1485
+ selectionOverride: PlaySelectionOverride | null,
1486
+ ): Promise<void> {
1487
+ // PD-1 — degrade loudly: every path out of this function that did NOT start
1488
+ // play now THROWS. A silent `return` resolved the caller's promise, so the
1489
+ // command relay answered `{ ok: true }` for a play that never started and
1490
+ // the CLI fell back to a generic "check logs/play-*.jsonl" that named no
1491
+ // cause. The three genuine-failure gates (no editor shell, no game
1492
+ // container, no project) throw here; the epoch checks below throw a
1493
+ // distinct "cancelled" message, because "someone stopped it" is also a real
1494
+ // answer and is not the same answer as "it broke".
1495
+ if (!_ctx) {
1496
+ const bindingEpoch = _playEpoch;
1497
+ markPlayBootPhase('waiting for the editor to bind Play');
1498
+ await waitForPlayModeBinding();
1499
+ if (_playEpoch !== bindingEpoch) {
1500
+ throw new Error('Play start cancelled while waiting for the editor to bind.');
1501
+ }
1502
+ }
1503
+ if (!_ctx) throw new Error('Play mode failed to start: the editor shell is not bound.');
1504
+ // EVERY step below publishes itself before it runs (`play-boot-phase.ts`).
1505
+ // A play boot that stops answering is otherwise indistinguishable from a
1506
+ // healthy tab — measured at N=20000, three consecutive silent timeouts.
1507
+ // `enterPlayMode` owns the begin/end pair; this function owns the marks.
1508
+ // Play must never start while the boot-time project bootstrap is still in
1509
+ // flight. Resolves immediately when no bootstrap is pending; gating HERE
1510
+ // rather than in the Play button covers every caller — UI, `vgai play` relay,
1511
+ // SDK.
1512
+ markPlayBootPhase('waiting for the project bootstrap to settle');
1513
+ await projectBootstrapSettled();
1514
+ // The play button is only shown while stopped, so a non-null _instance.session here is
1515
+ // stale — a previous play that didn't fully tear down (e.g. a re-entry race).
1516
+ // Clean it up so re-entering play mode always works.
1517
+ if (_instance.session) exitPlayMode();
1518
+ // After the stale-session cleanup: exitPlayMode clears the override, and
1519
+ // THIS run's override must survive that. A later Play button (no override)
1520
+ // remounts the source-declared key.
1521
+ _playSelectionOverride = selectionOverride;
1522
+ // Project detection settling is not the same thing as the editor's
1523
+ // authoring/ingest bootstrap settling: the latter waits for document/story
1524
+ // installation and can arrive seconds later. Reuse its deduplicated promise
1525
+ // here so a fast ▶ cannot enter generic first-party Play while the actual
1526
+ // ingest root is still mounting, then have that late boot mount steal and
1527
+ // pause the session. This call is a no-op for a project with no ingest route.
1528
+ if (await startAutoLaunchedAdapterPlay(_ctx.store)) return;
1529
+ // An ingest root that already has Edit pieces (a named world component, or
1530
+ // isolation-document scene tabs) holds its live mount back until here
1531
+ // (`ingest/deferred-ingest-play.ts`): Edit shows the constructs, and PLAY
1532
+ // is what constructs and runs the game. It mounts through the ordinary
1533
+ // ingest routes and owns the whole run, so this returns instead of also
1534
+ // booting a first-party composition.
1535
+ if (await mountDeferredIngestForPlay(_ctx.store)) return;
1536
+ // Capture this play's generation AFTER the stale-session cleanup (which
1537
+ // bumps the epoch via exitPlayMode). Any exitPlayMode — or a newer
1538
+ // enterPlayMode — during the async boot below invalidates this generation.
1539
+ const epoch = ++_playEpoch;
1540
+ _activePlaytest = playtest;
1541
+ // #146 — fence for the relay snapshot's `pageErrors` (see
1542
+ // `getPlayStartedAt`): errors logged before THIS play run are stale noise
1543
+ // to a probe reading "what went wrong during my run".
1544
+ _playStartedAtMs = Date.now();
1545
+ // A new run re-opens the window the previous exit closed.
1546
+ _playEndedAtMs = null;
1547
+ const { store } = _ctx;
1548
+
1549
+ // Nothing is pre-validated here: a TSX world root has no schema to check
1550
+ // against, so its failures surface as module/mount errors.
1551
+
1552
+ // Patch console → editorConsole tagged [game]
1553
+ patchConsole();
1554
+
1555
+ // Start log session and register sink for JSONL persistence. Start, every
1556
+ // flush, and end share ONE permanent promise tail. `exitPlayMode()` stays
1557
+ // synchronous for UI callers, but a restart's start is queued after the
1558
+ // prior run's final flush/end; otherwise that late end closes the NEW
1559
+ // server session and every later 200ms flush receives a 409. Keeping start
1560
+ // on the tail also handles Stop arriving while start itself is in flight:
1561
+ // Stop's end queues behind it, then the epoch check below prevents the
1562
+ // cancelled run from installing a sink/interval after Stop.
1563
+ const logSessionStart = _logFlushChain.then(() => startLogSession(runName));
1564
+ _logFlushChain = logSessionStart;
1565
+ await playBootStep('starting the play log session', logSessionStart);
1566
+ if (epoch !== _playEpoch) return;
1567
+ _pendingEntries = [];
1568
+ _lastPersistedDebugEventSeq = 0;
1569
+ editorConsole.setSink((entry: ConsoleEntry) => {
1570
+ // Play-log half: stamp {tick, simT} from the live
1571
+ // session's built-in `time` provider so log entries correlate
1572
+ // frame-exactly with `ctx.debug.emit` events. Best-effort — entries
1573
+ // logged before the session finishes booting (or after stop) carry no
1574
+ // stamp, and a throwing read must never break logging.
1575
+ let stamp: { tick: number; simT: number } | undefined;
1576
+ try {
1577
+ const time = _instance.session?.game?.systemAdapters.debug?.state('time') as
1578
+ | { tick?: number; simSeconds?: number }
1579
+ | undefined;
1580
+ if (typeof time?.tick === 'number' && typeof time.simSeconds === 'number') {
1581
+ stamp = { tick: time.tick, simT: time.simSeconds };
1582
+ }
1583
+ } catch {
1584
+ /* unstampable — keep the entry */
1585
+ }
1586
+ _pendingEntries.push({
1587
+ t: entry.timestamp,
1588
+ level: entry.level,
1589
+ msg: entry.message,
1590
+ ...(entry.source ? { source: entry.source } : {}),
1591
+ ...(entry.subsystem ? { sub: entry.subsystem } : {}),
1592
+ ...(entry.metadata ? { meta: entry.metadata } : {}),
1593
+ ...(stamp ?? {}),
1594
+ // The run's world/time-scale, when this writer genuinely knows them.
1595
+ ...playLogRunStamp(),
1596
+ });
1597
+ });
1598
+ _flushInterval = setInterval(() => {
1599
+ drainDebugEventsToPlayLog();
1600
+ if (_pendingEntries.length > 0) {
1601
+ const batch = _pendingEntries.splice(0);
1602
+ _logFlushChain = _logFlushChain.then(() => flushLogEntries(batch));
1603
+ }
1604
+ }, 200);
1605
+
1606
+ // Register Escape → stop play mode
1607
+ _escapeListener = (e: KeyboardEvent) => {
1608
+ if (e.key === 'Escape' && shouldEscapeStopPlay(e)) exitPlayMode();
1609
+ };
1610
+ // Bubble phase (default, no `capture`) is load-bearing: it lets overlay
1611
+ // handlers (menus/dialogs/popovers) run first and preventDefault() the
1612
+ // event before shouldEscapeStopPlay sees it.
1613
+ window.addEventListener('keydown', _escapeListener);
1614
+
1615
+ // Create game canvas inside the dedicated game container. While the game is
1616
+ // not running there IS no game viewport, so the document that owns that
1617
+ // container is installed here — the first thing this play does that the
1618
+ // author can see — and this waits for its panel to commit.
1619
+ markPlayBootPhase('opening the Game document');
1620
+ await editorHost().workspace.liveDocument.acquire();
1621
+ if (epoch !== _playEpoch) return;
1622
+ const gameContainer = editorHost().workspace.liveDocument.container();
1623
+ if (!gameContainer) {
1624
+ const msg = 'Play mode failed to start: the game container is not mounted in the workspace.';
1625
+ editorConsole.error(msg, 'play-mode');
1626
+ // Tear down the console patch + flush interval + escape listener set up
1627
+ // above, same as the other failure paths (ED8).
1628
+ exitPlayMode();
1629
+ // PD-1: throw, don't return — a bare return resolved the caller's promise
1630
+ // and the relay acked a play that never started.
1631
+ throw new Error(msg);
1632
+ }
1633
+ const w = gameContainer.clientWidth;
1634
+ const h = gameContainer.clientHeight;
1635
+ // First-party play is the same page-shaped problem ingest already solved:
1636
+ // a canvas game that does `document.body.appendChild(overlay)` with
1637
+ // `position:fixed;inset:0` (floor-sim's F1 dock, its html/body sheet)
1638
+ // would cover the editor. Hand it this pane as its page before any
1639
+ // project module evaluates — `game-realm-page.ts` reasons 1 and 2.
1640
+ if (!gameContainer.style.contain) {
1641
+ gameContainer.style.cssText += GAME_SURFACE_CONTAINMENT_CSS;
1642
+ }
1643
+ markGameCssScope(gameContainer);
1644
+ setGameSurface(gameContainer);
1645
+
1646
+ // The host's live transition (chrome dissolve, camera flight) — before the
1647
+ // play state flips; every exit path funnels through endPlayTransition.
1648
+ editorHost().viewport.transition.begin();
1649
+
1650
+ // setPlayState auto-switches to Game tab
1651
+ store.setPlayState('playing');
1652
+ // THE GAME TAKES THE KEYBOARD AS PLAY STARTS. Until the player clicks the
1653
+ // pane, focus stays on the transport button they pressed and the hotkey
1654
+ // scope on the workspace — so the next Enter or Space activates that
1655
+ // button and STOPS play, and a redeploy Enter "does nothing" (runhuman
1656
+ // pass 145; traced on production build 70: keydown target BUTTON, play
1657
+ // ends). Focus the pane and hand the scope to the viewport now, the same
1658
+ // state a click into the game produces.
1659
+ focusGameSurface();
1660
+ editorConsole.log('Play mode started', 'play-mode');
1661
+ // Apply the project's adapter-declared utilities after transient Play chrome settles.
1662
+ editorHost().viewport.transition.onSettled(() => {
1663
+ if (epoch === _playEpoch && store.playState === 'playing') revealWorkspacePlayUtilities();
1664
+ });
1665
+
1666
+ // WHAT THIS BOOT CREATED, BEFORE ANYTHING ELSE CAN REACH IT. `_instance.id`
1667
+ // and `_instance.session` are assigned only once the mount AND the bindings
1668
+ // after it have all succeeded, so a failure anywhere earlier leaves
1669
+ // `exitPlayMode` holding an EMPTY id — and an empty id is exactly what
1670
+ // `disposeInstanceRealm` early-returns on, while a bare `clearGameSurface()`
1671
+ // resets the DEFAULT realm's page instead of this mount's. These two locals
1672
+ // are the handles the catch below needs to reclaim a half-built play.
1673
+ let bootMountId: string | null = null;
1674
+ let bootedSession: GameSession | null = null;
1675
+ try {
1676
+ const project = getCurrentProject();
1677
+
1678
+ if (!project) {
1679
+ // PD-1: throw (the catch below logs + rolls back exactly as it does for
1680
+ // every other boot failure) instead of returning — a bare return let the
1681
+ // relay ack a play that never started.
1682
+ throw new Error('No project open — open or create a project first');
1683
+ }
1684
+
1685
+ const manifest = await playBootStep('fetching the project manifest', fetchGameManifest());
1686
+ // Publish the exact roots BEFORE entry resolution/mounting. The Game
1687
+ // document is already visible at this point; without a waiting fact its
1688
+ // empty area cannot say which declared roots it is waiting for.
1689
+ _hostMountedReadyRootIds = declaredRoots(manifest).map((root) => root.id);
1690
+ for (const rootId of _hostMountedReadyRootIds) {
1691
+ recordRootReadiness({
1692
+ rootId,
1693
+ mechanism: 'host-mount',
1694
+ source: 'declared',
1695
+ state: 'waiting',
1696
+ });
1697
+ }
1698
+ // Every project mounts through the same manifest composer.
1699
+ // Cardinality never selects a runtime, hierarchy, or persistence model.
1700
+ // Every root resolves from its own adapter-owned content.
1701
+ let session: GameSession;
1702
+ // PD-3 — open the cross-root module-split window BEFORE any root imports
1703
+ // its entry, and close it after every root has MOUNTED (a root that
1704
+ // imports lazily inside `mount()` has to be inside the window too). See
1705
+ // `project-module-split.ts` for why a mount-scoped window is the sound
1706
+ // way to count module instances.
1707
+ beginProjectModuleSplitWatch();
1708
+ const { entries, mountId } = await playBootStep(
1709
+ "resolving the project's root entries",
1710
+ resolveAllRootEntries(manifest, project.rootPath, {
1711
+ selectionOverride: selectionOverride ?? undefined,
1712
+ }),
1713
+ );
1714
+ editorConsole.log(
1715
+ `Resolved manifest composition (${declaredRoots(manifest).length} world${declaredRoots(manifest).length === 1 ? '' : 's'}): ` +
1716
+ declaredRoots(manifest)
1717
+ .map((w) => `${w.id} (${w.surface})`)
1718
+ .join(', '),
1719
+ 'play-mode',
1720
+ );
1721
+ // Play may have been exited while resolving — don't boot a runtime for
1722
+ // a play that is already over.
1723
+ if (epoch !== _playEpoch) return;
1724
+ // Re-read the container's real size instead of the `w`/`h` captured
1725
+ // BEFORE `store.setPlayState('playing')` above (that capture can race
1726
+ // the Game-tab layout switch and read 0x0). This still matters for the
1727
+ // INITIAL render-buffer resolution (`mountManifestRoots`'s own
1728
+ // `width`/`height` default otherwise falls back to the manifest's
1729
+ // `resolution`) even though it is no longer the ONLY thing standing
1730
+ // between a 0x0 mount and a correctly-laid-out canvas: the roots-path
1731
+ // canvas's on-screen SIZE is now fully CSS-container-driven
1732
+ // (`create-runtime.ts`'s `mountOneThreeRoot`, E4.R1 reopen fix), not
1733
+ // pinned to whatever it mounted at.
1734
+ const rw = gameContainer.clientWidth || w;
1735
+ const rh = gameContainer.clientHeight || h;
1736
+ setGameSurface(gameContainer, mountId);
1737
+ // The realm under `mountId` exists from here on (this call created its
1738
+ // page, and every project module served under this id resolves through
1739
+ // it). Record it so a failure below can reclaim it — see the catch.
1740
+ bootMountId = mountId;
1741
+ // NOT a `playBootStep`: an abandoned mount would leak a live session, so
1742
+ // this step has no stall guard — which is exactly why it needs a phase.
1743
+ // MEASURED at N=20000: the block that ate three command budgets started
1744
+ // here and ran for 199.5s with nothing anywhere able to name it.
1745
+ markPlayBootPhase('mounting the runtime roots');
1746
+ const { mountManifestRoots } = await import('@volter/game-runtime/runtime/mount-manifest');
1747
+ session = await mountManifestRoots({
1748
+ manifest,
1749
+ container: gameContainer,
1750
+ entries,
1751
+ width: rw,
1752
+ height: rh,
1753
+ // D15/T-D15.6 — `vgai play --seed`'s explicit config leg; `undefined`
1754
+ // (the overwhelmingly common case) leaves `mountManifestRoots`'s own
1755
+ // manifest/`?vgai-seed=` precedence untouched.
1756
+ seed: explicitSeed,
1757
+ playtest,
1758
+ });
1759
+ bootedSession = session;
1760
+
1761
+ // Play was exited (Stop/Escape) — or re-entered — while the runtime was
1762
+ // booting. This session belongs to a play that is already over: stop it
1763
+ // and bail WITHOUT adopting its scene into the store. Adopting here left
1764
+ // the editor showing an empty hierarchy in edit mode and leaked the
1765
+ // session (RAF loop, Rapier world, WebGL context).
1766
+ if (epoch !== _playEpoch) {
1767
+ session.stop();
1768
+ disposeInstanceRealmAfterStop(session, mountId, 'Cancelled instance');
1769
+ return;
1770
+ }
1771
+ // PD-3 — close the window and report LOUDLY. A split does not stop the
1772
+ // game (both copies run; they just disagree), so this is an error-level
1773
+ // report rather than a throw: the failure mode being fixed is SILENCE,
1774
+ // not a crash. `collectState` carries the same reports to `vgai status`,
1775
+ // and `editorConsole.error` reaches the editor console panel and the
1776
+ // play-log sink the CLI reads back.
1777
+ for (const split of endProjectModuleSplitWatch(project.rootPath)) {
1778
+ editorConsole.error(formatProjectModuleSplitMessage(split), 'play-mode');
1779
+ }
1780
+ // READINESS, ANSWERED BY THE HOST. A host-mounted (exported-composition)
1781
+ // root needs no game code to state when it is ready: the host RAN the
1782
+ // mount, and `mountManifestRoots` resolving IS that answer — including the
1783
+ // roots' own async setup, which it awaits. So every one of these roots
1784
+ // reports `declared`, and no measured wait stands anywhere behind them
1785
+ // (`readiness.ts`; published as `vgai status`'s `readiness` facet).
1786
+ for (const rootId of _hostMountedReadyRootIds) {
1787
+ recordRootReadiness({ rootId, mechanism: 'host-mount', source: 'declared', state: 'ready' });
1788
+ }
1789
+ // The game has mounted its ordinary native roots. Only now does the
1790
+ // boundary project app-owned reads, commands and input onto the session
1791
+ // protocol; no component participated in host registration.
1792
+ installAdapterRuntimeBindings(session.game);
1793
+ _instance.session = session;
1794
+ _instance.id = mountId;
1795
+ _instance.journalSession = playJournal(mountId, '').session;
1796
+ _instance.name = instanceNameAt(0);
1797
+ _instance.unregisterPerformanceSource?.();
1798
+ _instance.unregisterPerformanceSource = registerPerformanceSource({
1799
+ id: 'workspace:game',
1800
+ label: 'Game runtime',
1801
+ kind: 'game',
1802
+ instanceId: mountId,
1803
+ profiler: session.game.profiler,
1804
+ });
1805
+ clearRestartRequired();
1806
+ _instance.determinismDeclared = manifest.determinism?.seededRandom === true;
1807
+ // D16 parity with the standalone mount path (`mount-manifest.ts`'s
1808
+ // `setRoomDeclared` wiring): a project whose manifest declares a Colyseus
1809
+ // room must declare `locus: 'client' | 'server'` on every debug command
1810
+ // it registers — in editor play mode exactly like on the standalone
1811
+ // page. Idempotent for the manifest composer (`mountManifestRoots`
1812
+ // already set it).
1813
+ if (manifest.configurations.some((c) => c.kind === 'process')) {
1814
+ getDebugRegistry(session.game)?.setRoomDeclared(true);
1815
+ }
1816
+ notifySessionListeners();
1817
+ // D19: play edits are session-local regardless of world count.
1818
+ store.setPlayEditRegime('ephemeral');
1819
+
1820
+ // Swap store to game scene so hierarchy/inspector show game entities.
1821
+ // liveHierarchy: first-party play mode is the ONLY adoption path that may
1822
+ // synthesize descriptors for untagged runtime objects (anti-shim rule —
1823
+ // ingest/module mounts adopt foreign scenes and must never fabricate).
1824
+ _instance.presentation = editorHost().viewport.presentRoots(session.game.roots);
1825
+ markPlayBootPhase('installing play authoring for each root');
1826
+ await installPlayRootAuthoring(
1827
+ store,
1828
+ session.game.roots,
1829
+ manifest,
1830
+ _instance.presentation?.worldId ?? null,
1831
+ () => epoch === _playEpoch,
1832
+ );
1833
+ // Stop/Escape can land while the authoring install above is in flight, and
1834
+ // `exitPlayMode` is synchronous: by the time this resumes it has already
1835
+ // stopped this session and run its whole teardown. Everything below binds
1836
+ // the editor TO that session — instrument addressing, the play-session
1837
+ // readers, the input gate, the store subscription — so without this guard a
1838
+ // Stop during boot re-installs all of it onto a game that no longer exists.
1839
+ // The Game inspection subject is the visible half: its whole lifetime
1840
+ // contract is "no runtime, no subject", and a registration published here
1841
+ // has no exit left to drop it. Same check the two boot awaits above make.
1842
+ // The install itself takes the same generation guard (it awaits too, and
1843
+ // used to overwrite the just-restored edit authoring on the way out), so
1844
+ // by the time this line runs there is genuinely nothing left to undo.
1845
+ if (epoch !== _playEpoch) return;
1846
+
1847
+ // Wire physics sync THROUGH the first-party `RapierPhysicsAdapter`:
1848
+ // the gizmo path freezes a Rapier-owned body on beginEdit, commits the edited
1849
+ // pose each frame (the callback below), and unfreezes on release — so the
1850
+ // postPhysics writer doesn't snap the gizmo edit back. The editor speaks only
1851
+ // the `PhysicsAdapter` interface, never Rapier directly.
1852
+ // Install the GAME-scoped System-adapter aggregate (§7.1-3, probe1) — every
1853
+ // world's `mounted.systems` merged (first registration wins per key), NOT
1854
+ // just the default world's own copy (`firstParty(session).systems`, the
1855
+ // pre-fix read) — so a networking/etc. adapter registered from ANY world's
1856
+ // setup is visible to the editor's gizmo path + inspector panels.
1857
+ markPlayBootPhase('binding the editor to the running game');
1858
+ const systems = session.game.systemAdapters;
1859
+ // Register UNDER THIS MOUNT'S ID. Until now every mount registered as the
1860
+ // anonymous solo instance, so `systemsForInstance` had exactly one thing
1861
+ // it could ever resolve and the id it resolves BY was never produced —
1862
+ // the addressing layer was a switchboard with nothing plugged in.
1863
+ setActiveSystems(systems, mountId);
1864
+ setInspectedInstance(mountId);
1865
+ publishPlaySessionReaders();
1866
+ _instance.unsubscribeSystemAdapters?.();
1867
+ _instance.unsubscribeSystemAdapters =
1868
+ session.game.subscribeSystemAdapters?.(() => {
1869
+ updateInstanceSystems(session.game.systemAdapters, mountId);
1870
+ }) ?? null;
1871
+
1872
+ // T6.3: gate game input (raw `window`/`document` listeners in game code via
1873
+ // gated-globals, AND the default world's first-party InputManager) to only
1874
+ // fire while play is actually running AND the Game tab is the focused
1875
+ // viewport — so keystrokes typed into the editor (Scene tab, inspector
1876
+ // fields) don't leak into the running game.
1877
+ // The gate is FOCUS-AWARE (`instanceInputActive`): input flows only while
1878
+ // play runs, the Game tab is active, AND this instance holds keyboard focus.
1879
+ // For a single instance focus is always the primary, so this is identical to
1880
+ // the pre-split behaviour; with a split, exactly the focused seat is live.
1881
+ // Same id, and that is the point: the realm this mount's project modules
1882
+ // resolve their gated `window`/`document` through is keyed by the mount id
1883
+ // baked into their own urls, so the gate has to be registered under it or
1884
+ // the modules find the default realm's gate instead of their own.
1885
+ setGameInputGate(() => instanceInputActive(mountId), mountId);
1886
+ // Sync EVERY live instance's first-party InputManager from the play/tab/
1887
+ // focus predicate whenever the store changes (a tab switch flips the active
1888
+ // viewport for all of them). `resyncInstanceInputs` resolves each
1889
+ // instance's `game.input` defensively — a session whose game handle has no
1890
+ // first-party InputManager (an ingest mount, a partial double) has only the
1891
+ // raw window/document gate above.
1892
+ resyncInstanceInputs();
1893
+ _unsubStore = store.subscribe(resyncInstanceInputs);
1894
+
1895
+ // Node-id keyed only: `setEcsSyncTransform` hands this an editor node id
1896
+ // and a THREE `Transform`, which a display-keyed carrier has no values for.
1897
+ const physics = nodeKeyedPhysics(systems.physics);
1898
+ store.setEcsSyncTransform((id, obj) => {
1899
+ // `commit` refuses an id this adapter cannot resolve rather than
1900
+ // returning as if the write landed — so ask before driving it.
1901
+ if (!physics || physics.ownerOf(id) === 'unresolved') return;
1902
+ physics.commit(id, {
1903
+ position: obj.position.toArray() as [number, number, number],
1904
+ rotation: obj.quaternion.toArray() as [number, number, number, number],
1905
+ scale: obj.scale.toArray() as [number, number, number],
1906
+ });
1907
+ });
1908
+
1909
+ // Container may have been display:none when w/h were read above.
1910
+ // Now that the session exists, do one resize with the actual dimensions.
1911
+ // W2c: a device preset chosen BEFORE play must land its emulated DPR on
1912
+ // the fresh session too (mount pins `min(devicePixelRatio, 2)`), so an
1913
+ // active preset forces this resize even at an unchanged size.
1914
+ const emulatedDpr = deviceEmulatedPixelRatio();
1915
+ const actualW = gameContainer.clientWidth;
1916
+ const actualH = gameContainer.clientHeight;
1917
+ if (actualW > 0 && actualH > 0 && (actualW !== w || actualH !== h || emulatedDpr !== null)) {
1918
+ session.resize(actualW, actualH, emulatedDpr ?? undefined);
1919
+ }
1920
+
1921
+ // Game is fully up: let the entry transition cross-fade once the camera
1922
+ // flight lands. The live-camera getter makes the flight converge on the
1923
+ // game's ACTUAL render camera (follow rigs / CameraDescriptor components may
1924
+ // have moved it during boot), so the hand-off is pixel-continuous.
1925
+ editorHost().viewport.transition.ready(() => {
1926
+ const game = _instance.session?.game;
1927
+ if (!game) return null;
1928
+ for (const world of game.roots) {
1929
+ if (world.mounted.kind === 'three') return world.mounted.camera;
1930
+ }
1931
+ return null;
1932
+ });
1933
+ } catch (err) {
1934
+ const msg = `Play mode failed to start: ${err}`;
1935
+ editorConsole.error(msg, 'play-mode');
1936
+ // Reclaim what THIS boot built but never handed over. `_instance.id` is
1937
+ // still `''` for every failure before the hand-off, so `exitPlayMode`'s own
1938
+ // teardown cannot see this mount at all: its `disposeInstanceRealm('')`
1939
+ // early-returns and its `clearGameSurface()` resets the DEFAULT realm.
1940
+ // Unconditional on the epoch — this realm and this session belong to THIS
1941
+ // boot and to nothing else, so a Stop that landed mid-boot (which skips the
1942
+ // `exitPlayMode` below) must not strand them either.
1943
+ if (bootMountId !== null && _instance.id !== bootMountId) {
1944
+ if (bootedSession) {
1945
+ try {
1946
+ bootedSession.stop();
1947
+ } catch (stopErr) {
1948
+ editorConsole.error(`Error stopping the failed play session: ${stopErr}`, 'play-mode');
1949
+ }
1950
+ disposeInstanceRealmAfterStop(bootedSession, bootMountId, 'Failed instance');
1951
+ } else {
1952
+ disposeInstanceRealm(bootMountId, 'Failed instance');
1953
+ }
1954
+ }
1955
+ // Roll back only if THIS play is still the live generation — if it was
1956
+ // already exited during boot (epoch moved on), a second exitPlayMode here
1957
+ // could tear down a newer play that started in the meantime.
1958
+ if (epoch === _playEpoch) exitPlayMode();
1959
+ throw new Error(msg);
1960
+ }
1961
+ }
1962
+
1963
+ /**
1964
+ * Mount an ADDITIONAL instance of this project beside the primary one — the
1965
+ * cardinality half of multiplayer authoring (see the `_additional` doc).
1966
+ *
1967
+ * Valid only while the primary is playing. The returned id is the instance's
1968
+ * mount id (what `?vgai-mount=` carries and what `game.instance(id)`
1969
+ * addresses). This does the INSTANCE subset of `enterPlayModeInner` and none
1970
+ * of its session/focus work: it resolves its own mount epoch, mounts the roots
1971
+ * into `container`, registers a performance source and its System adapters
1972
+ * under the mount id, and leaves editor focus, the store scene, authoring, the
1973
+ * console patch and the camera transition entirely to the primary.
1974
+ */
1975
+ export async function mountAdditionalInstance(
1976
+ container: HTMLElement,
1977
+ name?: string,
1978
+ ): Promise<string> {
1979
+ if (!_ctx) throw new Error('Cannot mount an additional instance: play mode is not bound.');
1980
+ if (!_instance.session) {
1981
+ throw new Error('Cannot mount an additional instance: no primary play session is running.');
1982
+ }
1983
+ // This function AWAITS (manifest fetch, entry resolution, the runtime mount);
1984
+ // play can STOP mid-flight (exitPlayMode bumps `_playEpoch` and nulls
1985
+ // `_instance.session`). The start guard above is stale by the time the awaits
1986
+ // finish, so capture the epoch and re-check it after mounting — otherwise
1987
+ // wiring this instance dereferences the torn-down primary (`_instance.session`
1988
+ // is null → "Cannot read properties of null (reading 'game')").
1989
+ const epoch = _playEpoch;
1990
+ const project = getCurrentProject();
1991
+ if (!project) throw new Error('Cannot mount an additional instance: no project is open.');
1992
+ const manifest = await fetchGameManifest();
1993
+
1994
+ // Its OWN mount epoch → its own id and its own per-url module graph. No
1995
+ // `editorPreview`: an additional instance is not the focused editor
1996
+ // viewport, so it resolves without the viewport-camera injection the primary
1997
+ // threads in. The split watch is mount-scoped and these mounts are
1998
+ // sequential, so opening one around this mount cannot overlap the primary's.
1999
+ beginProjectModuleSplitWatch();
2000
+ const { entries, mountId } = await resolveAllRootEntries(manifest, project.rootPath, {
2001
+ selectionOverride: _playSelectionOverride ?? undefined,
2002
+ });
2003
+ if (!container.style.contain) {
2004
+ container.style.cssText += GAME_SURFACE_CONTAINMENT_CSS;
2005
+ }
2006
+ markGameCssScope(container);
2007
+ setGameSurface(container, mountId);
2008
+ const { mountManifestRoots } = await import('@volter/game-runtime/runtime/mount-manifest');
2009
+ const session = await mountManifestRoots({
2010
+ manifest,
2011
+ container,
2012
+ entries,
2013
+ width: container.clientWidth,
2014
+ height: container.clientHeight,
2015
+ playtest: _activePlaytest,
2016
+ });
2017
+ for (const split of endProjectModuleSplitWatch(project.rootPath)) {
2018
+ editorConsole.error(formatProjectModuleSplitMessage(split), 'play-mode');
2019
+ }
2020
+
2021
+ // Play stopped (or restarted) while we were mounting: the primary this
2022
+ // instance would attach beside is gone. Abandon the freshly-mounted session
2023
+ // cleanly rather than wiring it against a null primary. Returning the id (not
2024
+ // throwing) keeps the caller's teardown a no-op — the instance was never
2025
+ // pushed to `_additional`, so its per-viewport unmount finds nothing.
2026
+ if (epoch !== _playEpoch || !_instance.session) {
2027
+ try {
2028
+ session.stop();
2029
+ } catch {
2030
+ // Best-effort teardown of an instance nothing will ever address.
2031
+ }
2032
+ disposeInstanceRealmAfterStop(session, mountId, name?.trim() || 'Cancelled instance');
2033
+ return mountId;
2034
+ }
2035
+
2036
+ installAdapterRuntimeBindings(session.game);
2037
+
2038
+ const inst = createPlayInstance();
2039
+ inst.id = mountId;
2040
+ // Its label: the caller's name, else the "Instance N" default for its position.
2041
+ inst.name = name?.trim() || instanceNameAt(_additional.length + 1);
2042
+ inst.container = container;
2043
+ inst.session = session;
2044
+ inst.unregisterPerformanceSource = registerPerformanceSource({
2045
+ id: `workspace:game:${mountId}`,
2046
+ label: `Game runtime (instance ${mountId})`,
2047
+ kind: 'game',
2048
+ instanceId: mountId,
2049
+ profiler: session.game.profiler,
2050
+ });
2051
+ const systems = session.game.systemAdapters;
2052
+ setActiveSystems(systems, mountId);
2053
+ inst.unsubscribeSystemAdapters =
2054
+ session.game.subscribeSystemAdapters?.(() => {
2055
+ updateInstanceSystems(session.game.systemAdapters, mountId);
2056
+ }) ?? null;
2057
+ // FOCUS-AWARE input, exactly like the primary: this instance takes the shared
2058
+ // keyboard only while it holds focus. It mounts UNFOCUSED (the primary keeps
2059
+ // focus), so its gate is closed and its InputManager is disabled until a click
2060
+ // on its viewport routes focus here (`setFocusedInstance`). This is what stops
2061
+ // the pre-focus bug where every seat took the same keystroke, AND what lets
2062
+ // you drive a chosen seat manually rather than only via autoplay.
2063
+ setGameInputGate(() => instanceInputActive(mountId), mountId);
2064
+ _additional.push(inst);
2065
+ // Now that it is in `_additional`, sync its (and every) InputManager to the
2066
+ // current focus — disabled here, since the primary is focused.
2067
+ resyncInstanceInputs();
2068
+
2069
+ // `setActiveSystems` moved editor focus (`getActiveSystems`) onto the newer
2070
+ // mount. The user is still authoring the PRIMARY, so restore its focus
2071
+ // without disturbing either instance's addressed registration.
2072
+ const primarySystems = _instance.session.game.systemAdapters;
2073
+ setActiveSystems(primarySystems, _instance.id);
2074
+
2075
+ notifySessionListeners();
2076
+ return mountId;
2077
+ }
2078
+
2079
+ /** Live additional-instance ids, in mount order — for the Game view and tests. */
2080
+ export function additionalInstanceIds(): string[] {
2081
+ return _additional.map((inst) => inst.id);
2082
+ }
2083
+
2084
+ /** Every live instance as `{ id, name }`, primary first — what `list-instances`
2085
+ * surfaces so a driver/HUD can show which mount is "Instance 2" without the
2086
+ * instance layer learning a game-specific role. Only genuinely-mounted
2087
+ * instances (a real id) are included. */
2088
+ export function instanceEntries(): { id: string; name: string }[] {
2089
+ const entries: { id: string; name: string }[] = [];
2090
+ if (_instance.id) entries.push({ id: _instance.id, name: _instance.name });
2091
+ for (const inst of _additional) if (inst.id) entries.push({ id: inst.id, name: inst.name });
2092
+ return entries;
2093
+ }
2094
+
2095
+ function instanceNameForId(id: string): string | undefined {
2096
+ if (_instance.id === id) return _instance.name;
2097
+ return _additional.find((instance) => instance.id === id)?.name;
2098
+ }
2099
+
2100
+ function disposeInstanceRealm(id: string, name: string): void {
2101
+ // An empty id means this run never resolved a composition, so it never
2102
+ // created a realm of its own. (A failed boot that DID get that far reclaims
2103
+ // its realm through `bootMountId` in `enterPlayModeInner`'s catch, which is
2104
+ // why this guard can stay.)
2105
+ if (!id) return;
2106
+ clearGameSurface(id);
2107
+ reclaimGameRealm(id, name);
2108
+ }
2109
+
2110
+ /** A native React reconciler may finish component effect cleanup after the
2111
+ * synchronous stop() call returns. Audit the game realm only once every root
2112
+ * says that cleanup has landed; otherwise the editor races the owner, reclaims
2113
+ * listeners itself, and files a false leak warning. */
2114
+ function disposeInstanceRealmAfterStop(session: GameSession, id: string, name: string): void {
2115
+ void session.stopComplete.then(() => disposeInstanceRealm(id, name));
2116
+ }
2117
+
2118
+ /** Tear down ONE additional instance by its mount id. Idempotent: a no-op if
2119
+ * the id is not (or no longer) a live additional instance, so the Game view's
2120
+ * per-viewport unmount and `exitPlayMode`'s bulk teardown can both fire for
2121
+ * the same instance without a double stop. */
2122
+ export function unmountAdditionalInstance(id: string): void {
2123
+ const idx = _additional.findIndex((inst) => inst.id === id);
2124
+ if (idx < 0) return;
2125
+ const [inst] = _additional.splice(idx, 1);
2126
+ if (!inst) return;
2127
+ inst.unsubscribeSystemAdapters?.();
2128
+ inst.unsubscribeSystemAdapters = null;
2129
+ const stoppingSession = inst.session;
2130
+ try {
2131
+ stoppingSession?.stop();
2132
+ } catch (err) {
2133
+ editorConsole.error(`Error stopping additional instance ${inst.id}: ${err}`, 'play-mode');
2134
+ }
2135
+ inst.unregisterPerformanceSource?.();
2136
+ setActiveSystems(null, inst.id);
2137
+ if (stoppingSession) disposeInstanceRealmAfterStop(stoppingSession, inst.id, inst.name);
2138
+ else disposeInstanceRealm(inst.id, inst.name);
2139
+ inst.container = null;
2140
+ inst.session = null;
2141
+ // If the removed instance held keyboard focus, focus falls back to the primary
2142
+ // (`focusedInstanceId` already resolves a stale id to it); re-sync so the
2143
+ // primary's InputManager re-enables, and notify the viewport highlight.
2144
+ if (_focusedInstanceId === id) {
2145
+ _focusedInstanceId = null;
2146
+ resyncInstanceInputs();
2147
+ notifyFocusedInstance();
2148
+ }
2149
+ notifySessionListeners();
2150
+ }
2151
+
2152
+ /** Tear down every additional instance. Called by `exitPlayMode`; each stops
2153
+ * its session and stops being addressable, symmetrically with the primary. */
2154
+ function unmountAdditionalInstances(): void {
2155
+ // Snapshot ids first — `unmountAdditionalInstance` mutates `_additional`.
2156
+ for (const id of additionalInstanceIds()) unmountAdditionalInstance(id);
2157
+ }
2158
+
2159
+ /**
2160
+ * The desired split-screen layout, as the LABELS of the instances to show
2161
+ * (index 0 is the primary). This is the single source of truth for both how
2162
+ * many viewports the Game view renders and what each is named — a name is a
2163
+ * hint (see `PlayInstance.name`), never the mechanism. `[]` means no split
2164
+ * (one instance, no badges). Driven by `set-instance-count` /
2165
+ * `editor.instances(n | names[])`; reset on exit so a fresh play starts single.
2166
+ */
2167
+ let _instanceNames: string[] = [];
2168
+ const extraInstanceListeners = new Set<() => void>();
2169
+
2170
+ /** The default label for the instance at `index` (0 = primary). When the
2171
+ * game's RUNTIME `NetworkingAdapter` exposes `getPlayerIdentity` (an OPTIONAL
2172
+ * capability a game implements only if it has a real, game-defined identity —
2173
+ * the editor never fabricates one), the PRIMARY instance reads that name off
2174
+ * the adapter instead of the generic default. The editor reads the adapter,
2175
+ * never infers: no adapter, no identity member, or a non-multiplayer game all
2176
+ * fall back to "Instance N". The edit-time adapter deliberately provides no
2177
+ * identity, so before Play every mount is "Instance N" unless a running game
2178
+ * supplies one. Only the primary (index 0) maps to the identity; the extras
2179
+ * are additional local seats and keep the numbered default. */
2180
+ function defaultInstanceName(index: number): string {
2181
+ if (index === 0) {
2182
+ const authored = getActiveNetworking()?.getPlayerIdentity?.()?.name?.trim();
2183
+ if (authored) return authored;
2184
+ }
2185
+ return `Instance ${index + 1}`;
2186
+ }
2187
+
2188
+ /** How many instances BESIDE the primary the Game view should show. */
2189
+ export function desiredExtraInstances(): number {
2190
+ return _instanceNames.length === 0 ? 0 : _instanceNames.length - 1;
2191
+ }
2192
+
2193
+ /** The label for the instance at `index` (0 = primary) — the explicit name if
2194
+ * one was given, else the "Instance N" default. */
2195
+ export function instanceNameAt(index: number): string {
2196
+ return _instanceNames[index] ?? defaultInstanceName(index);
2197
+ }
2198
+
2199
+ export function subscribeExtraInstances(listener: () => void): () => void {
2200
+ extraInstanceListeners.add(listener);
2201
+ return () => extraInstanceListeners.delete(listener);
2202
+ }
2203
+
2204
+ function notifyExtraInstances(): void {
2205
+ for (const listener of extraInstanceListeners) listener();
2206
+ }
2207
+
2208
+ /** Set how many EXTRA instances (beyond the primary) the Game view shows, with
2209
+ * default "Instance N" labels. Clamped at 0; the view reconciles to match. */
2210
+ export function setDesiredExtraInstances(count: number): void {
2211
+ const extra = Math.max(0, Math.floor(count));
2212
+ setDesiredInstanceNames(
2213
+ extra === 0 ? [] : Array.from({ length: extra + 1 }, (_v, i) => defaultInstanceName(i)),
2214
+ );
2215
+ }
2216
+
2217
+ /** Set the split layout by explicit labels (index 0 = primary). `names.length`
2218
+ * is the TOTAL instance count; `[]` or a single name collapses to no split. */
2219
+ export function setDesiredInstanceNames(names: string[]): void {
2220
+ const next =
2221
+ names.length <= 1 ? [] : names.map((n, i) => (n?.trim() ? n.trim() : defaultInstanceName(i)));
2222
+ if (next.length === _instanceNames.length && next.every((n, i) => n === _instanceNames[i]))
2223
+ return;
2224
+ _instanceNames = next;
2225
+ notifyExtraInstances();
2226
+ }
2227
+
2228
+ /**
2229
+ * Exit play mode: stop game, remove canvas, re-enable editor.
2230
+ */
2231
+ export function exitPlayMode(): void {
2232
+ // Cancel an in-flight boot even if the editor has not bound Play yet.
2233
+ _playEpoch++;
2234
+ cancelPendingWorkspacePlayUtilities();
2235
+ if (!_ctx) return;
2236
+ // The SAFETY NET for this run's recording, not its normal close.
2237
+ //
2238
+ // The relayed `stop` awaits `endPlayRecording('stop')` before calling this
2239
+ // (`command-listener.ts`), which is the ordered close. What reaches here
2240
+ // unclosed is every OTHER exit — the Play bar's Stop button, Escape, a boot
2241
+ // failure's rollback — and this function is synchronous, so the finalize can
2242
+ // only be fired, not awaited. It is idempotent and a no-op when the ordered
2243
+ // close already ran.
2244
+ void endPlayRecording('teardown');
2245
+ // No runtime, no Game subject and no `play` props — unconditionally, and
2246
+ // before the ingest early-return below, so no exit path can leave the play
2247
+ // surface's empty state (or a contribution) holding a game that has stopped.
2248
+ dropPlaySessionReaders();
2249
+ _playSelectionOverride = null;
2250
+ // A play run this editor handed to the ingest routes is theirs to end —
2251
+ // there is no first-party session, adopted scene or root authoring here to
2252
+ // tear down, and running the rest of this function over one would clear
2253
+ // state the ingest teardown owns. Returns false for every other run.
2254
+ if (exitDeferredIngestPlay(_ctx.store)) return;
2255
+ _activePlaytest = null;
2256
+ // Keyboard focus resets so the next play starts with the primary focused.
2257
+ _focusedInstanceId = null;
2258
+ notifyFocusedInstance();
2259
+ // PD-1: `_playStartedAtMs` is NOT cleared here — see its declaration. The
2260
+ // next enterPlayMode re-stamps it; clearing it on exit blinded every
2261
+ // `pageErrors` reader to the failure that caused the exit.
2262
+ const { store } = _ctx;
2263
+ // Drain while the session/debug registry still exists. Stopping disposes
2264
+ // it, and the last pickup/win event is often emitted inside the final 200ms
2265
+ // interval before Stop.
2266
+ drainDebugEventsToPlayLog();
2267
+
2268
+ // Additional instances stop WITH the session — the primary owns the play
2269
+ // lifecycle, so its exit ends every instance mounted beside it. Before the
2270
+ // primary teardown so their sessions/registrations are gone first. Reset the
2271
+ // desired split too, so the next play starts single-view.
2272
+ setDesiredExtraInstances(0);
2273
+ unmountAdditionalInstances();
2274
+
2275
+ // Reverse the entry transition first: restore the pre-play editor camera,
2276
+ // re-materialize the dock chrome, re-enable layout persistence. Idempotent
2277
+ // and safe on every exit path (Stop, Escape, boot failure, restart).
2278
+ editorHost().viewport.transition.end();
2279
+
2280
+ // Restore the viewport host's prior authored subject before stopping (the
2281
+ // runtime owns the presented native tree and may dispose it during stop).
2282
+ store.setEcsSyncTransform(null);
2283
+ // Unregister everything this mount registered under ITS id, so a stopped
2284
+ // instance stops being addressable instead of lingering as a bag that still
2285
+ // answers commands. Captured before anything clears it, because the gate is
2286
+ // released further down. `''` when play never got as far as resolving a
2287
+ // composition — which is exactly the id such a run would have used, if any.
2288
+ const mountId = _instance.id;
2289
+ const instanceName = _instance.name || 'Game instance';
2290
+ _instance.id = '';
2291
+ _instance.unsubscribeSystemAdapters?.();
2292
+ _instance.unsubscribeSystemAdapters = null;
2293
+ setActiveSystems(null, mountId);
2294
+ _instance.presentation?.dispose();
2295
+ _instance.presentation = null;
2296
+ exitPlayRootAuthoring(store);
2297
+ restorePriorAuthoring();
2298
+ // Readout-only — clear the regime the moment play is no longer active.
2299
+ store.setPlayEditRegime(null);
2300
+
2301
+ // Always null _instance.session even if stop() throws — otherwise enterPlayMode's
2302
+ // `if (_instance.session) return` guard would permanently block re-entering play mode.
2303
+ const stoppingSession = _instance.session;
2304
+ if (stoppingSession) {
2305
+ try {
2306
+ stoppingSession.stop();
2307
+ } catch (err) {
2308
+ editorConsole.error(`Error stopping play session: ${err}`, 'play-mode');
2309
+ } finally {
2310
+ _instance.unregisterPerformanceSource?.();
2311
+ _instance.unregisterPerformanceSource = null;
2312
+ _instance.session = null;
2313
+ _instance.determinismDeclared = false;
2314
+ notifySessionListeners();
2315
+ }
2316
+ }
2317
+ if (stoppingSession) disposeInstanceRealmAfterStop(stoppingSession, mountId, instanceName);
2318
+ else disposeInstanceRealm(mountId, instanceName);
2319
+
2320
+ // RESOURCE OWNERSHIP: exactly the roots THIS play run recorded, dropped by
2321
+ // this run's one teardown. Per-id rather than a blanket clear, because a
2322
+ // deferred ingest root mounted beside this session records its own readiness
2323
+ // through its own lifecycle and its answer is still true.
2324
+ for (const rootId of _hostMountedReadyRootIds) clearRootReadiness(rootId);
2325
+ _hostMountedReadyRootIds = [];
2326
+
2327
+ // Cleanup subscriptions
2328
+ _unsubStore?.();
2329
+ _unsubStore = null;
2330
+
2331
+ // T6.3: no game running — game input (raw window/document listeners AND the
2332
+ // InputManager sync above) should never be suppressed again until the next
2333
+ // play session re-gates it. The mount's own gate is DROPPED rather than set
2334
+ // to always-true: its id is never reused, so overwriting would retain one
2335
+ // dead closure per play run.
2336
+ setGameInputGate(() => true);
2337
+ clearGameSurface();
2338
+
2339
+ _instance.resizeObserver?.disconnect();
2340
+ _instance.resizeObserver = null;
2341
+
2342
+ if (_escapeListener) {
2343
+ window.removeEventListener('keydown', _escapeListener);
2344
+ _escapeListener = null;
2345
+ }
2346
+
2347
+ // Log before clearing sink so this message gets persisted
2348
+ editorConsole.log('Play mode stopped', 'play-mode');
2349
+
2350
+ // PD-3 — a stopped session has no live roots to disagree, so its split
2351
+ // reports must not outlive it (the PD-1 lesson: a diagnostic that cannot
2352
+ // go back to healthy is worse than none).
2353
+ clearProjectModuleSplitReports();
2354
+
2355
+ // Flush remaining log entries and end session
2356
+ if (_flushInterval) {
2357
+ clearInterval(_flushInterval);
2358
+ _flushInterval = null;
2359
+ }
2360
+ // The interval is only the steady-state drain. Stop is a boundary of its
2361
+ // own: debug events emitted after the last 200ms turn are still evidence and
2362
+ // must join the same awaited queue before the server closes the session.
2363
+ drainDebugEventsToPlayLog();
2364
+ editorConsole.setSink(null);
2365
+ if (_pendingEntries.length > 0) {
2366
+ const batch = _pendingEntries.splice(0);
2367
+ _logFlushChain = _logFlushChain.then(() => flushLogEntries(batch));
2368
+ }
2369
+ // The server has one active log file. End it only after every queued batch
2370
+ // has reached the append endpoint, otherwise Stop can race the final fetch
2371
+ // and silently discard the most useful end-of-run evidence.
2372
+ _logFlushChain = _logFlushChain.then(() => endLogSession());
2373
+
2374
+ unpatchConsole();
2375
+
2376
+ // setPlayState('stopped') auto-switches back to the Edit tab, and its
2377
+ // live → stopped edge is what normally closes the Game document. A play
2378
+ // that FAILED before ever flipping the store to 'playing' never produces
2379
+ // that edge, so release directly too — `releaseGameDocument` is the one
2380
+ // idempotent teardown path either way (`game-document.ts`).
2381
+ store.setPlayState('stopped');
2382
+ editorHost().workspace.liveDocument.release();
2383
+ // LAST — the run's error window closes only once teardown is done, so every
2384
+ // error this teardown itself logged still belongs to the run that caused it
2385
+ // (PD-1). Errors after this instant are the SESSION's, and the
2386
+ // `sessionErrors` facet is what reports them.
2387
+ //
2388
+ // Guarded on the window actually being OPEN: this function is idempotent and
2389
+ // callers invoke it unconditionally (the `stop` command relays here even when
2390
+ // nothing is playing, and the lease-void teardown can fire while stopped). An
2391
+ // unconditional stamp would move a CLOSED window's end forward on every
2392
+ // redundant stop, silently reclassifying the session errors logged since the
2393
+ // real end back into the play-fenced facets — which the CLI only prints while
2394
+ // play is live. That is the exact hiding this window exists to end.
2395
+ if (_playStartedAtMs !== null && _playEndedAtMs === null) _playEndedAtMs = Date.now();
2396
+ }
2397
+
2398
+ /**
2399
+ * Pause play mode: freeze game, allow editor inspection.
2400
+ */
2401
+ export function pausePlayMode(): void {
2402
+ if (!_instance.session || !_ctx) return;
2403
+ // Freeze EVERY seat, not just the primary — a paused split with the extras
2404
+ // still ticking is not paused.
2405
+ for (const inst of allLiveInstances()) inst.session?.pause();
2406
+ _ctx.store.setPlayState('paused');
2407
+
2408
+ editorConsole.log('Play mode paused', 'play-mode');
2409
+ }
2410
+
2411
+ /**
2412
+ * Resume play mode from pause.
2413
+ */
2414
+ export function resumePlayMode(): void {
2415
+ if (!_instance.session || !_ctx) return;
2416
+
2417
+ for (const inst of allLiveInstances()) inst.session?.resume();
2418
+ _ctx.store.setPlayState('playing');
2419
+
2420
+ editorConsole.log('Play mode resumed', 'play-mode');
2421
+ }
2422
+
2423
+ /**
2424
+ * Step one fixed-timestep frame while paused.
2425
+ */
2426
+ export function stepPlayMode(): void {
2427
+ if (!_instance.session) return;
2428
+ for (const inst of allLiveInstances()) inst.session?.step();
2429
+ }
2430
+
2431
+ // --- Console patching ---
2432
+
2433
+ function patchConsole(): void {
2434
+ // LAYERING (the full note lives on `installEditorConsoleCapture` in
2435
+ // `editor-console.ts` — read the two together). The session-lifetime capture
2436
+ // installed at editor boot is what `console.error`/`console.warn` currently
2437
+ // ARE, so the wrappers below sit OUTSIDE it and call through to it. While
2438
+ // this run is live THIS patch owns the funnel and tags entries 'game' — the
2439
+ // more specific answer — so the boot capture must not also push the same
2440
+ // message as 'editor'. Suspend it for exactly the lifetime of this patch.
2441
+ suspendEditorConsoleCapture();
2442
+ _originalConsoleLog = console.log;
2443
+ _originalConsoleInfo = console.info;
2444
+ _originalConsoleWarn = console.warn;
2445
+ _originalConsoleError = console.error;
2446
+
2447
+ const capture = (level: ConsoleEntry['level'], args: readonly unknown[]) => {
2448
+ if (_engineLogActive) return;
2449
+ const instanceId = currentGameRealmMountId();
2450
+ editorConsole.logStructured(
2451
+ level,
2452
+ formatConsoleArgs(args),
2453
+ 'game',
2454
+ undefined,
2455
+ instanceId
2456
+ ? { instanceId, instanceName: instanceNameForId(instanceId) ?? `Instance ${instanceId}` }
2457
+ : undefined,
2458
+ );
2459
+ };
2460
+
2461
+ console.log = (...args: unknown[]) => {
2462
+ _originalConsoleLog?.apply(console, args);
2463
+ capture('info', args);
2464
+ };
2465
+ console.info = (...args: unknown[]) => {
2466
+ _originalConsoleInfo?.apply(console, args);
2467
+ capture('info', args);
2468
+ };
2469
+ console.warn = (...args: unknown[]) => {
2470
+ _originalConsoleWarn?.apply(console, args);
2471
+ capture('warn', args);
2472
+ };
2473
+ console.error = (...args: unknown[]) => {
2474
+ _originalConsoleError?.apply(console, args);
2475
+ capture('error', args);
2476
+ };
2477
+ }
2478
+
2479
+ function unpatchConsole(): void {
2480
+ // Hand the funnel back to the boot-installed session-lifetime capture
2481
+ // (`editor-console.ts`) — restoring the saved originals below re-exposes its
2482
+ // wrappers, and this is what lets them push to the store again.
2483
+ resumeEditorConsoleCapture();
2484
+ if (_originalConsoleLog) console.log = _originalConsoleLog;
2485
+ if (_originalConsoleInfo) console.info = _originalConsoleInfo;
2486
+ if (_originalConsoleWarn) console.warn = _originalConsoleWarn;
2487
+ if (_originalConsoleError) console.error = _originalConsoleError;
2488
+ _originalConsoleLog = null;
2489
+ _originalConsoleInfo = null;
2490
+ _originalConsoleWarn = null;
2491
+ _originalConsoleError = null;
2492
+ }
2493
+
2494
+ // THE PLAY LANE, as the host sees it: the host stops asking this module by
2495
+ // name and asks its live-session registry instead. Registered at the module's
2496
+ // LOAD, which the contribution loader performs for every module this package
2497
+ // declares — so the lane exists from the moment a project that depends on
2498
+ // `@volter/editor-game` opens, long before anything asks to play.
2499
+ editorHost().live.register({
2500
+ id: 'play',
2501
+ priority: 0,
2502
+ mounted: isPlayModeActive,
2503
+ playing: isPlayModeActive,
2504
+ stop: exitPlayMode,
2505
+ instanceContainer: (id) => getInstanceContainer(id),
2506
+ startedAt: getPlayStartedAt,
2507
+ endedAt: getPlayEndedAt,
2508
+ restartRequired: getRestartRequiredReason,
2509
+ restart: () => void enterPlayMode(),
2510
+ // The live remount `open-scene` asks for: a scene entry opened while Play
2511
+ // runs re-serves the realm rather than restarting the session.
2512
+ remount: async (args) => {
2513
+ try {
2514
+ await enterPlayMode(undefined, undefined, undefined, args);
2515
+ if (!isPlayModeActive()) {
2516
+ return {
2517
+ ok: false,
2518
+ error: 'Play did not start: the remount was superseded before it finished booting.',
2519
+ };
2520
+ }
2521
+ return { ok: true };
2522
+ } catch (error) {
2523
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
2524
+ }
2525
+ },
2526
+ });
2527
+
2528
+ // What only Play knows of the state report (`host.session.reportFacet`):
2529
+ // `vgai status --json` spreads these beside the host's own fields.
2530
+ editorHost().session.reportFacet(() => ({
2531
+ // The live loop's time-scale, so `play.status` reports the real applied
2532
+ // value after a `set-time-scale` instead of an honest-gap null.
2533
+ timeScale: getPlayRuntimeAccess()?.loop.timeScale ?? null,
2534
+ // Issue #175 — the REAL loop liveness (`GameLoop.liveness`), NOT the
2535
+ // store's playState: that stays 'playing' even while the host loop reports
2536
+ // `loop-starved` (no recent rAF progress — see game-loop.ts). Reading only
2537
+ // playState is exactly the gap that let a frozen game report as healthy
2538
+ // (measured: playState "playing", sim speed 0.00x over 32.9s wall). `null`
2539
+ // outside play mode — there is no loop to report on.
2540
+ loopLiveness: getPlayRuntimeAccess()?.loop.liveness ?? null,
2541
+ // The pending-restart reason the PlayBar's Restart button is currently
2542
+ // surfacing (source changed while the game is running — e.g. an R3F entry
2543
+ // write-back, a registry.ts edit), or null when the running session is
2544
+ // fresh. Agents need the same "your running game is stale, restart play"
2545
+ // signal humans get.
2546
+ restartRequired: getRestartRequiredReason(),
2547
+ // D15/T-D15.6: the live session's ctx.random root seed (real — reflects the
2548
+ // boot seed or the last successful play.seed.set), and whether the running
2549
+ // project declares determinism.seededRandom at all. Both null/false when no
2550
+ // first-party Game is running.
2551
+ seed: getPlayRuntimeAccess()?.random?.seed ?? null,
2552
+ deterministic: getPlayRuntimeAccess()?.determinismDeclared ?? false,
2553
+ }));