@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,335 @@
1
+ /**
2
+ * EVERY RELAYED PLAY RECORDS, AND AN IDLE RUN STOPS ITSELF.
3
+ *
4
+ * The defect this closes is not a missing feature — the recorder
5
+ * (`gameplay-recording.ts`) already existed, behind `vgai eval
6
+ * 'editor.recording.start()'`. The defect is that verifying a game through a
7
+ * single screenshot answers a temporal question by luck, and a capability an
8
+ * agent has to remember to switch on is one it does not use. So there is no
9
+ * flag: `vgai play` starts a recording, full stop, and the evidence exists
10
+ * whether or not anyone thought to ask for it.
11
+ *
12
+ * WHY THE RELAYED PLAY AND NOT THE PLAY BUTTON. The binding is in
13
+ * `play.command.ts`'s `'play'` handler, so it covers `vgai play`, the SDK's
14
+ * `play.start`, and nothing else. Clicking Play in the editor is a HUMAN at
15
+ * the surface: they are the witness, the idle rule below ("no session
16
+ * commands and no player input") is meaningless for them, and auto-stopping
17
+ * someone who is watching a cutscene would be a bug, not evidence.
18
+ *
19
+ * WHY AUTO-STOP AT ALL. A WebM is only readable once the recorder finalizes
20
+ * it, so an abandoned play — the agent's turn ended, the harness died, the
21
+ * script threw — leaves a growing file that nobody can open. The idle window
22
+ * is what converts an abandoned run into finished evidence.
23
+ *
24
+ * WHAT RECORDING DOES *NOT* DO: gate the still (2026-08-29). `vgai screenshot`
25
+ * used to REFUSE while a recording was live, on the argument that a single
26
+ * frame answers a temporal question by luck. The argument is right and the
27
+ * refusal was still the defect: because play always records, one `vgai play`
28
+ * killed `vgai screenshot` for the rest of the session — and "what does it
29
+ * look like" is a question a still answers exactly, and the one the whole
30
+ * look/modeling lane asks (measured, cold fox #3: the loop is play → look).
31
+ * Discouraging a wrong question by making a right one impossible is not a
32
+ * trade this product makes. The clip is unchanged and unconditional; the
33
+ * redirect now rides ON the delivered frame
34
+ * (`../bridge/screenshot.ts`'s `screenshotRecordingNotice`), naming the file, its
35
+ * offset-0 wall clock, the play log, and the ten-frame bar. A capture reads
36
+ * the canvas and composites the DOM into an offscreen image, which the
37
+ * recorder's stream never sees, so nothing about the evidence changes.
38
+ */
39
+
40
+ import { isRootCanvas } from '@volter/editor-core/composite-screenshot';
41
+ import { editorConsole } from '@volter/editor-core/editor-console';
42
+ import {
43
+ type GameplayRecordingCapture,
44
+ type GameplayRecordingStarted,
45
+ type GameplayRecordingStartOptions,
46
+ gameplayRecordingActive,
47
+ startGameplayRecording,
48
+ stopGameplayRecording,
49
+ } from '../host/gameplay-recording';
50
+ import { editorHost } from '@volter/editor-sdk/host';
51
+ import type { AudioRecordingHandle } from '@volter/editor-project/adapter';
52
+
53
+ /**
54
+ * How long a play run may go with NO session command and NO player input
55
+ * before it stops itself and finalizes its recording.
56
+ *
57
+ * ONE constant, editor-side, and deliberately generous: the thing it must
58
+ * never do is cut a run that someone is still working with. Two minutes is
59
+ * longer than any gap between an agent's own commands (`vgai eval` round
60
+ * trips are seconds) and longer than a human's pause at the keyboard, while
61
+ * still being short enough that an abandoned run becomes a readable file
62
+ * inside the same working session rather than the next day.
63
+ */
64
+ export const PLAY_IDLE_AUTOSTOP_MS = 120_000;
65
+
66
+ /** How often the watchdog checks the clock. Coarse on purpose — this is a
67
+ * 2-minute window, and a hidden tab's timers are clamped to about 1 Hz
68
+ * anyway, so anything finer would just be a number that is not true. */
69
+ const IDLE_POLL_MS = 5_000;
70
+
71
+ /** Discrete signals that a human is at the controls. Movement is included
72
+ * because for most games it IS the input; a player aiming a mouse is not
73
+ * idle. Every listener is passive and capture-phase, so nothing here can
74
+ * affect what the game or the editor sees. */
75
+ const INPUT_EVENTS = [
76
+ 'keydown',
77
+ 'keyup',
78
+ 'pointerdown',
79
+ 'pointermove',
80
+ 'wheel',
81
+ 'touchstart',
82
+ 'touchmove',
83
+ ] as const;
84
+
85
+ interface LivePlayRecording {
86
+ readonly started: GameplayRecordingStarted;
87
+ lastActivityMs: number;
88
+ poll: number | null;
89
+ releaseInput: (() => void) | null;
90
+ /** Set the instant a stop begins, so the watchdog, an explicit stop, and a
91
+ * teardown racing each other cannot finalize the same recording twice. */
92
+ ending: boolean;
93
+ }
94
+
95
+ let live: LivePlayRecording | null = null;
96
+
97
+ /**
98
+ * A stop that has BEGUN but not finished.
99
+ *
100
+ * The recorder's own "already active" guard is released at the END of
101
+ * `stopGameplayRecording` (it awaits the in-flight frame, the recorder's stop
102
+ * event, and the chunk uploads). `live` is cleared at the START. So a restart —
103
+ * `vgai play` over a running play, which tears down through `exitPlayMode`'s
104
+ * fire-and-forget teardown and then starts the next recording — would look
105
+ * finished to this module while the recorder was still busy, and the next start
106
+ * would refuse. This handle is what a start waits on; without it the second
107
+ * play of any session silently records nothing.
108
+ */
109
+ let pendingStop: Promise<GameplayRecordingCapture | null> | null = null;
110
+ /** Which stop owns `pendingStop` — a finished stop must not clear a newer
111
+ * one's handle out from under a start that is waiting on it. */
112
+ let stopGeneration = 0;
113
+
114
+ /** The last recording this session finalized — the "most recent recording"
115
+ * a refusal points at once play is over. */
116
+ let lastFinalized: GameplayRecordingCapture | null = null;
117
+
118
+ /**
119
+ * What ended play, in the caller's own words.
120
+ *
121
+ * `'idle-autostop'` is the one this module produces itself; the rest come from
122
+ * whoever tore play down, so a reader of the journal never has to guess
123
+ * whether a clip ended because the run ended or because nobody was there.
124
+ */
125
+ export type PlayRecordingEndReason = 'stop' | 'idle-autostop' | 'restart' | 'teardown';
126
+
127
+ /** Called by the auto-stop to end play itself. Bound once, by `play-mode.ts`,
128
+ * so this module never imports the play lifecycle it is driven by. */
129
+ let stopPlay: (() => void) | null = null;
130
+
131
+ export function bindPlayRecordingStop(stop: () => void): void {
132
+ stopPlay = stop;
133
+ }
134
+
135
+ /** The editor owns this producer-side wire shape. Its exact keys are pinned by
136
+ * `play-recording.test.ts`; the SDK independently pins its consumer shape. */
137
+ export interface PlayRecordingStatus {
138
+ readonly path: string;
139
+ readonly format: 'composite-webm' | 'canvas-dom';
140
+ readonly replayPath: string | null;
141
+ readonly startedAt: string;
142
+ readonly rotates: boolean;
143
+ readonly logFile: string | null;
144
+ readonly idleAutoStopMs: number;
145
+ }
146
+
147
+ /** The live play recording's facts, or `null` when nothing is recording. */
148
+ export function livePlayRecording(): PlayRecordingStatus | null {
149
+ if (!live || !gameplayRecordingActive()) return null;
150
+ return {
151
+ path: live.started.path,
152
+ format: live.started.format,
153
+ replayPath: live.started.replayPath,
154
+ startedAt: live.started.startedAt,
155
+ rotates: live.started.rotates,
156
+ logFile: live.started.logFile,
157
+ idleAutoStopMs: PLAY_IDLE_AUTOSTOP_MS,
158
+ };
159
+ }
160
+
161
+ /** The most recent recording this session finished, or `null`. */
162
+ export function lastPlayRecording(): GameplayRecordingCapture | null {
163
+ return lastFinalized;
164
+ }
165
+
166
+ /**
167
+ * Reset the idle clock.
168
+ *
169
+ * Two callers, and the pair is the whole definition of "idle": every relayed
170
+ * session command (`host.session.onCommandDispatched`, subscribed below) and
171
+ * every player input event (the listeners installed below). A run is idle only
172
+ * when BOTH have gone quiet — an agent driving through `vgai eval` and a human
173
+ * holding a movement key are each, alone, enough to keep it alive.
174
+ */
175
+ export function notePlayActivity(): void {
176
+ if (live) live.lastActivityMs = Date.now();
177
+ }
178
+
179
+ function installInputListeners(): () => void {
180
+ if (typeof window === 'undefined') return () => undefined;
181
+ const onInput = (): void => notePlayActivity();
182
+ for (const type of INPUT_EVENTS) {
183
+ window.addEventListener(type, onInput, { capture: true, passive: true });
184
+ }
185
+ return () => {
186
+ for (const type of INPUT_EVENTS) {
187
+ window.removeEventListener(type, onInput, { capture: true });
188
+ }
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Start this play run's recording.
194
+ *
195
+ * Never throws: a browser that cannot record must not be a browser that cannot
196
+ * PLAY. The failure is reported to the editor console (which is what
197
+ * `vgai console` and every command's trailing console summary read) and play
198
+ * continues with no clip, rather than an ack that refuses a working game
199
+ * because its evidence path is unavailable.
200
+ */
201
+ export async function beginPlayRecording(
202
+ container: HTMLElement | null,
203
+ options: {
204
+ name?: string | null;
205
+ audio?: AudioRecordingHandle | null;
206
+ refreshFrame?: () => void;
207
+ canvasFrame?: GameplayRecordingStartOptions['canvasFrame'];
208
+ } = {},
209
+ ): Promise<PlayRecordingStatus | null> {
210
+ // A restart plays over a live run. End the old clip first — its bytes are
211
+ // real evidence of the run that just ended, and abandoning the recorder
212
+ // would leave the next start refusing with "already active".
213
+ if (live) await endPlayRecording('restart');
214
+ // …and wait out a stop ALREADY in flight (`exitPlayMode`'s fire-and-forget
215
+ // teardown is exactly that, on every restart). See `pendingStop`.
216
+ await pendingStop;
217
+ if (!container) {
218
+ editorConsole.warn(
219
+ 'Play started, but gameplay recording did not: no game surface is mounted to record.',
220
+ 'play-recording',
221
+ );
222
+ return null;
223
+ }
224
+ try {
225
+ const started = await startGameplayRecording(container, {
226
+ format:
227
+ Array.from(container.querySelectorAll('canvas')).filter(isRootCanvas).length === 1
228
+ ? 'canvas-dom'
229
+ : 'composite-webm',
230
+ ...(options.name ? { name: options.name } : {}),
231
+ ...(options.audio ? { audio: options.audio } : {}),
232
+ ...(options.refreshFrame ? { refreshFrame: options.refreshFrame } : {}),
233
+ ...(options.canvasFrame ? { canvasFrame: options.canvasFrame } : {}),
234
+ });
235
+ live = {
236
+ started,
237
+ lastActivityMs: Date.now(),
238
+ poll: null,
239
+ releaseInput: installInputListeners(),
240
+ ending: false,
241
+ };
242
+ live.poll = window.setInterval(() => {
243
+ const current = live;
244
+ if (!current || current.ending) return;
245
+ if (Date.now() - current.lastActivityMs < PLAY_IDLE_AUTOSTOP_MS) return;
246
+ void autoStopIdlePlay();
247
+ }, IDLE_POLL_MS);
248
+ return livePlayRecording();
249
+ } catch (error) {
250
+ editorConsole.warn(
251
+ `Play started, but gameplay recording did not: ${error instanceof Error ? error.message : String(error)}`,
252
+ 'play-recording',
253
+ );
254
+ return null;
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Finalize this run's recording. Idempotent, and safe to call when nothing is
260
+ * recording (the ordinary case for a play that never got one).
261
+ */
262
+ export async function endPlayRecording(
263
+ reason: PlayRecordingEndReason,
264
+ ): Promise<GameplayRecordingCapture | null> {
265
+ const current = live;
266
+ if (!current || current.ending) return pendingStop;
267
+ current.ending = true;
268
+ if (current.poll !== null) window.clearInterval(current.poll);
269
+ current.releaseInput?.();
270
+ live = null;
271
+ if (!gameplayRecordingActive()) return null;
272
+ // Published BEFORE the first await, so a start racing this stop can find it.
273
+ const generation = ++stopGeneration;
274
+ const stop = (async () => {
275
+ try {
276
+ const capture = await stopGameplayRecording(reason);
277
+ lastFinalized = capture;
278
+ return capture;
279
+ } catch (error) {
280
+ editorConsole.warn(
281
+ `Gameplay recording could not be finalized: ${error instanceof Error ? error.message : String(error)}`,
282
+ 'play-recording',
283
+ );
284
+ return null;
285
+ } finally {
286
+ // Only OUR stop clears the slot — a newer one may already own it.
287
+ if (stopGeneration === generation) pendingStop = null;
288
+ }
289
+ })();
290
+ pendingStop = stop;
291
+ return stop;
292
+ }
293
+
294
+ /** One sentence, used by both the auto-stop note and the `vgai screenshot`
295
+ * refusal, so the two doors cannot describe the same clip differently. */
296
+ export function describeCapture(capture: GameplayRecordingCapture): string {
297
+ const seconds = Math.round(capture.durationMs / 100) / 10;
298
+ return (
299
+ `${capture.path} — ${seconds}s at ${capture.effectiveFps} fps` +
300
+ `${capture.hidden ? ' (tab hidden; that is the cadence actually captured, not the requested one)' : ''}`
301
+ );
302
+ }
303
+
304
+ /**
305
+ * Nobody has touched this run for the idle window: finalize the evidence and
306
+ * stop play.
307
+ *
308
+ * Recording first, THEN play. A WebM is only readable once its recorder
309
+ * finalizes it, and tearing down the game surface underneath a live composite
310
+ * loop is how a clip ends up truncated at exactly the moment worth watching.
311
+ *
312
+ * This is NOT an error. An unattended run reaching its window is the designed
313
+ * outcome — the point is that the file is closed and named — so it is a
314
+ * console note, and the session journal carries the same fact as a
315
+ * `play-recording` row.
316
+ */
317
+ async function autoStopIdlePlay(): Promise<void> {
318
+ const idleSeconds = Math.round(PLAY_IDLE_AUTOSTOP_MS / 1000);
319
+ const capture = await endPlayRecording('idle-autostop');
320
+ editorConsole.log(
321
+ capture === null
322
+ ? `Play auto-stopped after ${idleSeconds}s with no session command and no player input.`
323
+ : `Play auto-stopped after ${idleSeconds}s with no session command and no player input. ` +
324
+ `Recording finalized: ${describeCapture(capture)}`,
325
+ 'play-recording',
326
+ );
327
+ stopPlay?.();
328
+ }
329
+
330
+ // HALF OF "IS THIS RUN IDLE" — the other half is player input, watched
331
+ // above. Every relayed command counts: an agent that is still driving a game
332
+ // through `vgai eval` is not idle, whatever the command was, and a stamp
333
+ // filed per verb would quietly exclude whichever verb someone forgot.
334
+ // No-op unless a recording is live.
335
+ editorHost().session.onCommandDispatched(() => notePlayActivity());
@@ -0,0 +1,165 @@
1
+ /**
2
+ * A `dom` root's PLAY-mode authoring: the SAME {@link ReactRootAuthoringAdapter}
3
+ * Edit mode uses, pointed at an EPHEMERAL destination — the running DOM — instead
4
+ * of the JSX source.
5
+ *
6
+ * Why not the live-only `DomAuthoringAdapter` (the ingest React lane's
7
+ * adapter) here: entity identity. Both this adapter and Edit mode's key nodes by
8
+ * the OID signature `walkOidTree` derives (`o1`, `o1#2`, …); the ingest adapter
9
+ * keys by a STRUCTURAL DOM path (`rdom:div[0]/span[0]`). Swapping adapters at play
10
+ * entry would therefore renumber every dom node, breaking the Edit→Play selection
11
+ * continuity that exists today (
12
+ * asserts the play tree is the OID tree), and would additionally drop the rows
13
+ * only this adapter serves — live component props, design tokens, matched CSS
14
+ * rules, text, empty-container hints. So play keeps the adapter and changes the
15
+ * DESTINATION, which is the only thing play actually needs to change.
16
+ *
17
+ * The destination swap is {@link createEphemeralDomWriteBackend}: a
18
+ * `SourceWriteBackend`-SHAPED writer whose every operation lands on the live DOM
19
+ * and none of which performs any I/O. That shape is the whole seam every write
20
+ * path in `ReactRootAuthoringAdapter` already funnels through (`writeStyleEntry`,
21
+ * `removeStyleProp`, `editText`, `writePropEdit`, `structOp`), so this file adds
22
+ * no second write path and Edit mode is untouched. It deliberately omits
23
+ * `prepare`/`readSource`/`applySource`, which is precisely how
24
+ * `withProjectSourceHistory` (`../history/source-history-backend.ts`) knows to
25
+ * leave it alone: an ephemeral play edit must never enter PROJECT history, and it
26
+ * cannot, because there is no project resource behind it.
27
+ *
28
+ * What that buys, per gesture, matching the three/pixi play regime (edits apply
29
+ * live to the running world, die with the session, never touch source):
30
+ * - style edits / the visibility toggle → the live inline style, at once;
31
+ * - `boxEdit` drags and arrow-key nudges → the live preview `boxEdit.apply`
32
+ * already paints, now COMMITTED live instead of refused at `end`;
33
+ * - `inspector.remove` of a longhand override → the live inline property;
34
+ * - text edits → the live element's text.
35
+ * Two gestures genuinely have no live equivalent and say so at the point of use
36
+ * rather than silently doing nothing: a component PROP is owned by the running
37
+ * React element's fiber (there is no writable live seam — the prop lives at its
38
+ * parent's call site), and a STRUCTURAL op would mutate a tree React's reconciler
39
+ * owns. Both refuse with a message naming the play regime, not the misleading
40
+ * "no dev server" text a backend-less session would print.
41
+ *
42
+ * A live write is honestly transient in both directions: React re-rendering the
43
+ * element repaints over it, exactly as a game's own update loop overwrites a
44
+ * three gizmo drag during play. That is the regime, not a defect.
45
+ */
46
+
47
+ import type { EditorShellStore } from '@volter/editor-core/editor-shell-store';
48
+ import type { SourceWriteBackend } from '@volter/editor-core/ui-source/source-write-backend';
49
+ import {
50
+ type OidElementLike,
51
+ ReactRootAuthoringAdapter,
52
+ walkOidTree,
53
+ } from '../react/react-world-authoring-adapter';
54
+ import type { AuthoringProvenance } from '@volter/editor-project/adapter';
55
+
56
+ /** Disclosed up front on the adapter, alongside `capabilities.persist: false` and
57
+ * the ephemeral `persistence.destination` play-mode's own wrapper installs — the
58
+ * same `source: 'live'` the adopted three scene reports. */
59
+ export const EPHEMERAL_DOM_PROVENANCE: AuthoringProvenance = {
60
+ source: 'live',
61
+ label: 'live',
62
+ detail:
63
+ 'Running DOM adopted from the playing game. Edits apply to this session only ' +
64
+ 'and are discarded when play stops.',
65
+ };
66
+
67
+ const STRUCTURE_REFUSAL =
68
+ 'structure is owned by the running React tree — play edits are live-DOM only and are ' +
69
+ 'discarded on stop; stop play to change structure in source';
70
+
71
+ /**
72
+ * Every live element carrying `oid`. An OID is a SOURCE signature, so one oid can
73
+ * resolve to many rendered elements (a `.map`ped list); a source write would change
74
+ * all of them, so the live write does too.
75
+ */
76
+ function elementsFor(root: OidElementLike, oid: string): OidElementLike[] {
77
+ const matches: OidElementLike[] = [];
78
+ for (const node of walkOidTree(root).nodes.values()) {
79
+ if (node.oid === oid) matches.push(node.el);
80
+ }
81
+ return matches;
82
+ }
83
+
84
+ /** A real `CSSStyleDeclaration` or a plain-object fixture, as a writable bag. */
85
+ function styleOf(el: OidElementLike): Record<string, unknown> | null {
86
+ return el.style ? (el.style as Record<string, unknown>) : null;
87
+ }
88
+
89
+ /**
90
+ * The play regime's write destination — see this file's header. Returns the
91
+ * `SourceWriteBackend` shape so `ReactRootAuthoringAdapter`'s existing write paths
92
+ * carry it unchanged; NOTHING here reads or writes a file, opens a socket, or
93
+ * reaches `/__ui-source`.
94
+ */
95
+ export function createEphemeralDomWriteBackend(root: OidElementLike): SourceWriteBackend {
96
+ return {
97
+ writeStyle: async (oid, prop, value) => {
98
+ const elements = elementsFor(root, oid);
99
+ if (elements.length === 0) {
100
+ return { changed: false, error: `no live element carries oid "${oid}"` };
101
+ }
102
+ for (const element of elements) {
103
+ const style = styleOf(element);
104
+ if (style) style[prop] = value;
105
+ }
106
+ return { changed: true, route: 'live-dom' };
107
+ },
108
+ removeStyle: async (oid, prop) => {
109
+ const elements = elementsFor(root, oid);
110
+ if (elements.length === 0) {
111
+ return { changed: false, error: `no live element carries oid "${oid}"` };
112
+ }
113
+ // `changed: false` when the property was never set live is the same honest
114
+ // no-op the source writer reports for an unauthored longhand — the caller
115
+ // (`removeStyleProp`) pushes nothing and touches nothing.
116
+ let changed = false;
117
+ for (const element of elements) {
118
+ const style = styleOf(element);
119
+ const current = style?.[prop];
120
+ if (style && current !== undefined && current !== null && current !== '') {
121
+ delete style[prop];
122
+ changed = true;
123
+ }
124
+ }
125
+ return { changed, route: 'live-dom' };
126
+ },
127
+ writeText: async (oid, text) => {
128
+ // Leaves only — the same gate `text.get` applies before offering the edit;
129
+ // replacing the text of an element with child elements would delete live
130
+ // children React owns.
131
+ const elements = elementsFor(root, oid).filter((element) => element.children.length === 0);
132
+ if (elements.length === 0) {
133
+ return { changed: false, error: `no live leaf element carries oid "${oid}"` };
134
+ }
135
+ const prevText = elements[0]?.textContent ?? null;
136
+ for (const element of elements) {
137
+ (element as { textContent: string | null }).textContent = text;
138
+ }
139
+ return { changed: true, prevText };
140
+ },
141
+ writeProp: async (_oid, prop) => ({
142
+ changed: false,
143
+ error:
144
+ `prop "${prop}" is owned by the running React element (its value lives at the ` +
145
+ 'parent call site, not in the DOM) — play edits are live-DOM only and are discarded ' +
146
+ 'on stop; stop play to change it in source',
147
+ }),
148
+ writeStruct: async () => ({ changed: false, error: STRUCTURE_REFUSAL }),
149
+ writeStructMany: async () => ({ changed: false, error: STRUCTURE_REFUSAL }),
150
+ };
151
+ }
152
+
153
+ /**
154
+ * The `dom` play child `play-mode.ts` installs: the ordinary React root adapter
155
+ * over the running root, writing live and disclosing that it does.
156
+ */
157
+ export function createReactPlayAuthoringAdapter(
158
+ root: OidElementLike,
159
+ store: EditorShellStore,
160
+ ): ReactRootAuthoringAdapter {
161
+ return new ReactRootAuthoringAdapter(root, store, {
162
+ writeBackend: createEphemeralDomWriteBackend(root),
163
+ provenance: EPHEMERAL_DOM_PROVENANCE,
164
+ });
165
+ }