@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.
- package/LICENSE +661 -0
- package/NOTICE +23 -0
- package/contributions/asset-budget-asset.menu.ts +26 -0
- package/contributions/asset-budget.action.ts +18 -0
- package/contributions/asset-budget.document.tsx +21 -0
- package/contributions/asset-budget.menu.ts +22 -0
- package/contributions/audio-unlock.service.ts +17 -0
- package/contributions/audio.utility.tsx +15 -0
- package/contributions/autoplay.service.ts +48 -0
- package/contributions/bridge.command.ts +172 -0
- package/contributions/build-profiles.document.tsx +21 -0
- package/contributions/build-progress.status.tsx +45 -0
- package/contributions/build.action.ts +26 -0
- package/contributions/build.command.ts +27 -0
- package/contributions/build.header.tsx +37 -0
- package/contributions/build.menu.ts +31 -0
- package/contributions/build.service.ts +31 -0
- package/contributions/connection.status.tsx +43 -0
- package/contributions/coverage.service.ts +118 -0
- package/contributions/edit-mode-audio.service.ts +23 -0
- package/contributions/edit-mode-networking.service.ts +30 -0
- package/contributions/game-document.service.ts +25 -0
- package/contributions/game-eval.command.ts +210 -0
- package/contributions/game.layout.ts +19 -0
- package/contributions/gameplay.command.ts +255 -0
- package/contributions/generation.service.ts +96 -0
- package/contributions/generations.status.tsx +53 -0
- package/contributions/ingest.service.ts +80 -0
- package/contributions/instances.command.ts +75 -0
- package/contributions/navmesh.menu.ts +39 -0
- package/contributions/navmesh.service.ts +19 -0
- package/contributions/network.utility.tsx +17 -0
- package/contributions/play.command.ts +355 -0
- package/contributions/profiler.action.ts +16 -0
- package/contributions/profiler.menu.ts +22 -0
- package/contributions/profiler.utility.tsx +17 -0
- package/contributions/react/component-board.service.ts +21 -0
- package/contributions/react/design-time-mount.service.ts +60 -0
- package/contributions/react/pasteboard.action.ts +41 -0
- package/contributions/react/react-inspector.service.ts +85 -0
- package/contributions/react/story-documents.service.ts +44 -0
- package/contributions/scene-document.service.ts +32 -0
- package/contributions/state-watch.action.ts +17 -0
- package/contributions/state-watch.menu.ts +23 -0
- package/contributions/state-watch.utility.tsx +20 -0
- package/contributions/team-playtest.service.ts +124 -0
- package/contributions/three/camera-runtime.inspector.tsx +34 -0
- package/contributions/three/component-board.service.ts +24 -0
- package/contributions/three/component-verbs.command.ts +110 -0
- package/contributions/three/component-verbs.service.ts +92 -0
- package/contributions/three/constraints.inspector.tsx +34 -0
- package/contributions/three/model-asset-sections.service.ts +26 -0
- package/contributions/three/reflection-probe-capture.inspector.tsx +32 -0
- package/contributions/three/story-documents.service.ts +31 -0
- package/contributions/three/three-authoring.service.ts +66 -0
- package/contributions/transport.header.tsx +19 -0
- package/contributions/xstate-behavior.action.ts +42 -0
- package/contributions/xstate-behavior.document.tsx +73 -0
- package/contributions/xstate-behavior.inspector.tsx +28 -0
- package/contributions/xstate-behavior.menu.ts +23 -0
- package/package.json +144 -0
- package/src/asset-budget/AssetBudgetPanel.tsx +1172 -0
- package/src/asset-budget/asset-budget-model.ts +799 -0
- package/src/asset-budget/basis-encoder.ts +165 -0
- package/src/asset-budget/gltf-io.ts +154 -0
- package/src/asset-budget/gltf-optimize.ts +384 -0
- package/src/asset-budget/image-dims.ts +78 -0
- package/src/asset-budget/optimize-apply.ts +221 -0
- package/src/audio/AudioDebuggerPanel.tsx +457 -0
- package/src/audio/audio-debugger-model.ts +87 -0
- package/src/bridge/call.ts +60 -0
- package/src/bridge/dispatch.ts +459 -0
- package/src/bridge/live-frames.ts +25 -0
- package/src/bridge/screenshot.ts +316 -0
- package/src/build/BuildProfilesPanel.tsx +476 -0
- package/src/build/build-session.ts +247 -0
- package/src/build/format-bytes.ts +14 -0
- package/src/command-results.ts +36 -0
- package/src/coverage/live-authoring-surface.ts +30 -0
- package/src/coverage/live-project-verbs.ts +162 -0
- package/src/coverage/native-system-coverage.ts +143 -0
- package/src/coverage/root-coverage.ts +79 -0
- package/src/coverage/session-coverage.ts +193 -0
- package/src/design-system-stories/ApplicationChrome.stories.tsx +100 -0
- package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +406 -0
- package/src/edit-mode/edit-mode-audio.ts +56 -0
- package/src/edit-mode/edit-mode-networking.ts +112 -0
- package/src/game-document/DevicePresetPicker.tsx +84 -0
- package/src/game-document/GameCaptureFrameButton.tsx +45 -0
- package/src/game-document/GameDocument.tsx +215 -0
- package/src/game-document/GamePanel.tsx +662 -0
- package/src/game-document/InstanceInspectorPicker.tsx +183 -0
- package/src/game-document/crowd-debug.ts +183 -0
- package/src/game-document/device-preview.ts +336 -0
- package/src/game-document/game-view-store.ts +107 -0
- package/src/game-document/physics-debug.ts +187 -0
- package/src/generation/GenerationActivity.tsx +421 -0
- package/src/generation/GenerationGallery.css +231 -0
- package/src/generation/generation-documents.tsx +338 -0
- package/src/generation/generation-jobs.ts +128 -0
- package/src/generation/generation-presentation.ts +257 -0
- package/src/host/adapter-reach.ts +445 -0
- package/src/host/adapter-runtime-bindings.ts +303 -0
- package/src/host/after-paint.ts +112 -0
- package/src/host/api/configurations.ts +66 -0
- package/src/host/authoring/babylon-authoring-adapter.ts +703 -0
- package/src/host/authoring/canvas-runtime-recognition.ts +50 -0
- package/src/host/authoring/contract-hierarchy-authoring.ts +144 -0
- package/src/host/authoring/contract-scenes-stories.ts +204 -0
- package/src/host/authoring/creation-site-related.ts +55 -0
- package/src/host/authoring/ephemeral-persistence.ts +29 -0
- package/src/host/authoring/gesture-persist.ts +84 -0
- package/src/host/authoring/ingest-data-writer.ts +232 -0
- package/src/host/authoring/ingest-source-persistence.ts +826 -0
- package/src/host/authoring/mount-isolated-pixi-screen.ts +250 -0
- package/src/host/authoring/mounted-authoring.ts +41 -0
- package/src/host/authoring/owned-pixi-ticker-listeners.ts +96 -0
- package/src/host/authoring/phaser-live-authoring-adapter.ts +265 -0
- package/src/host/authoring/pixi-authoring-adapter.ts +1554 -0
- package/src/host/authoring/pixi-creation-site-write-target.ts +60 -0
- package/src/host/authoring/pixi-isolation-assets.ts +25 -0
- package/src/host/authoring/pixi-live-write-target.ts +979 -0
- package/src/host/authoring/pixi-source-identity.ts +141 -0
- package/src/host/authoring/pixi-still-presentation.ts +71 -0
- package/src/host/authoring/pixi-structure-history.ts +237 -0
- package/src/host/authoring/pixi-transform-channels.ts +205 -0
- package/src/host/authoring/selection-remount-handoff.ts +23 -0
- package/src/host/authoring/source-persistence-backend.ts +373 -0
- package/src/host/authoring/source-refresh-revisions.ts +81 -0
- package/src/host/authoring/struct-write-pipe.ts +143 -0
- package/src/host/auto-frame-window.ts +89 -0
- package/src/host/binding-resolver.ts +393 -0
- package/src/host/browser-transpile.ts +631 -0
- package/src/host/canvas-entry-runtime.ts +95 -0
- package/src/host/components/CameraAuthoringOverlay.tsx +216 -0
- package/src/host/components/HeaderTelemetry.tsx +341 -0
- package/src/host/components/PixiIsolationSceneContent.tsx +280 -0
- package/src/host/components/ResolutionPicker.tsx +89 -0
- package/src/host/components/ThreeIsolationSceneContent.tsx +180 -0
- package/src/host/components/frame-debugger-model.ts +579 -0
- package/src/host/components/header-telemetry-model.ts +74 -0
- package/src/host/components/scene-document.tsx +447 -0
- package/src/host/components/utility-view-state.ts +87 -0
- package/src/host/components/world-root-stage-binding.tsx +133 -0
- package/src/host/components/world-root-stage.ts +1046 -0
- package/src/host/coverage/authoring-read-probe.ts +583 -0
- package/src/host/coverage/capability-coverage.ts +1587 -0
- package/src/host/coverage/coverage-accounting.ts +261 -0
- package/src/host/coverage/game-contract-seam-evidence.ts +82 -0
- package/src/host/coverage/project-verb-coverage.ts +148 -0
- package/src/host/coverage/system-adapter-coverage.ts +373 -0
- package/src/host/design-system-stories/StoryLayout.tsx +104 -0
- package/src/host/design-system-stories/fixtures/authoring.ts +247 -0
- package/src/host/design-system-stories/fixtures/editor-runtime.tsx +115 -0
- package/src/host/document-preview-three.ts +110 -0
- package/src/host/entry-adjudication.ts +89 -0
- package/src/host/game-location-guard.ts +150 -0
- package/src/host/game-module-access.ts +196 -0
- package/src/host/game-realm-page.ts +358 -0
- package/src/host/game-realm-reclaim.ts +55 -0
- package/src/host/game-realm-storage.ts +104 -0
- package/src/host/gameplay-export.ts +288 -0
- package/src/host/gameplay-recording.ts +717 -0
- package/src/host/gated-globals.ts +1511 -0
- package/src/host/history/json-history-resource.ts +254 -0
- package/src/host/ingest/registry.ts +261 -0
- package/src/host/instance-extract-actions.ts +120 -0
- package/src/host/instance-fork-actions.ts +120 -0
- package/src/host/play-control-hook.ts +26 -0
- package/src/host/playwright-shim.ts +479 -0
- package/src/host/projection/dom.ts +295 -0
- package/src/host/projection/pixi.ts +288 -0
- package/src/host/r3f-entry-runtime.ts +77 -0
- package/src/host/react-mount-runtime.ts +162 -0
- package/src/host/realm-services.ts +148 -0
- package/src/host/recording-preview.ts +106 -0
- package/src/host/roots/module-root.ts +203 -0
- package/src/host/roots/react-root.ts +181 -0
- package/src/host/same-realm-loop-gate.ts +544 -0
- package/src/host/scene-view-drawability.ts +69 -0
- package/src/host/sdk/tools.ts +31 -0
- package/src/host/served-bundle-runtime-modules.ts +331 -0
- package/src/host/server-log-bridge.ts +60 -0
- package/src/host/staged-projects.ts +24 -0
- package/src/host/stories/mounted-story-viewport-source.ts +64 -0
- package/src/host/stories/story-arg-descriptors.ts +50 -0
- package/src/host/stories/story-media-presence.ts +152 -0
- package/src/host/surface-content.ts +87 -0
- package/src/host/take-named-export.ts +23 -0
- package/src/host/three-ingest-runtime.ts +76 -0
- package/src/host/types-fastnoise-lite.d.ts +7 -0
- package/src/host/types-mikktspace.d.ts +20 -0
- package/src/host/types-troika-three-text.d.ts +7 -0
- package/src/host/use-active-performance-source.ts +53 -0
- package/src/host/viewport-pose-memory.ts +48 -0
- package/src/host/viewport-root-presentation.ts +40 -0
- package/src/ingest/active-ingest.ts +251 -0
- package/src/ingest/active-scene-navigation.ts +43 -0
- package/src/ingest/authoring/ingest-dom-surface-authoring.ts +191 -0
- package/src/ingest/authoring/ingest-root-adapter.ts +897 -0
- package/src/ingest/capture-wait-report.ts +122 -0
- package/src/ingest/deferred-ingest-play.ts +216 -0
- package/src/ingest/deferred-ingest-session.ts +23 -0
- package/src/ingest/discovery-public-ingest.ts +242 -0
- package/src/ingest/dom-stub-mark.ts +7 -0
- package/src/ingest/entry-load.ts +56 -0
- package/src/ingest/game-contract-realm.ts +34 -0
- package/src/ingest/game-pointer-lock.ts +89 -0
- package/src/ingest/held-scene-repaint.ts +73 -0
- package/src/ingest/host-surface-box.ts +174 -0
- package/src/ingest/ingest-boot-viewport.ts +54 -0
- package/src/ingest/ingest-canvas-scene-document.tsx +177 -0
- package/src/ingest/ingest-canvas-scene.ts +50 -0
- package/src/ingest/ingest-evidence-hook.ts +131 -0
- package/src/ingest/ingest-frame-snapshot.ts +183 -0
- package/src/ingest/ingest-play-commands.ts +134 -0
- package/src/ingest/ingest-play-control.ts +299 -0
- package/src/ingest/ingest-render-debug.ts +320 -0
- package/src/ingest/ingest-siblings.ts +487 -0
- package/src/ingest/ingest-status.ts +62 -0
- package/src/ingest/live-ingest-facet.ts +46 -0
- package/src/ingest/module-mode.ts +229 -0
- package/src/ingest/mount-canvas-ingest-root.ts +899 -0
- package/src/ingest/mount-coverage.ts +296 -0
- package/src/ingest/mount-dom-ingest-root.ts +282 -0
- package/src/ingest/mount-ingest-root.ts +744 -0
- package/src/ingest/mount-three-ingest-root.ts +366 -0
- package/src/ingest/resolve-canvas.ts +47 -0
- package/src/ingest/resolve-three.ts +123 -0
- package/src/ingest/served-bundle.ts +109 -0
- package/src/ingest/served-html-boot.ts +292 -0
- package/src/ingest/surface-canvas.ts +84 -0
- package/src/ingest/surface-dom.ts +84 -0
- package/src/ingest/surface-three.ts +128 -0
- package/src/ingest/types.ts +76 -0
- package/src/ingest/unmount-ingest-root.ts +224 -0
- package/src/navmesh/navmesh-actions.ts +27 -0
- package/src/navmesh/navmesh-handler.ts +237 -0
- package/src/navmesh/navmesh-workflow-store.ts +78 -0
- package/src/network/NetworkInspectorPanel.tsx +644 -0
- package/src/network/network-inspector-model.ts +225 -0
- package/src/play/play-boot-stall.ts +118 -0
- package/src/play/play-log-events.ts +26 -0
- package/src/play/play-mode.ts +2553 -0
- package/src/play/play-recording.ts +335 -0
- package/src/play/react-play-live-authoring.ts +165 -0
- package/src/play-bar/PlayBar.tsx +488 -0
- package/src/play-bar/PlayerCountPicker.tsx +100 -0
- package/src/profiler/FrameDebuggerPanel.tsx +458 -0
- package/src/profiler/PerformancePanel.tsx +1068 -0
- package/src/profiler/ProfilerPanel.tsx +53 -0
- package/src/profiler/frame-debugger-store.ts +97 -0
- package/src/profiler/main-thread-busy.ts +90 -0
- package/src/react/design-time-react-mount.ts +782 -0
- package/src/react/dom-authoring-adapter.ts +764 -0
- package/src/react/pasteboard-materialize.ts +154 -0
- package/src/react/react-inspector-section.tsx +2496 -0
- package/src/react/react-world-authoring-adapter.ts +3795 -0
- package/src/react/story-documents/story-args-section.ts +19 -0
- package/src/react/story-documents/story-documents.tsx +1380 -0
- package/src/react/story-paint-bounds.ts +94 -0
- package/src/react/ui-board-document.tsx +101 -0
- package/src/react/ui-board-title.ts +9 -0
- package/src/react/ui-component-board.ts +72 -0
- package/src/services/audio-pose-guard.ts +81 -0
- package/src/services/game-audio-unlock.ts +48 -0
- package/src/state-watch/StateWatchPanel.tsx +535 -0
- package/src/three/authoring/camera-runtime-inspector-section.tsx +135 -0
- package/src/three/authoring/constraint-inspector-section.tsx +189 -0
- package/src/three/authoring/design-time-renderer.ts +189 -0
- package/src/three/authoring/model-asset-inspector-section.css +41 -0
- package/src/three/authoring/model-asset-inspector-section.tsx +869 -0
- package/src/three/authoring/oid-source-persistence.ts +557 -0
- package/src/three/authoring/r3f-design-session.ts +1174 -0
- package/src/three/authoring/r3f-source-authoring-adapter.ts +5949 -0
- package/src/three/authoring/reflection-probe-inspector-section.tsx +76 -0
- package/src/three/authoring/spatial-audio-handles.ts +215 -0
- package/src/three/authoring/spatial-collider-handles.ts +229 -0
- package/src/three/authoring/spatial-joint-handles.ts +114 -0
- package/src/three/authoring/spatial-light-handles.ts +178 -0
- package/src/three/authoring/spatial-lod-handles.ts +93 -0
- package/src/three/authoring/spatial-particle-handles.ts +368 -0
- package/src/three/authoring/three-authoring-adapter.ts +1953 -0
- package/src/three/authoring/three-scene-identity.ts +19 -0
- package/src/three/authoring/three-spatial-handles.ts +161 -0
- package/src/three/authoring/typed-three-inspector.ts +528 -0
- package/src/three/component-verbs/extract-menu.ts +71 -0
- package/src/three/component-verbs/fork-menu.ts +78 -0
- package/src/three/component-verbs/internals-menu.ts +91 -0
- package/src/three/story-documents/three-story-documents.tsx +607 -0
- package/src/three/three-board/ThreeBoardDocument.tsx +888 -0
- package/src/three/three-board/board-framing.ts +412 -0
- package/src/three/three-board/board-layout.ts +401 -0
- package/src/three/three-board/board-scene.ts +901 -0
- package/src/three/three-board/three-component-board.ts +62 -0
- package/src/xstate/XStateBehaviorSection.tsx +130 -0
- package/src/xstate/XStateMachineInspector.tsx +566 -0
- package/src/xstate/character-animation-machine.fixture.ts +74 -0
- package/src/xstate/live-behaviors.ts +106 -0
- package/src/xstate/use-live-actor-state.ts +44 -0
- package/src/xstate/xstate-graph.ts +235 -0
- 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
|
+
}
|