@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,254 @@
1
+ import { sha256Hex } from '@volter/editor-core/bytes-codec';
2
+ import { HistoryOperationError, type HistoryService } from '@volter/editor-core/history/history-service';
3
+ import type { ResourceDescriptor, ResourceDriver, ResourceKind } from '@volter/editor-core/history/types';
4
+
5
+ const encoder = new TextEncoder();
6
+ const decoder = new TextDecoder();
7
+
8
+ /**
9
+ * WHO OWNS A LIVE-EDIT JOURNAL, WHO SHARES IT, AND THE ONE PATH ALLOWED TO END
10
+ * IT. This block is the single statement of that ownership; every journal below
11
+ * (`three-authoring-adapter`, `pixi-live-write-target`,
12
+ * `pixi-structure-history`, `dom-authoring-adapter`,
13
+ * `react-world-authoring-adapter`) reads it rather than restating it.
14
+ *
15
+ * THE RULE: **a journal's session is its SUBJECT's, never its MOUNT's.**
16
+ * - OWNER — the subject named by {@link JournalSubject.session}. A held
17
+ * Edit-mode surface's subject is the WORLD, which outlives every remount
18
+ * (HMR, a scene switch, a re-entered ingest), so its journal lives in the one
19
+ * {@link AUTHORING_SESSION}. Play's subject is the PLAY RUN, which ends when
20
+ * somebody presses ■.
21
+ * - SHARERS — every mount of that subject, in succession. Two mounts of one
22
+ * world are two sharers of ONE journal: `registerSession` returns the same
23
+ * descriptor for the same location, so the second mount rejoins the first
24
+ * mount's entries instead of minting a stack of its own.
25
+ * - THE ONE TEARDOWN PATH — `HistoryService.expireSession(session)`, called by
26
+ * the owner of that session and by nobody else. Today its only caller is
27
+ * play's `exitPlayRootAuthoring()` (`play-mode.ts`), which ends the run it
28
+ * started. A journal has NO teardown verb of its own, deliberately: a mount
29
+ * holding one must not be able to end a session it does not own.
30
+ *
31
+ * WHAT THIS COST BEFORE IT WAS STATED (measured 2026-08-16, canvas held
32
+ * surface): every journal minted `sessionId: \`pixi-${crypto.randomUUID()}\`` —
33
+ * a fresh identity per MOUNT — and `dispose()` expired it. So a remount marked
34
+ * the surface's whole undo stack `expired`: `canUndo` went true → false and
35
+ * `undo()` returned false, while the entity ids the entries addressed were
36
+ * byte-identical across the remount. A world-scoped stack ended by a per-mount
37
+ * teardown is exactly the shape build-rule 4 exists to catch.
38
+ *
39
+ * SO THE TWO VERBS MEAN DIFFERENT THINGS, and conflating them was the defect:
40
+ * {@link JsonHistoryResource.dispose} is "THIS MOUNT detaches" (unregister the
41
+ * driver; entries stay restorable and the next mount re-registers), while
42
+ * `HistoryService.expireSession` is "the SUBJECT is gone". `dispose()` used to
43
+ * call `expireSession` itself, which is precisely how a sharer came to end an
44
+ * owner's session.
45
+ */
46
+ export interface JournalSubject {
47
+ /** The subject's session — one path segment. {@link AUTHORING_SESSION} for
48
+ * every held/authoring surface; a per-run id for play. */
49
+ readonly session: string;
50
+ /** Unique WITHIN that session: which world's which journal. Two journals in
51
+ * one world (2D edits vs 2D structure) and two worlds in one session must
52
+ * never collide, or the second registration finds the first's driver. */
53
+ readonly id: string;
54
+ }
55
+
56
+ /**
57
+ * The editor's authoring session — deliberately a CONSTANT, because there is
58
+ * exactly one of it and it lasts as long as the project is open. That is what
59
+ * makes a remount rejoin rather than restart.
60
+ */
61
+ export const AUTHORING_SESSION = 'authoring';
62
+
63
+ /** A held/authoring surface's journal: owned by the world, shared by its
64
+ * mounts, never ended by one of them. */
65
+ export function authoringJournal(id: string): JournalSubject {
66
+ return { session: AUTHORING_SESSION, id };
67
+ }
68
+
69
+ /** A play run's journal: owned by the run, and ended by it — which is what
70
+ * keeps play-time edits out of the edit-mode undo stack after ■. */
71
+ export function playJournal(runId: string, id: string): JournalSubject {
72
+ return { session: `play-${runId}`, id };
73
+ }
74
+
75
+ export interface JsonHistoryResourceOptions<T> {
76
+ readonly history: HistoryService;
77
+ readonly kind: ResourceKind;
78
+ readonly scope: 'project' | 'session';
79
+ readonly location?: string;
80
+ /** Required for `scope: 'session'` — see {@link JournalSubject}. */
81
+ readonly subject?: JournalSubject;
82
+ readonly displayName?: string;
83
+ readonly capture: () => T;
84
+ readonly restore: (value: T) => void | Promise<void>;
85
+ /**
86
+ * The part of the captured state that constitutes this resource's CONFLICT
87
+ * IDENTITY — i.e. "has something else changed this resource since the
88
+ * transaction was recorded?". Defaults to the whole captured value.
89
+ *
90
+ * Override it when `capture()` deliberately includes state that is NOT part
91
+ * of the resource's persisted content and may change on its own. The ingest
92
+ * overlay resource is the motivating case: its snapshot carries a mirror of
93
+ * every live `Object3D`'s pose (needed as the RESTORE payload), but an
94
+ * ingested game is a RUNNING game that animates its own objects every frame.
95
+ * Hashing that live simulation state as identity made every ingest
96
+ * transaction fail preflight with `content-conflict` a frame after it was
97
+ * recorded — so undo silently did nothing (issue #81). The resource's real
98
+ * content is the overlay it serializes to disk, which only editor edits
99
+ * touch.
100
+ */
101
+ readonly conflictIdentity?: (value: T) => unknown;
102
+ }
103
+
104
+ /** Concrete JSON document/session resource backed by the project HistoryService. */
105
+ export class JsonHistoryResource<T> {
106
+ readonly descriptor: ResourceDescriptor;
107
+ private revision = 0;
108
+ private readonly unregister: () => void;
109
+ private disposed = false;
110
+ /**
111
+ * Has THIS MOUNT written to the resource yet? The conflict check below is the
112
+ * only reader, and the reason is the other half of making a journal outlive
113
+ * its mount: {@link conflictIdentity} is per-mount bookkeeping (which props
114
+ * the editor has touched), so a REJOINING mount starts empty and every
115
+ * inherited entry failed preflight with `content-conflict` — the stack
116
+ * survived the remount and then refused to restore, which is no better than
117
+ * losing it. A mount that has recorded nothing has observed no divergence and
118
+ * has no basis to refuse; within a mount the check is unchanged, which is
119
+ * what keeps issue #81's protection intact.
120
+ */
121
+ private recorded = false;
122
+
123
+ constructor(private readonly options: JsonHistoryResourceOptions<T>) {
124
+ if (options.scope === 'project') {
125
+ if (!options.location) throw new Error('A project JSON history resource needs a location.');
126
+ this.descriptor = options.history.registry.registerProject(
127
+ options.kind,
128
+ options.location,
129
+ options.displayName,
130
+ );
131
+ } else {
132
+ if (!options.subject) throw new Error('A session JSON history resource needs a subject.');
133
+ this.descriptor = options.history.registry.registerSession(
134
+ options.kind,
135
+ options.subject.session,
136
+ options.subject.id,
137
+ options.displayName,
138
+ );
139
+ }
140
+
141
+ const driver: ResourceDriver = {
142
+ descriptor: this.descriptor,
143
+ capture: async () => {
144
+ const bytes = this.encode(options.capture());
145
+ return {
146
+ revision: this.revision,
147
+ contentType: 'application/json',
148
+ bytes,
149
+ sha256: await sha256Hex(bytes),
150
+ };
151
+ },
152
+ preflight: async (expected) => {
153
+ const bytes = this.encode(options.capture());
154
+ const actualSha256 = await sha256Hex(bytes);
155
+ // A mount that has not written here cannot have seen anything diverge.
156
+ if (!this.recorded) return { ok: true };
157
+ // Fast path (and the behavior for every resource that does NOT narrow
158
+ // its identity): the full captured bytes still hash to what the
159
+ // transaction expects, so nothing has diverged.
160
+ if (actualSha256 === expected.sha256) return { ok: true };
161
+ // Otherwise the bytes differ — but that only counts as a CONFLICT if the
162
+ // part that identifies the resource differs. Compare the narrowed
163
+ // identity against the expected snapshot's own identity, decoded from
164
+ // the stored blob. (See `conflictIdentity` — an ingested game mutates
165
+ // the live-object mirror in its snapshot every frame on its own.)
166
+ if (options.conflictIdentity) {
167
+ const expectedValue = JSON.parse(
168
+ decoder.decode(options.history.snapshots.read(expected).bytes),
169
+ ) as T;
170
+ const expectedIdentity = JSON.stringify(options.conflictIdentity(expectedValue));
171
+ const actualIdentity = JSON.stringify(options.conflictIdentity(options.capture()));
172
+ if (expectedIdentity === actualIdentity) return { ok: true };
173
+ }
174
+ return {
175
+ ok: false,
176
+ reason: 'content-conflict',
177
+ actualRevision: this.revision,
178
+ actualSha256,
179
+ };
180
+ },
181
+ restore: async (snapshot) => {
182
+ const bytes = options.history.snapshots.read(snapshot).bytes;
183
+ await options.restore(JSON.parse(decoder.decode(bytes)) as T);
184
+ this.revision++;
185
+ },
186
+ estimateBytes: (snapshot) => snapshot.byteLength,
187
+ };
188
+ this.unregister = options.history.registerDriver(driver);
189
+ }
190
+
191
+ record(label: string, before: T, after: T): Promise<boolean> {
192
+ if (this.disposed) return Promise.reject(new Error('JSON history resource is disposed.'));
193
+ this.options.history.assertCanRecordAppliedChange();
194
+ this.revision++;
195
+ this.recorded = true;
196
+ return this.options.history
197
+ .recordAppliedTransaction(
198
+ {
199
+ label,
200
+ resources: [this.descriptor.key],
201
+ scope: this.descriptor.scope,
202
+ ...(this.descriptor.sessionId ? { sessionId: this.descriptor.sessionId } : {}),
203
+ },
204
+ [
205
+ {
206
+ resource: this.descriptor.key,
207
+ beforeBytes: this.encode(before),
208
+ afterBytes: this.encode(after),
209
+ contentType: 'application/json',
210
+ },
211
+ ],
212
+ )
213
+ .catch(async (error: unknown) => {
214
+ try {
215
+ await this.options.restore(structuredClone(before));
216
+ this.revision++;
217
+ } catch (compensationError) {
218
+ throw this.options.history.blockForCompensationFailure(label, [this.descriptor.key], {
219
+ operationError: error,
220
+ compensationError,
221
+ });
222
+ }
223
+ if (!(error instanceof HistoryOperationError)) {
224
+ this.options.history.reportAppliedChangeFailure(label, [this.descriptor.key], error);
225
+ }
226
+ throw error;
227
+ });
228
+ }
229
+
230
+ /** Must be called immediately before a live or overlay mutation begins. */
231
+ assertCanMutate(): void {
232
+ if (this.disposed) throw new Error('JSON history resource is disposed.');
233
+ this.options.history.assertCanRecordAppliedChange();
234
+ }
235
+
236
+ /**
237
+ * THIS MOUNT DETACHES — see the ownership block at the top of this file.
238
+ *
239
+ * Unregisters the driver and nothing else: the entries stay restorable, and
240
+ * the next mount of the same subject re-registers over the same descriptor
241
+ * and inherits them. This deliberately does NOT expire; a mount ending is not
242
+ * its subject ending, and treating it as one is what emptied a held surface's
243
+ * undo stack on every remount.
244
+ */
245
+ dispose(): void {
246
+ if (this.disposed) return;
247
+ this.disposed = true;
248
+ this.unregister();
249
+ }
250
+
251
+ private encode(value: T): Uint8Array {
252
+ return encoder.encode(JSON.stringify(value, null, 2));
253
+ }
254
+ }
@@ -0,0 +1,261 @@
1
+ /**
2
+ * THE in-tree ingest registry — one home, one glob, one manifest convention
3
+ * ONE INGEST HOME.
4
+ *
5
+ * Until this wave there were THREE parallel discovery mechanisms, each named
6
+ * after a rendering library rather than after what it did: `discovery.ts`
7
+ * globbed `games/`, `discovery2d.ts` globbed `games-2d/`, `discovery-react.ts`
8
+ * globbed `games-react/`. Each re-implemented the same four steps (glob the
9
+ * manifests, key them by folder id, parse+validate the single root, cache and
10
+ * look up by id) and each hard-coded the ADAPTER IDENTITY it would accept —
11
+ * `ingest-pixi`, `ingest-react` — which is a library name in a seam that is
12
+ * supposed to be about surfaces. A Phaser or Babylon game had no home.
13
+ *
14
+ * There is now ONE glob over ONE directory, and the discriminator is the
15
+ * manifest's own `surface` (`three | canvas | dom`) — the doctrine vocabulary,
16
+ * "what the host HANDS the root", never which library draws. A vendored Phaser
17
+ * game is a `canvas` root and needs no code here; the library it uses is
18
+ * metadata (`libraryOf`, read off the adapter identity for display/telemetry
19
+ * only, never branched on for mounting).
20
+ *
21
+ * What is deliberately NOT unified: the per-surface MOUNT paths
22
+ * (`surface-three.ts` / `surface-canvas.ts` / `surface-dom.ts`). Mounting a
23
+ * three root, a canvas root and a dom root are genuinely different jobs — a
24
+ * three fixture becomes an `IngestGame`, a canvas fixture an `IngestGame2D`, a
25
+ * dom fixture a `projectRoot` for
26
+ * the `/@fs/` resolver. Those three builders remain, each consuming THIS
27
+ * module's already-parsed entries. Discovery is one; mounting is three.
28
+ *
29
+ * Also not unified, and for a reason that is not library-shaped:
30
+ * `discovery-public-ingest.ts` resolves games vendored under
31
+ * `public/ingest/<id>/`. `public/` is outside Vite's module graph by
32
+ * construction (served verbatim in dev, copied at build), so those manifests
33
+ * are `fetch`ed by id rather than globbed. That split is SERVING-driven, not
34
+ * library-driven, and both halves are id-keyed.
35
+ *
36
+ * ## Why `import.meta.glob`
37
+ *
38
+ * These fixtures live inside this package's own source tree, which Vite (and
39
+ * Vitest, sharing Vite's transform pipeline) knows statically in dev, build and
40
+ * test alike — no runtime fetch, no `/@fs/`, no dev-server middleware.
41
+ * `resolveIngestDescriptor` (ingest/resolve-three.ts) is the route for an EXTERNAL
42
+ * project folder; it is deliberately not used here.
43
+ */
44
+
45
+ import { declaredRoots } from '@volter/editor-project/adapter/manifest-interpreter';
46
+ import type { ResolvedAdapter, ResolvedAdapterRoot } from '@volter/editor-project/manifest/load';
47
+ import { loadGameManifest, type ResolvedGameManifest } from '@volter/editor-project/manifest/load';
48
+
49
+ /** The one manifest glob. Every in-tree ingest fixture, whatever its surface. */
50
+ const manifestModules = import.meta.glob('./games/*/vgai.project.json', {
51
+ eager: true,
52
+ import: 'default',
53
+ }) as Record<string, unknown>;
54
+
55
+ /**
56
+ * The one entry-module glob, shared by every surface: the host imports a
57
+ * fixture's own entry module (and its `contractShim`, when it declares one) in
58
+ * the editor's realm. `.d.ts` is excluded: an ambient declaration file
59
+ * is not a module and must never become a dynamic-import chunk.
60
+ *
61
+ * `vgai.adapter.ts` is excluded too, and for a different reason: it is not a
62
+ * GAME module at all. It is host-realm DECLARATION (see {@link adapterModules}),
63
+ * read without booting anything, and a manifest that named it as an `entry` or
64
+ * a `contractShim` would be nonsense — so it is not in the set those fields
65
+ * resolve against.
66
+ */
67
+ export const entryModules = import.meta.glob([
68
+ './games/*/*.js',
69
+ './games/*/*.ts',
70
+ '!./games/*/*.d.ts',
71
+ '!./games/*/vgai.adapter.ts',
72
+ ]) as Record<string, () => Promise<unknown>>;
73
+
74
+ /**
75
+ * THE ADAPTER GLOB — one `vgai.adapter.ts` per ingest game, keyed by game id.
76
+ *
77
+ * ARCHITECTURE-CORE §The editor protocol: "Placement follows REALM: game-realm
78
+ * code (a shim that runs inside the game) lives with the game's bundle;
79
+ * host-realm declaration (`vgai.adapter.ts`) lives where the host can import it
80
+ * as a real module — a repo-vendored bundle's adapter file lives in the host's
81
+ * in-tree registry, never inside the verbatim-served bundle." This directory IS
82
+ * that registry, so it holds BOTH populations:
83
+ *
84
+ * - an in-tree fixture's adapter sits beside its own manifest and entry;
85
+ * - a public-bundle game's adapter sits in a folder holding ONLY that file —
86
+ * its bytes are served verbatim from `public/ingest/<id>/` and hashed by
87
+ * `vendor/games/verify-unaltered.mjs`, so nothing host-owned may go there.
88
+ *
89
+ * A folder with no `vgai.project.json` is invisible to {@link manifestModules}
90
+ * and therefore to {@link discoverIngestEntries} — the second population adds no
91
+ * fixtures, only declarations.
92
+ *
93
+ * LAZY, not eager, on purpose: `defineAdapter` validates at module-evaluation
94
+ * time and THROWS on a malformed table. Eager evaluation would make one bad
95
+ * adapter a boot failure for the whole editor; loading only the open game's
96
+ * adapter keeps that failure where the loader can name it and keep going
97
+ * (`project-adapter.ts`: "an editor that dies on a project's config file cannot
98
+ * be used to fix that file"). Vite resolves the glob statically in dev and in
99
+ * a production build alike, so no generated dispatch table is needed.
100
+ */
101
+ const adapterModules = import.meta.glob('./games/*/vgai.adapter.ts') as Record<
102
+ string,
103
+ () => Promise<unknown>
104
+ >;
105
+
106
+ /** The adapter module's filename — the contract's ONE spelling, host side. */
107
+ const ADAPTER_FILENAME = 'vgai.adapter.ts';
108
+
109
+ /** This directory's repo path, so a published `modulePath` names a real file. */
110
+ const REGISTRY_REPO_DIR = 'packages/editor/src/ingest/games';
111
+
112
+ const ADAPTER_KEY_RE = /^\.\/games\/([^/]+)\/vgai\.adapter\.ts$/;
113
+
114
+ /**
115
+ * THE LOOKUP KEY IS THE GAME ID; THE STORAGE KEY IS THE FOLDER NAME. What makes
116
+ * that the same question is an INVARIANT, not a coincidence.
117
+ *
118
+ * `project-adapter.ts` resolves an ingest project's adapter by the id the
119
+ * project's own manifest declares (`ingestGameIdOf` -> the single `{ ingest }`
120
+ * root's `id`), and the two functions below map that id straight onto
121
+ * `./games/<id>/`. So a folder whose game declares a DIFFERENT id binds that
122
+ * other game's table — silently, because a lookup by key cannot tell a mistaken
123
+ * key from a correct one.
124
+ *
125
+ * The invariant is therefore `folder id === the id that game's manifest
126
+ * declares`, for BOTH populations this registry serves — in-tree fixtures
127
+ * (`./games/<id>/vgai.project.json`) and public bundles
128
+ * (`public/ingest/<id>/vgai.project.json`, whose registry folder holds only the
129
+ * adapter file). It is guarded ONCE, statically, in
130
+ * `packages/editor/test/ingest-adapter-modules.test.ts`, which reads both id
131
+ * spaces off disk — the only realm that can, since `public/` is outside the
132
+ * module graph and reachable here by `fetch` alone.
133
+ */
134
+
135
+ /** Repo-relative path of a game's adapter module, or `null` when it has none. */
136
+ export function ingestAdapterModulePath(gameId: string): string | null {
137
+ if (!adapterModules[gameFileKey(gameId, ADAPTER_FILENAME)]) return null;
138
+ return `${REGISTRY_REPO_DIR}/${gameId}/${ADAPTER_FILENAME}`;
139
+ }
140
+
141
+ /**
142
+ * Import a game's adapter module. `null` = this game declares none, which is
143
+ * the ordinary "absence IS the declared native default" case the loader already
144
+ * knows how to answer; a module that exists and throws is left to throw, so the
145
+ * loader can name the failure rather than swallow it.
146
+ */
147
+ export async function importIngestAdapterModule(gameId: string): Promise<unknown | null> {
148
+ const load = adapterModules[gameFileKey(gameId, ADAPTER_FILENAME)];
149
+ return load ? await load() : null;
150
+ }
151
+
152
+ /** Every game id that ships an adapter module, sorted — what conformance scans. */
153
+ export function ingestAdapterGameIds(): string[] {
154
+ return Object.keys(adapterModules)
155
+ .map((key) => {
156
+ const match = ADAPTER_KEY_RE.exec(key);
157
+ if (!match?.[1]) throw new Error(`ingest registry: unexpected adapter glob key "${key}"`);
158
+ return match[1];
159
+ })
160
+ .sort((a, b) => a.localeCompare(b));
161
+ }
162
+
163
+ /** Served URLs for a manifest's `assets` map (game-relative path -> URL). */
164
+ export const assetUrls = import.meta.glob('./games/*/assets/**', {
165
+ eager: true,
166
+ query: '?url',
167
+ import: 'default',
168
+ }) as Record<string, string>;
169
+
170
+ const MANIFEST_KEY_RE = /^\.\/games\/([^/]+)\/vgai\.project\.json$/;
171
+
172
+ /** Folder-relative glob key for a file inside a discovered game's folder. */
173
+ export function gameFileKey(folderId: string, relPath: string): string {
174
+ return `./games/${folderId}/${relPath}`;
175
+ }
176
+
177
+ /** The `surface` vocabulary, restated by value (never imported from a schema
178
+ * module, matching how the manifest schema itself mirrors it). */
179
+ export type IngestSurface = 'three' | 'canvas' | 'dom';
180
+
181
+ /** A manifest root already narrowed to the `ingest` adapter — the registry does
182
+ * that narrowing ONCE, so no per-surface builder repeats the check. */
183
+ export type IngestRoot = Omit<ResolvedAdapterRoot, 'adapter'> & {
184
+ readonly adapter: Extract<ResolvedAdapter, { type: 'ingest' }>;
185
+ };
186
+
187
+ /** One discovered in-tree ingest fixture: its folder, its parsed manifest, and
188
+ * the single ingest root that manifest declares. */
189
+ export interface IngestEntry {
190
+ readonly folderId: string;
191
+ readonly manifest: ResolvedGameManifest;
192
+ readonly root: IngestRoot;
193
+ readonly surface: IngestSurface;
194
+ }
195
+
196
+ function folderIdFromManifestKey(key: string): string {
197
+ const match = MANIFEST_KEY_RE.exec(key);
198
+ if (!match?.[1]) {
199
+ throw new Error(`ingest registry: unexpected manifest glob key "${key}"`);
200
+ }
201
+ return match[1];
202
+ }
203
+
204
+ function buildEntry(folderId: string, raw: unknown): IngestEntry {
205
+ const manifest = loadGameManifest(raw, { configurationKinds: 'defer' });
206
+ const roots = declaredRoots(manifest);
207
+ if (roots.length !== 1) {
208
+ throw new Error(
209
+ `ingest registry: "${folderId}/vgai.project.json" must declare exactly one root ` +
210
+ `(found ${roots.length}).`,
211
+ );
212
+ }
213
+ const root = roots[0]!;
214
+ if (root.adapter.type !== 'ingest') {
215
+ throw new Error(
216
+ `ingest registry: "${folderId}/vgai.project.json"'s root must be an { ingest } root ` +
217
+ `(found adapter identity "${root.adapter.identity}").`,
218
+ );
219
+ }
220
+ // `root.adapter.type === 'ingest'` is proven above; TS cannot narrow the
221
+ // whole root through a nested discriminant, so restate it once, here, rather
222
+ // than in each per-surface builder.
223
+ const ingestRoot = root as IngestRoot;
224
+ return { folderId, manifest, root: ingestRoot, surface: ingestRoot.adapter.surface };
225
+ }
226
+
227
+ let _cache: IngestEntry[] | null = null;
228
+
229
+ /** Every in-tree ingest fixture, whatever its surface, sorted by folder id. */
230
+ export function discoverIngestEntries(): IngestEntry[] {
231
+ if (!_cache) {
232
+ _cache = Object.entries(manifestModules)
233
+ .map(([key, raw]) => buildEntry(folderIdFromManifestKey(key), raw))
234
+ .sort((a, b) => a.folderId.localeCompare(b.folderId));
235
+ }
236
+ return _cache;
237
+ }
238
+
239
+ /** Every in-tree fixture on one surface — what each per-surface builder reads. */
240
+ export function ingestEntriesOnSurface(surface: IngestSurface): IngestEntry[] {
241
+ return discoverIngestEntries().filter((e) => e.surface === surface);
242
+ }
243
+
244
+ /**
245
+ * The rendering library behind an ingest root, as METADATA — for a report line
246
+ * or a gallery badge, never as a mount discriminator. Derived from the adapter
247
+ * identity the manifest schema resolves, which is why a surface with no
248
+ * library-specific identity honestly answers `undefined` rather than guessing.
249
+ */
250
+ export function libraryOf(entry: IngestEntry): string | undefined {
251
+ switch (entry.root.adapter.identity) {
252
+ case 'ingest-three':
253
+ return 'three.js';
254
+ case 'ingest-pixi':
255
+ return 'PixiJS';
256
+ case 'ingest-react':
257
+ return 'React';
258
+ default:
259
+ return undefined;
260
+ }
261
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * "Extract Component…" — P1 structural extraction.
3
+ *
4
+ * The applicability rule and every sentence this action can say, kept pure and
5
+ * adapter-free for the same reason `instance-fork-actions.ts` (H7) is — the
6
+ * wiring that knows about adapters lives in
7
+ * `authoring/instance-extract-menu.ts`, and the SOURCE REWRITE lives
8
+ * server-side in `ui-source/plan-extract-component.ts`.
9
+ *
10
+ * FORK AND EXTRACT ARE COMPLEMENTS, split by the row's role. A COMPONENT
11
+ * instance row already has a definition module — customizing it is H7's fork
12
+ * (copy the module, retarget the tag). A NATIVE subtree row (`<group>`,
13
+ * `<mesh>` — assembled in place, no module of its own) is what THIS action
14
+ * serves: the subtree becomes a component under `src/prefabs/` with a portable
15
+ * CSF story, and the row becomes an ordinary instance. The two applicability
16
+ * predicates are disjoint on the same `role` field, so a hierarchy row offers
17
+ * at most one of the pair.
18
+ *
19
+ * The trailing ellipsis follows the same platform convention fork records: an
20
+ * action that writes NEW FILES — this one writes two.
21
+ */
22
+
23
+ import type { InstanceSourceLocator } from '@volter/editor-core/instance-source-actions';
24
+
25
+ export const EXTRACT_COMPONENT_LABEL = 'Extract Component…';
26
+
27
+ /**
28
+ * The adapter surface this action reads. `canExtractComponent` is the
29
+ * adapter's own honest answer to "could I write this?" (a write backend with
30
+ * the extract seam exists and this id has a source element);
31
+ * `extractComponent` performs it and returns the sentence to show.
32
+ */
33
+ export interface InstanceExtractSource extends InstanceSourceLocator {
34
+ readonly canExtractComponent?: ((id: string) => boolean) | undefined;
35
+ readonly extractComponent?: ((id: string, name?: string) => Promise<string>) | undefined;
36
+ }
37
+
38
+ /**
39
+ * Applicability — all about whether the edit is REACHABLE, never about whether
40
+ * it would succeed (only the plan can say, and its refusals are click-time
41
+ * hints):
42
+ *
43
+ * 1. the row is a NATIVE source element, not a component instance (those fork)
44
+ * and not the document row;
45
+ * 2. the row resolves a source location — there is a subtree to extract;
46
+ * 3. the adapter can actually write through the extract seam.
47
+ *
48
+ * An inapplicable row gets NO item rather than a disabled one — the registry
49
+ * idiom fork records.
50
+ */
51
+ export function canExtractNode(
52
+ node: { readonly role?: string | undefined } | null | undefined,
53
+ source: InstanceExtractSource | null | undefined,
54
+ id: string,
55
+ ): boolean {
56
+ // ALLOW-list, not a block-list: only the two native-row roles are subtrees
57
+ // assembled in place. Every other role either has a module already
58
+ // ('component' — fork's subject), is not a source element at all
59
+ // ('folder'/'root'/'document'/'story'), or is still mounting ('boundary').
60
+ if (node?.role !== 'entity' && node?.role !== 'element') return false;
61
+ if (!source || typeof source.extractComponent !== 'function') return false;
62
+ if (typeof source.sourceLocation !== 'function' || !source.sourceLocation(id)) return false;
63
+ return source.canExtractComponent?.(id) !== false;
64
+ }
65
+
66
+ // ------------------------------------------------------------------ sentences
67
+
68
+ /** The success hint NAMES BOTH NEW FILES — the author has to know what was
69
+ * created, because undo (which owns the callsite edit) will not remove them. */
70
+ export function extractedHint(
71
+ tag: string,
72
+ newName: string,
73
+ componentPath: string,
74
+ storyPath: string,
75
+ ): string {
76
+ return (
77
+ `Extracted <${tag}> → <${newName}> (${componentPath}, story ${storyPath}). ` +
78
+ 'Undo restores the callsite; the new files stay.'
79
+ );
80
+ }
81
+
82
+ /** The row lost its source element between menu and click. */
83
+ export function extractUnavailableHint(tag: string): string {
84
+ return `<${tag}> is no longer in the source index — there is nothing to extract.`;
85
+ }
86
+
87
+ /** No dev-server write backend in this session (a hosted or read-only tier). */
88
+ export function extractNoBackendHint(tag: string): string {
89
+ return `This session cannot write project source, so <${tag}> cannot be extracted.`;
90
+ }
91
+
92
+ /**
93
+ * The new files were written and the CALLSITE edit then failed. Reported in
94
+ * full: the module and story are inert (nothing imports them), so the failure
95
+ * mode is clutter, not breakage — and the author is told exactly that.
96
+ */
97
+ export function extractPartialHint(
98
+ tag: string,
99
+ newName: string,
100
+ componentPath: string,
101
+ detail: string,
102
+ ): string {
103
+ return (
104
+ `Wrote ${componentPath} (and its story) for <${newName}>, but the <${tag}> callsite was ` +
105
+ `not replaced (${detail}). Nothing imports the new files yet, so they are inert — ` +
106
+ 'delete them or try again.'
107
+ );
108
+ }
109
+
110
+ /** Any other refusal: the plan's own named reason, or a transport failure. */
111
+ export function extractRefusedHint(reason: string): string {
112
+ return `Cannot extract: ${reason}`;
113
+ }
114
+
115
+ /** Did a hint sentence report a LANDED extraction? Owned by this module —
116
+ * the one place the sentences are minted — so the command door's ok/error
117
+ * split cannot drift from the wording it matches. */
118
+ export function isExtractedHint(hint: string): boolean {
119
+ return hint.startsWith('Extracted ');
120
+ }