@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,979 @@
1
+ /**
2
+ * The LIVE half of the canvas surface's persistence axis
3
+ * ({@link CanvasWriteTarget}) — a display tree the editor did not author the
4
+ * JSX of, driven through the running `PIXI.Container`s themselves.
5
+ *
6
+ * WHERE A CLOSED GESTURE GOES IS A PARAMETER, not a property of this file.
7
+ * Constructed with no {@link LiveCanvasWriteTargetOptions.persistence} backend
8
+ * it is exactly what it always was: edits apply to the running tree (and,
9
+ * where a body owns the transform, through the freeze→commit→unfreeze physics
10
+ * handshake) and are journaled for UNDO within the session, never saved.
11
+ * Constructed WITH one — which is what
12
+ * `createCreationSiteCanvasWriteTarget` hands it — a closed gesture is offered
13
+ * to the game's OWN SOURCE first, at the line that constructed the object, and
14
+ * only falls back to the session journal when the backend refuses, with the
15
+ * refusal's own reason reported. That is the same seam the three lane uses
16
+ * (`source-persistence-backend.ts`), not a second one: the ingest-authoring
17
+ * model's "every edit edits the game's own CODE or DATA" is one rule, and it
18
+ * would not survive two implementations.
19
+ *
20
+ * A REFUSAL IS PER-EDIT AND CARRIES ITS REASON. Nothing here decides whether a
21
+ * write is possible — `creation-site-edit.ts` decides it on the server against
22
+ * the game's real bytes — and nothing here retries with looser rules or writes
23
+ * anywhere else. The sentence the backend returns is what
24
+ * `transformEditability` shows before the gesture and what the console reports
25
+ * after it.
26
+ *
27
+ * Extracted verbatim from the class this replaces: it was a SECOND canvas
28
+ * adapter keyed on provenance, which ARCHITECTURE-CORE §Rules names the
29
+ * forbidden shape. Its live behaviour is unchanged; only its place in the seam
30
+ * moved, and the persistence parameter is what this unit added.
31
+ */
32
+
33
+ import type {
34
+ AuthoringAdapter2D,
35
+ Overlay2D,
36
+ Transform2DValue,
37
+ } from '@volter/game-runtime/pixi/authoring';
38
+ import type { PhysicsAdapter2D } from '@volter/game-runtime/pixi/system-adapters';
39
+ import type {
40
+ ComponentInstanceApplyResult,
41
+ ComponentInstanceDescription,
42
+ ComponentInstanceOverride,
43
+ ComponentInstancesProvider,
44
+ NodeCreationSite,
45
+ PersistenceProvider,
46
+ PropertyDescriptor,
47
+ TransformChannel,
48
+ TransformEditability,
49
+ } from '@volter/editor-project/adapter';
50
+ import type { Container } from 'pixi.js';
51
+ import type { ChannelValue, CreationSiteLiteralReport } from '@volter/editor-core/creation-site-edit';
52
+ import { creationSiteAnchor, instancesAtSite } from '@volter/editor-core/creation-site-registry';
53
+ import { editorConsole } from '@volter/editor-core/editor-console';
54
+ import { JsonHistoryResource } from '../history/json-history-resource';
55
+ import { createEphemeralPersistence } from './ephemeral-persistence';
56
+ import { multiChannelRefusal, persistChannelWrite } from './gesture-persist';
57
+ import type { CanvasWriteContext, CanvasWriteTarget } from './pixi-authoring-adapter';
58
+ import { transform2DChanged } from './pixi-transform-channels';
59
+ import type { SourcePersistenceBackend, SourceWriteSubject } from './source-persistence-backend';
60
+ import {
61
+ LIVE_ONLY_ACK,
62
+ LIVE_ONLY_DESTINATION,
63
+ resolvesLiveOnly,
64
+ runWritePipe,
65
+ type WriteAck,
66
+ type WriteResolution,
67
+ } from '@volter/editor-core/authoring/write-pipe';
68
+
69
+ interface PixiLiveHistoryState {
70
+ overlay: Overlay2D;
71
+ nodes: Record<
72
+ string,
73
+ {
74
+ label: string;
75
+ alpha: number;
76
+ visible: boolean;
77
+ tint?: number;
78
+ transform?: Transform2DValue;
79
+ /** The node's own transform ORIGIN — a container's pivot in local units,
80
+ * a sprite's normalized anchor. Journaled because an origin move is an
81
+ * authored edit like any other, and a snapshot that omitted it would
82
+ * record a transaction whose undo silently restored nothing. */
83
+ pivot?: readonly [number, number];
84
+ anchor?: readonly [number, number];
85
+ }
86
+ >;
87
+ }
88
+
89
+ /** The point-shaped members this target reads and writes as a pair of numbers.
90
+ * A Pixi `ObservablePoint` is not structured-cloneable, so history holds the
91
+ * two numbers and puts them back through the same setter an edit uses. */
92
+ function pointOf(value: unknown): readonly [number, number] | undefined {
93
+ if (!value || typeof value !== 'object') return undefined;
94
+ const point = value as { x?: unknown; y?: unknown };
95
+ return typeof point.x === 'number' && typeof point.y === 'number'
96
+ ? [point.x, point.y]
97
+ : undefined;
98
+ }
99
+
100
+ /** Put one journaled origin back through the same setter an edit uses — and
101
+ * only on an object that HAS that member, so restoring a container's snapshot
102
+ * never invents a sprite anchor. */
103
+ function restoreOrigin(
104
+ display: Container,
105
+ member: 'pivot' | 'anchor',
106
+ value: readonly [number, number] | undefined,
107
+ ): void {
108
+ const shaped = display as Container & Record<string, unknown>;
109
+ if (!value || !pointOf(shaped[member])) return;
110
+ shaped[member] = { x: value[0], y: value[1] };
111
+ }
112
+
113
+ function numToHex(n: number): string {
114
+ return `#${(n & 0xffffff).toString(16).padStart(6, '0')}`;
115
+ }
116
+ function hexToNum(hex: string): number {
117
+ return Number.parseInt(hex.replace('#', ''), 16);
118
+ }
119
+
120
+ /** The three transform channels, in the order `endTransformEdit` reports them. */
121
+ const TRANSFORM_CHANNELS = ['position', 'rotation', 'scale'] as const;
122
+
123
+ export interface LiveCanvasWriteTargetOptions {
124
+ /** Transform ownership + the freeze/commit/unfreeze handshake, so a drag
125
+ * sticks instead of being stomped by the next physics step. */
126
+ readonly physics: PhysicsAdapter2D;
127
+ /**
128
+ * Where a closed gesture's value goes. ABSENT ⇒ live-only for the session,
129
+ * which is the honest answer for a tree with no reachable source; present ⇒
130
+ * the game's own source, per edit, when every gate the backend owns is open.
131
+ */
132
+ readonly persistence?: SourcePersistenceBackend | undefined;
133
+ }
134
+
135
+ export function createLiveCanvasWriteTarget(
136
+ options: LiveCanvasWriteTargetOptions,
137
+ ): CanvasWriteTarget {
138
+ const { physics } = options;
139
+ const persist = options.persistence ?? null;
140
+ let a2d: AuthoringAdapter2D;
141
+ let notify: () => void = () => {};
142
+ let historyResource: JsonHistoryResource<PixiLiveHistoryState> | null = null;
143
+ let editStartState: PixiLiveHistoryState | undefined;
144
+ /** The transform in force when the current gesture began — the value the
145
+ * creation-site planner's equality backstop is checked against. */
146
+ let editBaseline: Transform2DValue | undefined;
147
+ /**
148
+ * Did the current gesture move the node's ORIGIN (pivot/anchor)?
149
+ *
150
+ * The origin handle writes two things at once — the origin itself and a
151
+ * compensating `position` that keeps the drawn content still — and the origin
152
+ * is NOT one of `TRANSFORM_CHANNELS`. Counting only those would see a lone
153
+ * `position` move, write it into the game's source, and leave the origin half
154
+ * in the session alone; one Ctrl+Z would then undo half the drag, which is the
155
+ * exact failure `endTransformEdit`'s multi-property refusal exists to prevent.
156
+ * So the origin counts as a moved property there.
157
+ */
158
+ let editMovedOrigin = false;
159
+ /** Has this session already raised the "structural edits stay live-only"
160
+ * warning? (See `reportStructureLiveOnly`.) */
161
+ let structureLiveOnlyAnnounced = false;
162
+
163
+ const captureHistoryState = (): PixiLiveHistoryState => {
164
+ const nodes: PixiLiveHistoryState['nodes'] = {};
165
+ const visit = (id: string): void => {
166
+ const node = a2d.node(id);
167
+ const display = a2d.displayObject(id) as (Container & { tint?: number }) | null;
168
+ if (!node || !display) return;
169
+ let transform: Transform2DValue | null = null;
170
+ try {
171
+ transform = a2d.getTransform(id);
172
+ } catch {
173
+ // A custom adapter may expose inspectable container-like nodes without
174
+ // Pixi transform observables. Their non-transform fields stay journaled.
175
+ }
176
+ const pivot = pointOf((display as { pivot?: unknown }).pivot);
177
+ const anchor = pointOf((display as { anchor?: unknown }).anchor);
178
+ nodes[id] = {
179
+ label: display.label ?? '',
180
+ alpha: display.alpha,
181
+ visible: display.visible,
182
+ ...('tint' in display ? { tint: display.tint } : {}),
183
+ ...(transform ? { transform } : {}),
184
+ ...(pivot ? { pivot } : {}),
185
+ ...(anchor ? { anchor } : {}),
186
+ };
187
+ for (const childId of node.childIds) visit(childId);
188
+ };
189
+ for (const root of a2d.roots()) visit(root.id);
190
+ return { overlay: structuredClone(a2d.serializeOverlay()), nodes };
191
+ };
192
+
193
+ const restoreHistoryState = (state: PixiLiveHistoryState): void => {
194
+ for (const [id, value] of Object.entries(state.nodes)) {
195
+ const display = a2d.displayObject(id) as (Container & { tint?: number }) | null;
196
+ if (!display) continue;
197
+ display.label = value.label;
198
+ display.alpha = value.alpha;
199
+ display.visible = value.visible;
200
+ if (value.tint !== undefined && 'tint' in display) display.tint = value.tint;
201
+ restoreOrigin(display, 'pivot', value.pivot);
202
+ restoreOrigin(display, 'anchor', value.anchor);
203
+ if (value.transform) a2d.setTransform(id, value.transform);
204
+ }
205
+ a2d.replaceOverlay(state.overlay);
206
+ notify();
207
+ };
208
+
209
+ /**
210
+ * JOURNALED LOUDLY. A rejected `record(...)` means the edit IS applied to the
211
+ * live tree and has NO history entry: the user sees the value they changed
212
+ * and Ctrl+Z does nothing, with nothing said. Same statement (and same
213
+ * wording) as the three lane's `recordHistory`
214
+ * (`three-authoring-adapter.ts`) and the canvas structure lane's
215
+ * `CanvasStructureHistory.run`.
216
+ *
217
+ * WHY THE REPORT IS HERE AND NOT IN THE VERB'S ACK. Every value verb funnels
218
+ * through this function, and the ones that carry an ack — `set`,
219
+ * `endTransformEdit` — reach it on the arm where the pipe resolved LIVE-ONLY,
220
+ * whose ack answers a different question (where the BYTES went — nowhere, by
221
+ * design, on that arm) and cannot carry a journaling failure without claiming
222
+ * it was a persistence one. The rest (`revertOverrides`, the unpersisted
223
+ * `set`/`endTransformEdit` paths) return `void`, so no caller can await this
224
+ * write at all.
225
+ */
226
+ const recordHistory = (label: string, before: PixiLiveHistoryState | undefined): void => {
227
+ if (historyResource && before) {
228
+ void historyResource.record(label, before, captureHistoryState()).catch((error: unknown) => {
229
+ editorConsole.error(
230
+ `[canvas] failed to journal "${label}" into project history — the edit is applied ` +
231
+ `but has no history entry (it cannot be undone): ${
232
+ error instanceof Error ? error.message : String(error)
233
+ }`,
234
+ 'authoring',
235
+ );
236
+ });
237
+ }
238
+ };
239
+
240
+ const writeProp = (id: string, path: string, value: unknown): void => {
241
+ const display = a2d.displayObject(id) as (Container & Record<string, unknown>) | null;
242
+ if (!display) return;
243
+ if (path === 'name') a2d.set(id, 'label', value as string);
244
+ else if (path === 'tint') a2d.set(id, 'tint', hexToNum(value as string));
245
+ else if (path === 'visible') a2d.set(id, 'visible', value as boolean);
246
+ else if (path === 'alpha') a2d.set(id, 'alpha', value as number);
247
+ else display[path] = value;
248
+ notify();
249
+ };
250
+
251
+ const readProp = (id: string, path: string): unknown => {
252
+ const display = a2d.displayObject(id) as (Container & Record<string, unknown>) | null;
253
+ if (!display) return undefined;
254
+ if (path === 'name') return display.label;
255
+ if (path === 'tint') return numToHex((display as { tint?: number }).tint ?? 0xffffff);
256
+ return display[path];
257
+ };
258
+
259
+ // ───────────────────────────────────────────── the persistence collaboration
260
+
261
+ /**
262
+ * One property's value in the shape the creation-site planner speaks.
263
+ *
264
+ * Pixi's own vocabulary, not the editor's neutral 3D transform: a canvas
265
+ * `position` is TWO numbers and a canvas `rotation` is ONE scalar in radians,
266
+ * which is what the game's own source holds and therefore what a literal at a
267
+ * creation site can be compared against. Translating to the neutral transform
268
+ * here would compare a quaternion to a number.
269
+ */
270
+ const readChannel = (id: string, property: string): ChannelValue | undefined => {
271
+ const transform = a2d.getTransform(id);
272
+ if (property === 'position') return transform ? [...transform.position] : undefined;
273
+ if (property === 'rotation') return transform ? transform.rotation : undefined;
274
+ if (property === 'scale') return transform ? [...transform.scale] : undefined;
275
+ return readProp(id, property) as ChannelValue | undefined;
276
+ };
277
+
278
+ /** Put one property back on the live object — the undo/redo half of a
279
+ * persisted edit, routed through the SAME writers an ordinary edit uses. */
280
+ const applyChannel = (id: string, property: string, value: ChannelValue): void => {
281
+ if (property === 'position' && Array.isArray(value)) {
282
+ a2d.setTransform(id, { position: [value[0] ?? 0, value[1] ?? 0] });
283
+ } else if (property === 'rotation' && typeof value === 'number') {
284
+ a2d.setTransform(id, { rotation: value });
285
+ } else if (property === 'scale' && Array.isArray(value)) {
286
+ a2d.setTransform(id, { scale: [value[0] ?? 1, value[1] ?? 1] });
287
+ } else {
288
+ writeProp(id, property, value);
289
+ return;
290
+ }
291
+ notify();
292
+ };
293
+
294
+ /**
295
+ * The creation site an edit to `property` would have to be written at, plus
296
+ * how many objects that site built.
297
+ *
298
+ * There is no OWNER HOP on this surface, and that is a fact rather than an
299
+ * omission: a Pixi display object holds its own tint, alpha and visibility —
300
+ * there is no separate material object with its own `new`, which is the case
301
+ * that forces `ChannelOwner` to exist on three.
302
+ */
303
+ const subjectFor = (id: string, property: string): SourceWriteSubject => {
304
+ const anchor = creationSiteAnchor(a2d.displayObject(id) ?? null);
305
+ return {
306
+ entityId: id,
307
+ property,
308
+ surface: 'pixi',
309
+ anchor,
310
+ // This surface plans ONE lane and its absence: a construction literal in
311
+ // the game's own module, or nothing at all. There is no serve-time prop
312
+ // stamp here and no data-record anchor, and claiming either would be a
313
+ // kind this target cannot write.
314
+ anchorKind: anchor.anchored ? 'construction-literal' : 'live-only',
315
+ instances: anchor.anchored && anchor.kind === 'source' ? instancesAtSite(anchor) : 0,
316
+ };
317
+ };
318
+
319
+ const labelOf = (id: string): string => a2d.node(id)?.label ?? '2D Object';
320
+
321
+ /**
322
+ * Close out one gesture: write it into the game's own source if every gate is
323
+ * open, and otherwise journal it live-only.
324
+ *
325
+ * ONE gesture becomes ONE history entry either way — the persisted path's
326
+ * transaction carries both the file and the live value, so the live-only
327
+ * journal is SKIPPED when it succeeds rather than added to it. Transcribed
328
+ * from `ThreeAuthoringAdapter.persistOrJournal`, including its rule that a
329
+ * gesture which moved more than one channel is refused AS A WHOLE: a partial
330
+ * write would leave one channel in the file and one only in the session, and
331
+ * one Ctrl+Z would then undo half a drag.
332
+ */
333
+ const persistOrJournal = (
334
+ id: string,
335
+ label: string,
336
+ before: PixiLiveHistoryState | undefined,
337
+ property: string | null,
338
+ baseline: ChannelValue | undefined,
339
+ next: ChannelValue | undefined,
340
+ ): Promise<WriteAck> | undefined => {
341
+ if (!persist) return undefined;
342
+ if (property === null || baseline === undefined || next === undefined) {
343
+ // Nothing single-channel to write, so there is no edit for the pipe to
344
+ // carry — the live values still have to be undoable.
345
+ recordHistory(label, before);
346
+ return undefined;
347
+ }
348
+ // The shared kit resolution (`gesture-persist.ts`). NOTE the drift it
349
+ // retired: this copy used to check the LANE before the gate, so a
350
+ // live-only subject whose gate was also shut earned a different refusal
351
+ // sentence here than on the three lane. The documented order — gate
352
+ // first, because it names the precise reason — now holds everywhere.
353
+ return persistChannelWrite({
354
+ backend: persist,
355
+ subject: () => subjectFor(id, property),
356
+ baseline,
357
+ next,
358
+ label,
359
+ journal: () => recordHistory(label, before),
360
+ });
361
+ };
362
+
363
+ const listProperties = (id: string): PropertyDescriptor[] => {
364
+ const props: PropertyDescriptor[] = [
365
+ { path: 'name', label: 'Name', type: 'string' },
366
+ { path: 'visible', label: 'Visible', type: 'boolean' },
367
+ { path: 'alpha', label: 'Alpha', type: 'number' },
368
+ ];
369
+ const display = a2d.displayObject(id);
370
+ if (display && 'tint' in display) props.push({ path: 'tint', label: 'Tint', type: 'color' });
371
+ return props;
372
+ };
373
+
374
+ // ─────────────────────────────────── component instances: the site IS the component
375
+ //
376
+ // On this surface a "component" is not a declared React/prefab component at
377
+ // all — it is the CONSTRUCTION STATEMENT. `new Bubble(0.5, 0.5)` is the
378
+ // declaration, its literals are the defaults, and the live object the editor
379
+ // is showing is the instance. So an OVERRIDE is exactly "the live value is not
380
+ // the one that line names", REVERT puts the line's own value back on the
381
+ // object, and APPLY writes the live value INTO the line — which is what makes
382
+ // every sibling that line constructs inherit it on the next load.
383
+ //
384
+ // The literals come from the server (`/__ingest-source/inspect`), because
385
+ // deciding what a literal is means parsing the game's source, and that parser
386
+ // is deliberately not in the browser bundle. `describe` is synchronous, so it
387
+ // answers from a CACHE and asks for a cold one — see `literalsFor`.
388
+
389
+ /** Literals per SITE, not per node: they are a property of the line, and the
390
+ * same line can have constructed several of the nodes being inspected. */
391
+ const literalsBySite = new Map<string, Record<string, CreationSiteLiteralReport>>();
392
+ /** ONE read in flight per site, ever — the answer's promise, so a synchronous
393
+ * `describe` and an awaiting `revert` share the same request rather than
394
+ * racing two. A failed read resolves to `{}` and stays cached for the
395
+ * session, the same direction `loadIngestOwnership` fails in. */
396
+ const literalRequests = new Map<string, Promise<Record<string, CreationSiteLiteralReport>>>();
397
+
398
+ const siteKeyOf = (anchor: NodeCreationSite): string | null =>
399
+ anchor.anchored && anchor.kind === 'source'
400
+ ? `${anchor.file}:${anchor.line}:${anchor.col}`
401
+ : null;
402
+
403
+ /** The channel paths a canvas subject can be described against: its three
404
+ * transform channels plus its own reflected inspector rows. */
405
+ const instanceProperties = (id: string): ReadonlyArray<{ path: string; label: string }> => [
406
+ { path: 'position', label: 'Position' },
407
+ { path: 'rotation', label: 'Rotation' },
408
+ { path: 'scale', label: 'Scale' },
409
+ ...listProperties(id).map((property) => ({ path: property.path, label: property.label })),
410
+ ];
411
+
412
+ /** Read the site once, for the whole subject, and notify when it lands so the
413
+ * inspector draws the description on the next render. */
414
+ const loadLiterals = (
415
+ id: string,
416
+ key: string,
417
+ anchor: NodeCreationSite,
418
+ instances: number,
419
+ ): Promise<Record<string, CreationSiteLiteralReport>> => {
420
+ const existing = literalRequests.get(key);
421
+ if (existing) return existing;
422
+ const read = persist?.readSiteLiterals;
423
+ const properties = instanceProperties(id)
424
+ .map((property) => ({ property: property.path, live: readChannel(id, property.path) }))
425
+ .filter(
426
+ (entry): entry is { property: string; live: ChannelValue } => entry.live !== undefined,
427
+ );
428
+ if (!read || properties.length === 0) return Promise.resolve({});
429
+ const request = read({
430
+ anchor,
431
+ instances,
432
+ writeScope: 'creation-site',
433
+ surface: 'pixi',
434
+ properties,
435
+ }).then((answer) => {
436
+ literalsBySite.set(key, answer);
437
+ notify();
438
+ return answer;
439
+ });
440
+ literalRequests.set(key, request);
441
+ return request;
442
+ };
443
+
444
+ /**
445
+ * The site's literals for `id`, or `null` while they are still being read.
446
+ *
447
+ * `null` is an absence of KNOWLEDGE, never a claim that the site names
448
+ * nothing — the caller reports no description at all for it, which is what the
449
+ * shell already reads as "this subject is not a component instance", and the
450
+ * read's own notify brings the real answer one render later.
451
+ */
452
+ const literalsFor = (
453
+ id: string,
454
+ key: string,
455
+ anchor: NodeCreationSite,
456
+ instances: number,
457
+ ): Record<string, CreationSiteLiteralReport> | null => {
458
+ const known = literalsBySite.get(key);
459
+ if (known) return known;
460
+ void loadLiterals(id, key, anchor, instances);
461
+ return null;
462
+ };
463
+
464
+ /** Float slack for "is the live value still the one the line names" — a
465
+ * running game's own arithmetic lands a few ULPs off an authored decimal, and
466
+ * reporting that as an override would put a phantom row in every inspector. */
467
+ const CHANNEL_EPS = 1e-6;
468
+
469
+ const sameChannelValue = (a: ChannelValue, b: ChannelValue): boolean => {
470
+ if (Array.isArray(a) && Array.isArray(b)) {
471
+ return a.length === b.length && a.every((value, index) => sameChannelValue(value, b[index]!));
472
+ }
473
+ if (typeof a === 'number' && typeof b === 'number') return Math.abs(a - b) <= CHANNEL_EPS;
474
+ return a === b;
475
+ };
476
+
477
+ /** The game's OWN name for what this node is: the class its construction
478
+ * statement built. Never a name this editor invented for it. */
479
+ const componentNameOf = (id: string): string => {
480
+ const display = a2d.displayObject(id);
481
+ return display?.constructor?.name || (a2d.node(id)?.kind ?? '2D Object');
482
+ };
483
+
484
+ const affectedInstances = (count: number): string =>
485
+ `${count} ${count === 1 ? 'instance' : 'instances'}`;
486
+
487
+ const overrideOf = (
488
+ id: string,
489
+ property: { path: string; label: string },
490
+ report: CreationSiteLiteralReport | undefined,
491
+ subject: ReturnType<typeof subjectFor>,
492
+ ): ComponentInstanceOverride | null => {
493
+ const live = readChannel(id, property.path);
494
+ if (!report || report.literal === null || live === undefined) return null;
495
+ if (sameChannelValue(report.literal, live)) return null;
496
+ const gate = persist?.gate({
497
+ ...subject,
498
+ property: property.path,
499
+ writeScope: 'creation-site',
500
+ });
501
+ const unavailable = !report.writable ? report.reason : gate?.ok ? undefined : gate?.reason;
502
+ return {
503
+ path: property.path,
504
+ label: property.label,
505
+ value: live,
506
+ ...(report.text ? { defaultText: report.text } : {}),
507
+ canApplyToComponent: unavailable === undefined,
508
+ ...(unavailable === undefined ? {} : { applyUnavailableReason: unavailable }),
509
+ affectedInstanceCount: subject.instances,
510
+ };
511
+ };
512
+
513
+ /**
514
+ * The two values an apply would write — or the reason it cannot happen, in the
515
+ * voice that OWNS that refusal: the planner's words when the source is what
516
+ * stands in the way, the backend gate's when the session mode or ownership is.
517
+ */
518
+ type ApplyPlan =
519
+ | {
520
+ readonly ok: true;
521
+ readonly backend: SourcePersistenceBackend;
522
+ readonly baseline: ChannelValue;
523
+ readonly next: ChannelValue;
524
+ }
525
+ | { readonly ok: false; readonly message: string };
526
+
527
+ const planApply = (
528
+ subject: SourceWriteSubject,
529
+ path: string,
530
+ report: CreationSiteLiteralReport | undefined,
531
+ live: ChannelValue | undefined,
532
+ ): ApplyPlan => {
533
+ if (!persist || !report || report.literal === null || live === undefined) {
534
+ return {
535
+ ok: false,
536
+ message:
537
+ report?.reason ??
538
+ `The construction site names no ${path} for this object, so there is no default to change.`,
539
+ };
540
+ }
541
+ if (!report.writable) {
542
+ return {
543
+ ok: false,
544
+ message: report.reason ?? `The construction site's ${path} cannot be rewritten.`,
545
+ };
546
+ }
547
+ const verdict = persist.gate({ ...subject, writeScope: 'creation-site' });
548
+ if (!verdict.ok) return { ok: false, message: verdict.reason };
549
+ return { ok: true, backend: persist, baseline: report.literal, next: live };
550
+ };
551
+
552
+ /**
553
+ * The site's literals for `id`, AWAITED — what an ACTING caller needs, where
554
+ * `literalsFor` is what a rendering one needs. A revert or an apply is a
555
+ * deliberate act with somewhere to await, so it must not silently do nothing
556
+ * merely because nothing happened to have described this node first (an agent
557
+ * driving the provider through `vgai eval` never does).
558
+ */
559
+ const literalsNow = async (
560
+ id: string,
561
+ ): Promise<Record<string, CreationSiteLiteralReport> | null> => {
562
+ const subject = subjectFor(id, 'position');
563
+ const key = siteKeyOf(subject.anchor);
564
+ if (!key) return null;
565
+ return (
566
+ literalsBySite.get(key) ?? (await loadLiterals(id, key, subject.anchor, subject.instances))
567
+ );
568
+ };
569
+
570
+ const instancesProvider: ComponentInstancesProvider = {
571
+ describe: (id): ComponentInstanceDescription | null => {
572
+ const subject = subjectFor(id, 'position');
573
+ const key = siteKeyOf(subject.anchor);
574
+ if (!key || !a2d.displayObject(id)) return null;
575
+ const literals = literalsFor(id, key, subject.anchor, subject.instances);
576
+ // An EMPTY map is not "the site names nothing" — the server answers one
577
+ // entry per property asked, `literal: null` and all. It is "nothing
578
+ // answered": a tier with no `/__ingest-source/*` route, or a transport
579
+ // that failed. Describing an instance from it would report "using
580
+ // component defaults" about defaults nobody read.
581
+ if (!literals || Object.keys(literals).length === 0) return null;
582
+ const overrides = instanceProperties(id)
583
+ .map((property) => overrideOf(id, property, literals[property.path], subject))
584
+ .filter((override): override is ComponentInstanceOverride => override !== null);
585
+ return {
586
+ componentName: componentNameOf(id),
587
+ ...(subject.anchor.anchored ? { sourcePath: subject.anchor.display } : {}),
588
+ overrides,
589
+ };
590
+ },
591
+
592
+ // REVERT IS A LIVE WRITE, and only a live write: the value being restored is
593
+ // the one the source already holds, so there is nothing to write back to it.
594
+ // One transaction for the batch — reverting three overrides is one Ctrl+Z.
595
+ revert: async (id, paths) => {
596
+ const literals = await literalsNow(id);
597
+ if (!literals) return;
598
+ const before = captureHistoryState();
599
+ const reverted: string[] = [];
600
+ for (const path of paths) {
601
+ const literal = literals[path]?.literal;
602
+ if (literal === null || literal === undefined) continue;
603
+ applyChannel(id, path, literal);
604
+ reverted.push(path);
605
+ }
606
+ if (reverted.length === 0) return;
607
+ recordHistory(
608
+ reverted.length === 1
609
+ ? `Revert ${labelOf(id)} ${reverted[0]}`
610
+ : `Revert ${reverted.length} ${labelOf(id)} overrides`,
611
+ before,
612
+ );
613
+ notify();
614
+ return LIVE_ONLY_ACK;
615
+ },
616
+
617
+ applyToComponent: async (id, path): Promise<ComponentInstanceApplyResult> => {
618
+ const subject = { ...subjectFor(id, path), writeScope: 'creation-site' as const };
619
+ const key = siteKeyOf(subject.anchor);
620
+ const report = (await literalsNow(id))?.[path];
621
+ const live = readChannel(id, path);
622
+ const plan = planApply(subject, path, report, live);
623
+ if (!plan.ok) return { changed: false, message: plan.message };
624
+ const label =
625
+ `Apply ${path} to ${componentNameOf(id)} default ` +
626
+ `(${affectedInstances(subject.instances)})`;
627
+ // BASELINE IS THE LITERAL, not the live value: the planner may only
628
+ // rewrite a literal it can prove is the value in force at that slot, and
629
+ // for this gesture the value in force AT THE SITE is exactly what the site
630
+ // says. The live value is the one being written INTO it.
631
+ const persisted = await plan.backend.write({
632
+ ...subject,
633
+ baseline: plan.baseline,
634
+ next: plan.next,
635
+ label,
636
+ });
637
+ if (!persisted) {
638
+ return {
639
+ changed: false,
640
+ message: `${label} did not land — the editor console names why.`,
641
+ };
642
+ }
643
+ // The line says something new now; the next describe re-reads it.
644
+ if (key) literalsBySite.delete(key);
645
+ notify();
646
+ return {
647
+ changed: true,
648
+ message: `${path} now reads ${JSON.stringify(live)} for all ${affectedInstances(subject.instances)} at ${
649
+ subject.anchor.anchored ? subject.anchor.display : 'its creation site'
650
+ }.`,
651
+ write: {
652
+ destination: subject.anchor.anchored ? subject.anchor.display : 'creation site',
653
+ persisted: true,
654
+ },
655
+ };
656
+ },
657
+ };
658
+
659
+ return {
660
+ provenance: persist
661
+ ? {
662
+ source: 'foreign',
663
+ label: 'creation-site',
664
+ detail:
665
+ 'Live 2D (PixiJS) tree projected directly — an edit is written into the game’s own ' +
666
+ 'source at the line that constructed the object, and says why whenever it cannot be.',
667
+ }
668
+ : {
669
+ source: 'foreign',
670
+ label: 'live-only',
671
+ detail:
672
+ 'Live 2D (PixiJS) tree projected directly — edits apply for this session only and are never saved.',
673
+ },
674
+
675
+ // A creation-site write commits per edit through project history at the
676
+ // moment of the gesture, so there is no whole-document save to flush — but
677
+ // `destination` is still the one-line answer to "where do edits go", and it
678
+ // is a GETTER because holding or releasing the game changes the answer
679
+ // mid-session.
680
+ get persistence(): PersistenceProvider {
681
+ const backend = persist;
682
+ if (!backend) {
683
+ return { ...createEphemeralPersistence(), destination: LIVE_ONLY_DESTINATION };
684
+ }
685
+ return {
686
+ ...createEphemeralPersistence(),
687
+ get destination() {
688
+ return backend.destination();
689
+ },
690
+ };
691
+ },
692
+
693
+ // The READ surface answers from the same literal report the write path uses.
694
+ // A recorded `new` proves WHERE the object came from, but does not prove
695
+ // that this particular property is writable there. While the source read
696
+ // is in flight the lane is deliberately unknown; once it lands, a write
697
+ // the planner says it would accept earns `construction-literal` — a
698
+ // rewritable literal, or an insertable named assignment (`writable` with
699
+ // `literal: null`). A proven refusal is `live-only`, in agreement with
700
+ // the write pipe's actual destination rather than the constructor stamp
701
+ // alone.
702
+ truth: (id, property) => {
703
+ const subject = subjectFor(id, property);
704
+ if (subject.anchorKind === 'live-only') {
705
+ return { site: subject.anchor, writeAnchorKind: 'live-only' };
706
+ }
707
+ const key = siteKeyOf(subject.anchor);
708
+ if (!key) return { site: subject.anchor, writeAnchorKind: 'live-only' };
709
+ const known = literalsBySite.get(key);
710
+ if (!known) {
711
+ void loadLiterals(id, key, subject.anchor, subject.instances);
712
+ return { site: subject.anchor, writeAnchorKind: undefined };
713
+ }
714
+ const report = known[property];
715
+ return {
716
+ site: subject.anchor,
717
+ writeAnchorKind: report?.writable ? 'construction-literal' : 'live-only',
718
+ };
719
+ },
720
+
721
+ // A class-owned Pixi object carries a real component identity in its own
722
+ // constructor. That identity is enough to associate the instance with the
723
+ // project's portable CSF stories (generic PIXI.Container/Sprite names
724
+ // simply match nothing). Keep the source path absent: the construction
725
+ // site is vendor source while the colocated authoring component may be a
726
+ // project-owned TSX facade, and pretending those are the same file would
727
+ // incorrectly defeat the registry's name-based association.
728
+ componentIdentity: (id) => (a2d.displayObject(id) ? { name: componentNameOf(id) } : null),
729
+
730
+ // Present exactly when a backend can READ the game's own source: with no
731
+ // reachable source there are no literals to call defaults, and a description
732
+ // built without them would be the fabrication the doctrine forbids.
733
+ ...(persist?.readSiteLiterals ? { instances: () => instancesProvider } : {}),
734
+
735
+ /**
736
+ * Mid-gesture origin write — see {@link CanvasWriteTarget.writeOrigin}. It
737
+ * pushes no history of its own: `beginTransformEdit` has already captured
738
+ * the before-state (which now carries pivot/anchor), and
739
+ * `endTransformEdit` records the one entry covering the whole gesture.
740
+ */
741
+ writeOrigin(id: string, kind: 'pivot' | 'anchor', value: readonly [number, number]): void {
742
+ historyResource?.assertCanMutate();
743
+ const display = a2d.displayObject(id) as (Container & Record<string, unknown>) | null;
744
+ if (!display || !pointOf(display[kind])) return;
745
+ display[kind] = { x: value[0], y: value[1] };
746
+ editMovedOrigin = true;
747
+ notify();
748
+ },
749
+
750
+ bind(context: CanvasWriteContext): void {
751
+ a2d = context.a2d;
752
+ notify = context.notify;
753
+ const history = context.store.projectHistory;
754
+ historyResource = history
755
+ ? new JsonHistoryResource({
756
+ history,
757
+ kind: 'session-state',
758
+ scope: 'session',
759
+ // The SUBJECT's session, not this mount's — see the ownership block
760
+ // in `history/json-history-resource.ts`. A held canvas world is
761
+ // remounted by HMR and by a re-entered ingest, and its entity ids
762
+ // are identical across both, so its undo stack belongs to the world.
763
+ subject: { ...context.journal, id: `${context.journal.id}/world-2d-edits` },
764
+ displayName: 'World 2D edits',
765
+ capture: captureHistoryState,
766
+ // The snapshot mirrors every live display object, but this is a
767
+ // RUNNING game that moves its own sprites every frame, so the full
768
+ // snapshot stops matching a frame after it is taken. The OVERLAY is
769
+ // this resource's real content — only editor edits touch it — so it
770
+ // alone decides "did something else change this resource?". Without
771
+ // it every transaction fails preflight with `content-conflict` and
772
+ // undo silently does nothing (the three lane paid for this as #81).
773
+ conflictIdentity: (state) => state.overlay,
774
+ restore: async (state) => {
775
+ restoreHistoryState(state);
776
+ },
777
+ })
778
+ : null;
779
+ persist?.attach({ read: readChannel, apply: applyChannel });
780
+ },
781
+
782
+ onReindex(): void {
783
+ // Ids are structural, so a re-walk can move them; nothing here caches an
784
+ // id across one.
785
+ },
786
+
787
+ // A live tree's transform is writable wherever Pixi will accept it, which is
788
+ // everywhere. What the backend adds is the SENTENCE: where this particular
789
+ // object's edit will land, or the named reason it will only live in the
790
+ // session — the per-edit honesty the doctrine asks for, read before the
791
+ // gesture rather than after it. `removable` rides the same answer when the
792
+ // backend declares a removal door that is open for this subject — the flag
793
+ // `compose.ts` turns into the field's `resettable`, which is what lets
794
+ // `remove-inspection-field` reach {@link removeTransform} instead of
795
+ // refusing by name (the three lane's `removableFlag` is the transcribed
796
+ // precedent).
797
+ transformEditability(id: string, channel: TransformChannel): TransformEditability {
798
+ if (!persist) return { writable: true };
799
+ const subject = subjectFor(id, channel);
800
+ const removable =
801
+ subject.anchorKind !== 'live-only' && persist.removal?.available(subject)
802
+ ? { removable: true as const }
803
+ : {};
804
+ return { writable: true, reason: persist.describe(subject), ...removable };
805
+ },
806
+
807
+ beginTransformEdit(id: string): void {
808
+ historyResource?.assertCanMutate();
809
+ const display = a2d.displayObject(id);
810
+ if (display && physics.ownerOf(display) === 'physics') physics.freeze(display);
811
+ editStartState = captureHistoryState();
812
+ editBaseline = persist ? (a2d.getTransform(id) ?? undefined) : undefined;
813
+ editMovedOrigin = false;
814
+ },
815
+
816
+ writeTransform(id: string, next: Transform2DValue): void {
817
+ historyResource?.assertCanMutate();
818
+ const display = a2d.displayObject(id);
819
+ if (display && physics.ownerOf(display) === 'physics') {
820
+ physics.commit(display, next.position, next.rotation);
821
+ }
822
+ a2d.setTransform(id, next);
823
+ notify();
824
+ },
825
+
826
+ // Pushes exactly ONE undo entry per gesture: `writeTransform` fires many
827
+ // times during a live drag (no undo push there), and this fires once on
828
+ // commit, which is where the before/after pair is recorded — and where a
829
+ // source write is attempted.
830
+ endTransformEdit(id: string): void | Promise<WriteAck> {
831
+ const display = a2d.displayObject(id);
832
+ if (display && physics.ownerOf(display) === 'physics') physics.unfreeze(display);
833
+ const label = `Transform ${labelOf(id)}`;
834
+ const before = editStartState;
835
+ const baseline = editBaseline;
836
+ const movedOrigin = editMovedOrigin;
837
+ editStartState = undefined;
838
+ editBaseline = undefined;
839
+ editMovedOrigin = false;
840
+ if (!persist) {
841
+ recordHistory(label, before);
842
+ return;
843
+ }
844
+ const after = a2d.getTransform(id) ?? undefined;
845
+ // `origin` first, and it is not a writable channel — see `editMovedOrigin`.
846
+ const changed: string[] = movedOrigin ? ['origin'] : [];
847
+ if (baseline && after) {
848
+ changed.push(
849
+ ...TRANSFORM_CHANNELS.filter((channel) => transform2DChanged(channel, baseline, after)),
850
+ );
851
+ }
852
+ if (changed.length > 1) {
853
+ persist.report(label, multiChannelRefusal(changed));
854
+ }
855
+ const property = changed.length === 1 && changed[0] !== 'origin' ? changed[0]! : null;
856
+ return persistOrJournal(
857
+ id,
858
+ label,
859
+ before,
860
+ property,
861
+ property ? readChannelOf(baseline, property) : undefined,
862
+ property ? readChannelOf(after, property) : undefined,
863
+ );
864
+ },
865
+
866
+ /**
867
+ * THE REMOVAL DOOR for one transform channel — the byte-absence half the
868
+ * insertion arm makes necessary on this lane too: an authored canvas
869
+ * transform can ADD a `receiver.member.axis = value;` statement for an
870
+ * axis the game's source never named, and only deleting that statement
871
+ * restores byte-absence. Through the SAME pipe every other edit takes,
872
+ * resolving on the backend's removal verb; no live half is journalled (a
873
+ * removal changes the FILE — the running object keeps its value, and the
874
+ * undo is the backend's own project-source transaction). Transcribed from
875
+ * `ThreeAuthoringAdapter.pipedRemove`.
876
+ */
877
+ removeTransform(id: string, channel: TransformChannel): void | Promise<WriteAck> {
878
+ const backend = persist;
879
+ const removal = backend?.removal;
880
+ if (!backend || !removal) return;
881
+ const label = `Remove ${labelOf(id)} ${channel}`;
882
+ return runWritePipe({
883
+ resolve: (): WriteResolution => {
884
+ const subject = subjectFor(id, channel);
885
+ if (subject.anchorKind === 'live-only' || !removal.available(subject)) {
886
+ return resolvesLiveOnly(backend.describe(subject));
887
+ }
888
+ return {
889
+ reaches: 'writer',
890
+ anchorKind: subject.anchorKind,
891
+ destination: backend.destination(),
892
+ write: () => removal.perform({ ...subject, label }),
893
+ };
894
+ },
895
+ record: () => {},
896
+ report: (reason) => {
897
+ if (backend.armed()) backend.report(label, reason);
898
+ },
899
+ });
900
+ },
901
+
902
+ /**
903
+ * ONCE AS A WARNING, THEN AS A LOG — because the fact is a property of the
904
+ * LANE, not of each op.
905
+ *
906
+ * With a backend the user armed, every refused op reports through the same
907
+ * channel a refused transform does: they asked for source writes, so each
908
+ * one that did not land matters individually. Unarmed, the first structural
909
+ * edit of the session raises the warning that reaches the product's own
910
+ * doors (`vgai status`, the session journal, one ack to clear) — a whole
911
+ * class of this author's edits will not be saved, which is exactly what an
912
+ * unresolved warning is for — and every later op logs, because repeating an
913
+ * ack-requiring entry per created node would flood the set with the lane
914
+ * working as designed.
915
+ */
916
+ reportStructureLiveOnly(label: string, reason: string): void {
917
+ if (persist?.armed()) {
918
+ persist.report(label, reason);
919
+ return;
920
+ }
921
+ if (!structureLiveOnlyAnnounced) {
922
+ structureLiveOnlyAnnounced = true;
923
+ editorConsole.warn(
924
+ `[canvas] structural edits in this world are live-only — ${reason}. ` +
925
+ `First one: ${label}.`,
926
+ 'authoring',
927
+ );
928
+ return;
929
+ }
930
+ editorConsole.log(`[canvas] ${label} is live-only — ${reason}.`, 'authoring');
931
+ },
932
+
933
+ properties: listProperties,
934
+
935
+ get(id: string, path: string): unknown {
936
+ return readProp(id, path);
937
+ },
938
+
939
+ set(id: string, path: string, value: unknown): void | Promise<WriteAck> {
940
+ historyResource?.assertCanMutate();
941
+ const before = captureHistoryState();
942
+ const label = `Set ${labelOf(id)} ${path}`;
943
+ if (!persist) {
944
+ writeProp(id, path, value);
945
+ recordHistory(label, before);
946
+ return;
947
+ }
948
+ // Read the baseline BEFORE the write: the creation-site planner may only
949
+ // rewrite a literal it can prove is the value currently in force, and
950
+ // "currently" means before this gesture touched anything.
951
+ const baseline = readChannel(id, path);
952
+ writeProp(id, path, value);
953
+ const next = readChannel(id, path);
954
+ const moved =
955
+ baseline !== undefined &&
956
+ next !== undefined &&
957
+ JSON.stringify(baseline) !== JSON.stringify(next);
958
+ return persistOrJournal(id, label, before, moved ? path : null, baseline, next);
959
+ },
960
+
961
+ dispose(): void {
962
+ historyResource?.dispose();
963
+ historyResource = null;
964
+ persist?.dispose();
965
+ },
966
+ };
967
+ }
968
+
969
+ /** One channel out of a 2D transform, in the planner's own shape. */
970
+ function readChannelOf(
971
+ transform: Transform2DValue | undefined,
972
+ property: string,
973
+ ): ChannelValue | undefined {
974
+ if (!transform) return undefined;
975
+ if (property === 'position') return [...transform.position];
976
+ if (property === 'rotation') return transform.rotation;
977
+ if (property === 'scale') return [...transform.scale];
978
+ return undefined;
979
+ }