@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,826 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creation-site persistence for an ingested game — the client half of
|
|
3
|
+
* "an ingest edit writes the game's OWN source at the line that built the
|
|
4
|
+
* object".
|
|
5
|
+
*
|
|
6
|
+
* WHAT THIS OWNS
|
|
7
|
+
* 1. THE SESSION-WIDE GATES. Two independent facts, both of which must hold
|
|
8
|
+
* before a byte moves: the base must be user-owned and writable (a server
|
|
9
|
+
* fact — see `server/creation-site-write.ts`), which decides WHO RECORDS the
|
|
10
|
+
* resulting diff; and the gesture must be an authoring one rather than a
|
|
11
|
+
* play-time one (`editorIsAuthoring()`), which is what the surface the edit
|
|
12
|
+
* was made on already says.
|
|
13
|
+
* 2. THE TRANSACTION. One gesture produces one history transaction carrying
|
|
14
|
+
* BOTH halves — the source file's bytes and the live object's value — so a
|
|
15
|
+
* single undo puts the game and the file back together. See
|
|
16
|
+
* {@link IngestSourcePersistence.liveResource} for why the live half needs
|
|
17
|
+
* a resource of its own here where R3F needs none.
|
|
18
|
+
*
|
|
19
|
+
* WHAT THIS DOES NOT OWN. Whether an edit CAN be written is decided entirely by
|
|
20
|
+
* `creation-site-edit.ts` against the game's real bytes, on the server. Nothing
|
|
21
|
+
* in this file guesses, retries with looser rules, or falls back to a sidecar —
|
|
22
|
+
* a refusal comes back with its reason and becomes the label the user reads.
|
|
23
|
+
* The doctrine's words: persistence exists exactly where ownership does, and a
|
|
24
|
+
* property that cannot be honestly source-anchored presents as live-only.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { NodeCreationSite } from '@volter/editor-project/adapter';
|
|
28
|
+
import { base64ToBytes, bytesToBase64, sha256Hex } from '@volter/editor-core/bytes-codec';
|
|
29
|
+
import type {
|
|
30
|
+
ChannelValue,
|
|
31
|
+
CreationSiteLiteralReport,
|
|
32
|
+
CreationSiteSurface,
|
|
33
|
+
CreationSiteWriteScope,
|
|
34
|
+
} from '@volter/editor-core/creation-site-edit';
|
|
35
|
+
import { editorConsole } from '@volter/editor-core/editor-console';
|
|
36
|
+
import { editorIsAuthoring } from '@volter/editor-core/editor-session-mode';
|
|
37
|
+
import type { HistoryService } from '@volter/editor-core/history/history-service';
|
|
38
|
+
import { projectSourceAppliedChange } from '@volter/editor-core/history/source-history-backend';
|
|
39
|
+
import type { ResourceDriver, ResourceKey } from '@volter/editor-core/history/types';
|
|
40
|
+
import type { SourceWriteBackend } from '@volter/editor-core/ui-source/source-write-backend';
|
|
41
|
+
import {
|
|
42
|
+
type DataEditPlan,
|
|
43
|
+
dataPlacementRefusal,
|
|
44
|
+
dataRecordAnchor,
|
|
45
|
+
ingestDataWriterNow,
|
|
46
|
+
} from './ingest-data-writer';
|
|
47
|
+
|
|
48
|
+
const encoder = new TextEncoder();
|
|
49
|
+
const decoder = new TextDecoder();
|
|
50
|
+
|
|
51
|
+
// ────────────────────────────────────────────────────────────── ownership
|
|
52
|
+
|
|
53
|
+
export interface IngestSourceOwnership {
|
|
54
|
+
readonly writable: boolean;
|
|
55
|
+
readonly reason?: string;
|
|
56
|
+
readonly projectRoot: string | null;
|
|
57
|
+
/** Who accounts for the diff an edit produces — the server's phrase, shown
|
|
58
|
+
* verbatim. Present whenever `writable`; ownership decides the RECORDER, not
|
|
59
|
+
* whether the write happens. */
|
|
60
|
+
readonly recorder?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Unknown until the server answers — and "unknown" is NOT "writable". */
|
|
64
|
+
let ownership: IngestSourceOwnership | null = null;
|
|
65
|
+
let ownershipRequest: Promise<IngestSourceOwnership> | null = null;
|
|
66
|
+
|
|
67
|
+
export function ingestOwnershipNow(): IngestSourceOwnership | null {
|
|
68
|
+
return ownership;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Test-only: forget the server's answer so the next test asks again. */
|
|
72
|
+
export function resetIngestOwnershipForTest(): void {
|
|
73
|
+
ownership = null;
|
|
74
|
+
ownershipRequest = null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Ask the dev server whether this session's base may be written. Cached for the
|
|
79
|
+
* session: ownership is a property of the opened folder, and a project switch
|
|
80
|
+
* rebuilds the editor's adapters anyway.
|
|
81
|
+
*
|
|
82
|
+
* A transport failure is NOT treated as permission. There is exactly one safe
|
|
83
|
+
* direction for this question to fail in.
|
|
84
|
+
*/
|
|
85
|
+
export async function loadIngestOwnership(
|
|
86
|
+
fetchImpl: typeof fetch = fetch,
|
|
87
|
+
): Promise<IngestSourceOwnership> {
|
|
88
|
+
if (ownership) return ownership;
|
|
89
|
+
if (ownershipRequest) return ownershipRequest;
|
|
90
|
+
ownershipRequest = (async () => {
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetchImpl('/__ingest-source/ownership');
|
|
93
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
94
|
+
const value = (await res.json()) as IngestSourceOwnership;
|
|
95
|
+
ownership = value;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
ownership = {
|
|
98
|
+
writable: false,
|
|
99
|
+
reason: `the editor could not reach its dev server to check who owns this game's source (${
|
|
100
|
+
error instanceof Error ? error.message : String(error)
|
|
101
|
+
})`,
|
|
102
|
+
projectRoot: null,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return ownership;
|
|
106
|
+
})();
|
|
107
|
+
return ownershipRequest;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ──────────────────────────────────────────────────────────────── the write
|
|
111
|
+
|
|
112
|
+
/** What a persisted edit needs to know about the object it is writing. */
|
|
113
|
+
export interface IngestSourceWriteRequest {
|
|
114
|
+
/** Structural-path id — the key the live half restores through. */
|
|
115
|
+
readonly entityId: string;
|
|
116
|
+
/** An editor property path (a key of the surface's channel table). */
|
|
117
|
+
readonly property: string;
|
|
118
|
+
/** Which surface's vocabulary {@link IngestSourceWriteRequest.property} is in;
|
|
119
|
+
* absent ⇒ `three`. Sent to the server, which picks the channel table — the
|
|
120
|
+
* client never resolves the channel itself. */
|
|
121
|
+
readonly surface?: CreationSiteSurface | undefined;
|
|
122
|
+
/** The anchor of the object that OWNS the property (which for a material
|
|
123
|
+
* colour is the material, not the mesh — see `creation-site-edit.ts`). */
|
|
124
|
+
readonly anchor: NodeCreationSite;
|
|
125
|
+
/** How many objects that anchor has constructed. */
|
|
126
|
+
readonly instances: number;
|
|
127
|
+
/** Explicit authority to rewrite a shared construction default. */
|
|
128
|
+
readonly writeScope?: CreationSiteWriteScope | undefined;
|
|
129
|
+
readonly baseline: ChannelValue;
|
|
130
|
+
readonly next: ChannelValue;
|
|
131
|
+
/** History label for the transaction ("Transform Sun"). */
|
|
132
|
+
readonly label: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* "What does the line that built this object SAY these properties are?" — the
|
|
137
|
+
* read half of the same anchor a write uses.
|
|
138
|
+
*
|
|
139
|
+
* MANY PROPERTIES, ONE REQUEST, because the answer is one parse of one file: the
|
|
140
|
+
* inspector asks about a whole subject at once, and a per-property round trip
|
|
141
|
+
* would re-read and re-parse the game's source for every row it draws.
|
|
142
|
+
*/
|
|
143
|
+
export interface IngestInspectRequest {
|
|
144
|
+
readonly anchor: NodeCreationSite;
|
|
145
|
+
readonly instances: number;
|
|
146
|
+
readonly writeScope?: CreationSiteWriteScope | undefined;
|
|
147
|
+
readonly surface?: CreationSiteSurface | undefined;
|
|
148
|
+
readonly properties: ReadonlyArray<{ readonly property: string; readonly live: ChannelValue }>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A removal carries the subject and the label — no values. Absence is not a
|
|
152
|
+
* value, and a shape that took one would be a value write wearing another
|
|
153
|
+
* name (the same rule `SourceRemoveRequest` states at the backend seam). */
|
|
154
|
+
export interface IngestSourceRemoveRequest {
|
|
155
|
+
readonly property: string;
|
|
156
|
+
readonly surface?: CreationSiteSurface | undefined;
|
|
157
|
+
readonly anchor: NodeCreationSite;
|
|
158
|
+
readonly instances: number;
|
|
159
|
+
readonly writeScope?: CreationSiteWriteScope | undefined;
|
|
160
|
+
/** History label for the transaction ("Remove bubble position"). */
|
|
161
|
+
readonly label: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export type IngestSourceWriteOutcome =
|
|
165
|
+
| { readonly persisted: true; readonly file: string; readonly detail: string }
|
|
166
|
+
| { readonly persisted: false; readonly reason: string };
|
|
167
|
+
|
|
168
|
+
interface PreparedResponse {
|
|
169
|
+
changed?: boolean;
|
|
170
|
+
reason?: string;
|
|
171
|
+
file?: string;
|
|
172
|
+
resourcePath?: string;
|
|
173
|
+
prevSource?: string;
|
|
174
|
+
newSource?: string;
|
|
175
|
+
prevSha?: string;
|
|
176
|
+
newSha?: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The live values a persisted transaction restores — narrow on purpose: only
|
|
180
|
+
* the (object, property) pairs a persisted edit actually touched, never a
|
|
181
|
+
* mirror of the running scene. */
|
|
182
|
+
interface LiveSnapshot {
|
|
183
|
+
readonly objects: Record<string, Record<string, ChannelValue>>;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface IngestLiveAccess {
|
|
187
|
+
/** The value in force for one property, or `undefined` when the object is gone. */
|
|
188
|
+
read(entityId: string, property: string): ChannelValue | undefined;
|
|
189
|
+
/** Put a value back on the live object (undo/redo). */
|
|
190
|
+
apply(entityId: string, property: string, value: ChannelValue): void;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface IngestSourcePersistenceOptions {
|
|
194
|
+
readonly history: HistoryService | null;
|
|
195
|
+
readonly live: IngestLiveAccess;
|
|
196
|
+
readonly fetchImpl?: typeof fetch;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The `SourceWriteBackend` slice `projectSourceAppliedChange` and the source
|
|
201
|
+
* resource driver need, pointed at `/__ingest-source/*`.
|
|
202
|
+
*
|
|
203
|
+
* The JSX verbs (`writeStyle`/`removeStyle`/`writeStruct`) are structurally
|
|
204
|
+
* required by the interface and are genuinely NOT reachable through this
|
|
205
|
+
* backend — an ingested game has no oids. They throw, loudly and by name,
|
|
206
|
+
* rather than resolving to a shape that reads like a successful no-op: this is
|
|
207
|
+
* the honest-degradation rule, not a stub waiting to be filled in.
|
|
208
|
+
*/
|
|
209
|
+
function createIngestSourceBackend(fetchImpl: typeof fetch): SourceWriteBackend {
|
|
210
|
+
const post = async <T>(url: string, body: unknown): Promise<T> => {
|
|
211
|
+
const res = await fetchImpl(url, {
|
|
212
|
+
method: 'POST',
|
|
213
|
+
headers: { 'Content-Type': 'application/json' },
|
|
214
|
+
body: JSON.stringify(body),
|
|
215
|
+
});
|
|
216
|
+
const payload = (await res.json()) as T;
|
|
217
|
+
if (!res.ok && url.endsWith('/read')) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`Reading ingested game source failed with HTTP ${res.status}: ${JSON.stringify(payload)}`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
return payload;
|
|
223
|
+
};
|
|
224
|
+
/**
|
|
225
|
+
* The encoding each file came back in, remembered from its own read.
|
|
226
|
+
*
|
|
227
|
+
* The wire is deliberately explicit rather than sniffed (`creation-site-write.ts`
|
|
228
|
+
* §decodeSource: a client that forgets it gets a wrong SHA and a refusal, not a
|
|
229
|
+
* corrupted game), so the WRITE half has to say what the READ half said. Every
|
|
230
|
+
* restore is preceded by that resource's `preflight`, which reads — so by the
|
|
231
|
+
* time `applySource` runs for undo, this map has the answer. A file this
|
|
232
|
+
* backend has never read is text, which is what every source file is.
|
|
233
|
+
*
|
|
234
|
+
* Nothing here is per-game or per-format: base64 is simply what the server
|
|
235
|
+
* returns for bytes that are not text, and this echoes it back.
|
|
236
|
+
*/
|
|
237
|
+
const encodingOf = new Map<string, 'utf8' | 'base64'>();
|
|
238
|
+
const unreachable = (verb: string): never => {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`The creation-site source backend has no "${verb}" — an ingested game has no JSX oids; ` +
|
|
241
|
+
'ingest edits are anchored by creation site.',
|
|
242
|
+
);
|
|
243
|
+
};
|
|
244
|
+
return {
|
|
245
|
+
historyIdentity: INGEST_SOURCE_HISTORY_IDENTITY,
|
|
246
|
+
readSource: async (file) => {
|
|
247
|
+
const read = await post<{
|
|
248
|
+
source: string;
|
|
249
|
+
sha: string;
|
|
250
|
+
resourcePath: string;
|
|
251
|
+
encoding?: unknown;
|
|
252
|
+
}>('/__ingest-source/read', { file });
|
|
253
|
+
const binary = read.encoding === 'base64';
|
|
254
|
+
encodingOf.set(file, binary ? 'base64' : 'utf8');
|
|
255
|
+
return binary ? { ...read, bytes: base64ToBytes(read.source) } : read;
|
|
256
|
+
},
|
|
257
|
+
applySource: (file, source, ifMatchSha) =>
|
|
258
|
+
post('/__ingest-source/apply', {
|
|
259
|
+
file,
|
|
260
|
+
source: typeof source === 'string' ? source : bytesToBase64(source),
|
|
261
|
+
ifMatchSha,
|
|
262
|
+
encoding: typeof source === 'string' ? (encodingOf.get(file) ?? 'utf8') : 'base64',
|
|
263
|
+
}),
|
|
264
|
+
writeStyle: () => unreachable('writeStyle'),
|
|
265
|
+
removeStyle: () => unreachable('removeStyle'),
|
|
266
|
+
writeStruct: () => unreachable('writeStruct'),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const INGEST_SOURCE_HISTORY_IDENTITY = {};
|
|
271
|
+
|
|
272
|
+
export class IngestSourcePersistence {
|
|
273
|
+
private readonly backend: SourceWriteBackend;
|
|
274
|
+
private readonly fetchImpl: typeof fetch;
|
|
275
|
+
private readonly history: HistoryService | null;
|
|
276
|
+
private readonly live: IngestLiveAccess;
|
|
277
|
+
private liveResourceKey: ResourceKey | null = null;
|
|
278
|
+
private unregisterLive: (() => void) | null = null;
|
|
279
|
+
|
|
280
|
+
constructor(options: IngestSourcePersistenceOptions) {
|
|
281
|
+
this.history = options.history;
|
|
282
|
+
this.live = options.live;
|
|
283
|
+
this.fetchImpl = options.fetchImpl ?? fetch.bind(globalThis);
|
|
284
|
+
this.backend = createIngestSourceBackend(this.fetchImpl);
|
|
285
|
+
void loadIngestOwnership(this.fetchImpl);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
dispose(): void {
|
|
289
|
+
this.unregisterLive?.();
|
|
290
|
+
this.unregisterLive = null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The ONE cheap-gate decision, shared by the label and by the write.
|
|
295
|
+
*
|
|
296
|
+
* These were two functions once, and they disagreed: the label checked
|
|
297
|
+
* `instances > 1` while the write required `instances === 1`, so an object at
|
|
298
|
+
* a creation site with a count of ZERO was labelled "Persisting to …" and
|
|
299
|
+
* then silently journaled live-only. Found live on SimCity, not by a test —
|
|
300
|
+
* which is exactly the class of bug two parallel condition lists produce, so
|
|
301
|
+
* there is now one list and both callers read it.
|
|
302
|
+
*
|
|
303
|
+
* It is SYNCHRONOUS and reads only cached state, because the live-only path
|
|
304
|
+
* must stay as synchronous as it was before this feature existed:
|
|
305
|
+
* `transforms.endEdit` journals the session's undo entry inline, and
|
|
306
|
+
* deferring that to a microtask broke an `endEdit(); undo()` sequence that
|
|
307
|
+
* had worked for as long as ingest editing has
|
|
308
|
+
* (`three-structural-authoring.test.ts`).
|
|
309
|
+
*/
|
|
310
|
+
gate(
|
|
311
|
+
anchor: NodeCreationSite,
|
|
312
|
+
instances: number,
|
|
313
|
+
property: string,
|
|
314
|
+
writeScope: CreationSiteWriteScope = 'instance',
|
|
315
|
+
): { ok: true } | { ok: false; reason: string } {
|
|
316
|
+
if (!anchor.anchored) return { ok: false, reason: anchor.reason };
|
|
317
|
+
const at =
|
|
318
|
+
anchor.kind === 'data' ? `held at ${anchor.display}` : `created at ${anchor.display}`;
|
|
319
|
+
if (anchor.kind === 'data') {
|
|
320
|
+
// FIRST, because it is the only refusal about the PROPERTY rather than
|
|
321
|
+
// the session: a format that cannot hold this property will not start
|
|
322
|
+
// holding it in another mode, so the sentence the user reads should be
|
|
323
|
+
// about the format and not about anything they could go change.
|
|
324
|
+
const unplaceable = dataPlacementRefusal(property);
|
|
325
|
+
if (unplaceable) return { ok: false, reason: `${at}, but ${unplaceable}` };
|
|
326
|
+
}
|
|
327
|
+
const owned = ownership;
|
|
328
|
+
if (!owned) return { ok: false, reason: `${at}; still checking who owns this game's source` };
|
|
329
|
+
if (!owned.writable) {
|
|
330
|
+
return {
|
|
331
|
+
ok: false,
|
|
332
|
+
reason: `${at}; ${owned.reason ?? 'this game’s source is not writable'}`,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
// `instances` is a SOURCE fact — how many objects one `file:line` built —
|
|
336
|
+
// and it has no data-side analogue: a record index addresses exactly one
|
|
337
|
+
// record, so the ambiguity this rule exists to refuse cannot arise. Reusing
|
|
338
|
+
// the count here would be inventing a number rather than reading one.
|
|
339
|
+
if (anchor.kind === 'source' && instances !== 1 && writeScope !== 'creation-site') {
|
|
340
|
+
return {
|
|
341
|
+
ok: false,
|
|
342
|
+
reason:
|
|
343
|
+
instances === 0
|
|
344
|
+
? `${at}, but nothing was recorded as constructed there — the editor cannot tell whether that line builds this object alone`
|
|
345
|
+
: `${at}, but that creation site constructs ${instances} objects; a per-object edit cannot be written there`,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
// LAST, because it is the only one a user can change without changing the
|
|
349
|
+
// game: everything above says "no, whatever mode you are in".
|
|
350
|
+
if (!editorIsAuthoring()) {
|
|
351
|
+
return {
|
|
352
|
+
ok: false,
|
|
353
|
+
reason: `${at}, but this game is playing — hold it to author, or the edit stays live-only`,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
return { ok: true };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Would a write even be ATTEMPTED for this object? */
|
|
360
|
+
canAttempt(anchor: NodeCreationSite, instances: number, property: string): boolean {
|
|
361
|
+
return this.gate(anchor, instances, property).ok;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The sentence a surface shows for one object BEFORE any edit.
|
|
366
|
+
*
|
|
367
|
+
* An ANCHORED object always names its `file:line`, even when a gate is shut —
|
|
368
|
+
* "we know exactly where this came from and still cannot write it, because X"
|
|
369
|
+
* is strictly more useful than X alone, and it builds on the creation-site
|
|
370
|
+
* read surface rather than replacing it.
|
|
371
|
+
*/
|
|
372
|
+
describe(
|
|
373
|
+
anchor: NodeCreationSite,
|
|
374
|
+
instances: number,
|
|
375
|
+
property: string,
|
|
376
|
+
writeScope: CreationSiteWriteScope = 'instance',
|
|
377
|
+
): string {
|
|
378
|
+
const verdict = this.gate(anchor, instances, property, writeScope);
|
|
379
|
+
return verdict.ok
|
|
380
|
+
? `Persisting to ${(anchor as { display: string }).display}.`
|
|
381
|
+
: `Live-only edit — ${verdict.reason}.`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* READ what the construction statement says, for every property in one go.
|
|
386
|
+
*
|
|
387
|
+
* `{}` — not a throw and not a fabricated row — whenever the question has no
|
|
388
|
+
* answer here: an unanchored object, a data anchor (a binary record names no
|
|
389
|
+
* literal), or a tier with no `/__ingest-source/*` route at all. The caller
|
|
390
|
+
* shows nothing rather than a default it invented, which is the same rule the
|
|
391
|
+
* write path follows when a gate is shut.
|
|
392
|
+
*
|
|
393
|
+
* NO MODE GATE. This reads the game's own bytes and changes nothing, so the
|
|
394
|
+
* write path's question — is this gesture authoring or play? — does not arise:
|
|
395
|
+
* asking it here would blank the inspector's read surface for a game that is
|
|
396
|
+
* merely running, which is the state an ingest spends most of its life in.
|
|
397
|
+
*/
|
|
398
|
+
async inspect(request: IngestInspectRequest): Promise<Record<string, CreationSiteLiteralReport>> {
|
|
399
|
+
const anchor = request.anchor;
|
|
400
|
+
if (!anchor.anchored || anchor.kind !== 'source') return {};
|
|
401
|
+
if (request.properties.length === 0) return {};
|
|
402
|
+
try {
|
|
403
|
+
const answer = await this.post<{
|
|
404
|
+
properties?: Record<string, CreationSiteLiteralReport>;
|
|
405
|
+
}>('/__ingest-source/inspect', {
|
|
406
|
+
site: { file: anchor.file, line: anchor.line, col: anchor.col },
|
|
407
|
+
instances: request.instances,
|
|
408
|
+
...(request.writeScope ? { writeScope: request.writeScope } : {}),
|
|
409
|
+
...(request.surface ? { surface: request.surface } : {}),
|
|
410
|
+
properties: request.properties,
|
|
411
|
+
});
|
|
412
|
+
return answer.properties ?? {};
|
|
413
|
+
} catch {
|
|
414
|
+
// A transport failure is an absence of knowledge, never a claim about the
|
|
415
|
+
// source — the same direction `loadIngestOwnership` fails in.
|
|
416
|
+
return {};
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Attempt to write one property edit into the game's own truth.
|
|
422
|
+
*
|
|
423
|
+
* The session-wide gates first (cheap, local, and the answer the user is
|
|
424
|
+
* owed), then the plan against the real bytes, then ONE transaction carries
|
|
425
|
+
* the file and the live value together. WHICH truth is the anchor's own
|
|
426
|
+
* answer: a source
|
|
427
|
+
* anchor plans on the server against a `file:line`, a data anchor plans in
|
|
428
|
+
* the browser through the game's declared writer (see
|
|
429
|
+
* `ingest-data-writer.ts` for why the two halves sit where they do). Both
|
|
430
|
+
* land through the SAME guarded apply route, so the checksum guard and the
|
|
431
|
+
* vendored-lock recorder are identical for either.
|
|
432
|
+
*/
|
|
433
|
+
async persist(request: IngestSourceWriteRequest): Promise<IngestSourceWriteOutcome> {
|
|
434
|
+
if (!editorIsAuthoring()) {
|
|
435
|
+
return {
|
|
436
|
+
persisted: false,
|
|
437
|
+
reason: 'this game is playing — a play-time edit lives on the running object only',
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
const anchor = request.anchor;
|
|
441
|
+
if (!anchor.anchored) {
|
|
442
|
+
return { persisted: false, reason: anchor.reason };
|
|
443
|
+
}
|
|
444
|
+
const owned = await loadIngestOwnership(this.fetchImpl);
|
|
445
|
+
if (!owned.writable) {
|
|
446
|
+
return { persisted: false, reason: owned.reason ?? 'this game’s source is not writable' };
|
|
447
|
+
}
|
|
448
|
+
if (!this.history) {
|
|
449
|
+
return { persisted: false, reason: 'this session has no project history to record into' };
|
|
450
|
+
}
|
|
451
|
+
if (anchor.kind === 'data') return this.persistData(request, anchor);
|
|
452
|
+
|
|
453
|
+
const prepared = await this.post<PreparedResponse>('/__ingest-source/prepare', {
|
|
454
|
+
site: {
|
|
455
|
+
file: anchor.file,
|
|
456
|
+
line: anchor.line,
|
|
457
|
+
col: anchor.col,
|
|
458
|
+
},
|
|
459
|
+
instances: request.instances,
|
|
460
|
+
...(request.writeScope ? { writeScope: request.writeScope } : {}),
|
|
461
|
+
property: request.property,
|
|
462
|
+
...(request.surface ? { surface: request.surface } : {}),
|
|
463
|
+
baseline: request.baseline,
|
|
464
|
+
next: request.next,
|
|
465
|
+
});
|
|
466
|
+
if (
|
|
467
|
+
!prepared.changed ||
|
|
468
|
+
!prepared.file ||
|
|
469
|
+
!prepared.resourcePath ||
|
|
470
|
+
prepared.prevSource === undefined ||
|
|
471
|
+
prepared.newSource === undefined ||
|
|
472
|
+
!prepared.prevSha
|
|
473
|
+
) {
|
|
474
|
+
return { persisted: false, reason: prepared.reason ?? 'the edit was refused' };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ONE surgical write, planned in full before this line. The apply is
|
|
478
|
+
// checksum-guarded server-side, so a file that moved under us refuses
|
|
479
|
+
// rather than clobbers — and where the base is a repo-vendored game, that
|
|
480
|
+
// same route brings the game's lock along in the same operation.
|
|
481
|
+
//
|
|
482
|
+
// `note` goes through this direct post rather than `backend.applySource`
|
|
483
|
+
// because it is a property of THIS gesture (which property, at which
|
|
484
|
+
// creation site) and the backend's apply is also the undo/redo path, which
|
|
485
|
+
// has no gesture. The server treats it as a hint over a record it derives
|
|
486
|
+
// from the bytes either way.
|
|
487
|
+
const applied = await this.post<{ applied?: boolean; error?: string }>(
|
|
488
|
+
'/__ingest-source/apply',
|
|
489
|
+
{
|
|
490
|
+
file: prepared.file,
|
|
491
|
+
source: prepared.newSource,
|
|
492
|
+
ifMatchSha: prepared.prevSha,
|
|
493
|
+
note: `${request.property} at ${anchor.display}`,
|
|
494
|
+
},
|
|
495
|
+
);
|
|
496
|
+
if (!applied.applied) {
|
|
497
|
+
return { persisted: false, reason: applied.error ?? 'the write was refused' };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
await this.recordEdit(request, {
|
|
501
|
+
file: prepared.file,
|
|
502
|
+
resourcePath: prepared.resourcePath,
|
|
503
|
+
before: prepared.prevSource,
|
|
504
|
+
after: prepared.newSource,
|
|
505
|
+
});
|
|
506
|
+
return {
|
|
507
|
+
persisted: true,
|
|
508
|
+
file: prepared.file,
|
|
509
|
+
detail: `${request.property} → ${prepared.file}:${anchor.line}`,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Drop a property's authored value from the game's own source — the removal
|
|
515
|
+
* half of {@link persist}, on the SAME route (`prepare` with `remove: true`,
|
|
516
|
+
* then the checksum-guarded `apply` that brings a vendored game's lock along
|
|
517
|
+
* in the same gesture). The plan itself is `planCreationSiteRemoval`: the
|
|
518
|
+
* one deletable shape is the whole own-line assignment statement the
|
|
519
|
+
* insertion arm writes, so this is the byte-exact inverse of an insertion.
|
|
520
|
+
*
|
|
521
|
+
* The recorded transaction is SOURCE-ONLY, unlike {@link recordEdit}'s
|
|
522
|
+
* two-halves shape: a removal has no live half. The running object keeps
|
|
523
|
+
* its value (an unmodified ingested game does not re-derive its scene from
|
|
524
|
+
* source), so there is nothing to restore live on undo — the undo that
|
|
525
|
+
* matters is the file's own bytes, and recording a live half would make one
|
|
526
|
+
* removal restore a value nothing changed.
|
|
527
|
+
*/
|
|
528
|
+
async remove(request: IngestSourceRemoveRequest): Promise<IngestSourceWriteOutcome> {
|
|
529
|
+
if (!editorIsAuthoring()) {
|
|
530
|
+
return {
|
|
531
|
+
persisted: false,
|
|
532
|
+
reason: 'this game is playing — a play-time edit lives on the running object only',
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
const anchor = request.anchor;
|
|
536
|
+
if (!anchor.anchored) {
|
|
537
|
+
return { persisted: false, reason: anchor.reason };
|
|
538
|
+
}
|
|
539
|
+
const owned = await loadIngestOwnership(this.fetchImpl);
|
|
540
|
+
if (!owned.writable) {
|
|
541
|
+
return { persisted: false, reason: owned.reason ?? 'this game’s source is not writable' };
|
|
542
|
+
}
|
|
543
|
+
if (!this.history) {
|
|
544
|
+
return { persisted: false, reason: 'this session has no project history to record into' };
|
|
545
|
+
}
|
|
546
|
+
if (anchor.kind === 'data') {
|
|
547
|
+
return {
|
|
548
|
+
persisted: false,
|
|
549
|
+
reason:
|
|
550
|
+
"a level-data record has no removable source statement — its property is the record's " +
|
|
551
|
+
'own field, not an authored override',
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
const prepared = await this.post<PreparedResponse>('/__ingest-source/prepare', {
|
|
555
|
+
site: { file: anchor.file, line: anchor.line, col: anchor.col },
|
|
556
|
+
instances: request.instances,
|
|
557
|
+
...(request.writeScope ? { writeScope: request.writeScope } : {}),
|
|
558
|
+
property: request.property,
|
|
559
|
+
...(request.surface ? { surface: request.surface } : {}),
|
|
560
|
+
remove: true,
|
|
561
|
+
});
|
|
562
|
+
if (
|
|
563
|
+
!prepared.changed ||
|
|
564
|
+
!prepared.file ||
|
|
565
|
+
!prepared.resourcePath ||
|
|
566
|
+
prepared.prevSource === undefined ||
|
|
567
|
+
prepared.newSource === undefined ||
|
|
568
|
+
!prepared.prevSha
|
|
569
|
+
) {
|
|
570
|
+
return { persisted: false, reason: prepared.reason ?? 'the removal was refused' };
|
|
571
|
+
}
|
|
572
|
+
const applied = await this.post<{ applied?: boolean; error?: string }>(
|
|
573
|
+
'/__ingest-source/apply',
|
|
574
|
+
{
|
|
575
|
+
file: prepared.file,
|
|
576
|
+
source: prepared.newSource,
|
|
577
|
+
ifMatchSha: prepared.prevSha,
|
|
578
|
+
note: `${request.property} removed at ${anchor.display}`,
|
|
579
|
+
},
|
|
580
|
+
);
|
|
581
|
+
if (!applied.applied) {
|
|
582
|
+
return { persisted: false, reason: applied.error ?? 'the removal was refused' };
|
|
583
|
+
}
|
|
584
|
+
const sourceChange = projectSourceAppliedChange(this.backend, this.history, {
|
|
585
|
+
file: prepared.file,
|
|
586
|
+
resourcePath: prepared.resourcePath,
|
|
587
|
+
before: prepared.prevSource,
|
|
588
|
+
after: prepared.newSource,
|
|
589
|
+
});
|
|
590
|
+
await this.history.recordAppliedTransaction(
|
|
591
|
+
{ label: request.label, resources: [sourceChange.resource], scope: 'project' },
|
|
592
|
+
[sourceChange],
|
|
593
|
+
);
|
|
594
|
+
return {
|
|
595
|
+
persisted: true,
|
|
596
|
+
file: prepared.file,
|
|
597
|
+
detail: `${request.property} dropped from ${prepared.file}:${anchor.line}`,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* The DATA half: read the game's own data file, let the GAME'S OWN writer
|
|
603
|
+
* produce the next bytes, and write them through the same guarded route.
|
|
604
|
+
*
|
|
605
|
+
* Nothing here knows what any byte means. The host's whole contribution is
|
|
606
|
+
* transport, gating and the transaction; every judgement about the file —
|
|
607
|
+
* whether the record exists, whether the property is one this format can
|
|
608
|
+
* hold, whether the new value is even placeable — belongs to the writer and
|
|
609
|
+
* comes back as `{changed: false, reason}` when it is no.
|
|
610
|
+
*
|
|
611
|
+
* BINARY ALL THE WAY THROUGH. The file is carried base64 on both legs and its
|
|
612
|
+
* history snapshots hold that same base64 text while declaring the RAW bytes'
|
|
613
|
+
* sha, which is what makes undo byte-exact: restore hands the previous base64
|
|
614
|
+
* back to `applySource`, the server decodes it, and the digest it reports is
|
|
615
|
+
* the one the snapshot recorded.
|
|
616
|
+
*/
|
|
617
|
+
private async persistData(
|
|
618
|
+
request: IngestSourceWriteRequest,
|
|
619
|
+
anchor: Extract<NodeCreationSite, { kind: 'data' }>,
|
|
620
|
+
): Promise<IngestSourceWriteOutcome> {
|
|
621
|
+
const state = ingestDataWriterNow();
|
|
622
|
+
if (state.state !== 'ready') {
|
|
623
|
+
// Re-ask the anchor mint rather than restating its sentences here: it is
|
|
624
|
+
// the one place a writer's state becomes a reason, so the refusal a user
|
|
625
|
+
// reads mid-gesture is the same one the inspector showed beforehand.
|
|
626
|
+
const now = dataRecordAnchor(anchor.record);
|
|
627
|
+
return {
|
|
628
|
+
persisted: false,
|
|
629
|
+
reason: now.anchored ? "this game's data writer changed mid-gesture" : now.reason,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
const read = await this.post<{
|
|
633
|
+
source?: string;
|
|
634
|
+
sha?: string;
|
|
635
|
+
resourcePath?: string;
|
|
636
|
+
encoding?: string;
|
|
637
|
+
error?: string;
|
|
638
|
+
}>('/__ingest-source/read', { file: anchor.file });
|
|
639
|
+
if (typeof read.source !== 'string' || !read.sha || !read.resourcePath) {
|
|
640
|
+
return {
|
|
641
|
+
persisted: false,
|
|
642
|
+
reason: read.error ?? `this game's data file ${anchor.file} could not be read`,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
if (read.encoding !== 'base64') {
|
|
646
|
+
return {
|
|
647
|
+
persisted: false,
|
|
648
|
+
reason:
|
|
649
|
+
`${anchor.file} came back as ${read.encoding ?? 'utf8'} text, not bytes — a data writer ` +
|
|
650
|
+
'edits binary game data, and writing text back through it would change the file',
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
let plan: DataEditPlan;
|
|
655
|
+
try {
|
|
656
|
+
plan = await state.writer.planDataEdit(base64ToBytes(read.source), {
|
|
657
|
+
record: anchor.record,
|
|
658
|
+
property: request.property,
|
|
659
|
+
baseline: request.baseline,
|
|
660
|
+
next: request.next,
|
|
661
|
+
});
|
|
662
|
+
} catch (error) {
|
|
663
|
+
return {
|
|
664
|
+
persisted: false,
|
|
665
|
+
reason: `this game's data writer refused: ${
|
|
666
|
+
error instanceof Error ? error.message : String(error)
|
|
667
|
+
}`,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
if (!plan.changed) return { persisted: false, reason: plan.reason };
|
|
671
|
+
|
|
672
|
+
const nextSource = bytesToBase64(plan.bytes);
|
|
673
|
+
const applied = await this.post<{ applied?: boolean; error?: string }>(
|
|
674
|
+
'/__ingest-source/apply',
|
|
675
|
+
{
|
|
676
|
+
file: anchor.file,
|
|
677
|
+
source: nextSource,
|
|
678
|
+
encoding: 'base64',
|
|
679
|
+
ifMatchSha: read.sha,
|
|
680
|
+
note: `${request.property} at ${anchor.display}`,
|
|
681
|
+
},
|
|
682
|
+
);
|
|
683
|
+
if (!applied.applied) {
|
|
684
|
+
return { persisted: false, reason: applied.error ?? 'the write was refused' };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// The FILE's bytes into history, not the base64 the wire carried: a
|
|
688
|
+
// snapshot's `sha256` is the file's own digest, which is what undo compares
|
|
689
|
+
// the apply's answer against.
|
|
690
|
+
await this.recordEdit(request, {
|
|
691
|
+
file: anchor.file,
|
|
692
|
+
resourcePath: read.resourcePath,
|
|
693
|
+
before: base64ToBytes(read.source),
|
|
694
|
+
after: plan.bytes,
|
|
695
|
+
});
|
|
696
|
+
return {
|
|
697
|
+
persisted: true,
|
|
698
|
+
file: anchor.file,
|
|
699
|
+
detail: `${request.property} → ${anchor.display}`,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* ONE history transaction carrying BOTH halves of a persisted edit — the
|
|
705
|
+
* file's bytes and the live object's value — so a single undo puts the game
|
|
706
|
+
* and the file back together. Shared by the source and data paths because the
|
|
707
|
+
* property it guarantees is the same one either way.
|
|
708
|
+
*/
|
|
709
|
+
private async recordEdit(
|
|
710
|
+
request: IngestSourceWriteRequest,
|
|
711
|
+
file: {
|
|
712
|
+
file: string;
|
|
713
|
+
resourcePath: string;
|
|
714
|
+
before: string | Uint8Array;
|
|
715
|
+
after: string | Uint8Array;
|
|
716
|
+
},
|
|
717
|
+
): Promise<void> {
|
|
718
|
+
const history = this.history!;
|
|
719
|
+
const liveKey = this.ensureLiveResource();
|
|
720
|
+
const sourceChange = projectSourceAppliedChange(this.backend, history, file);
|
|
721
|
+
const before: LiveSnapshot = {
|
|
722
|
+
objects: { [request.entityId]: { [request.property]: request.baseline } },
|
|
723
|
+
};
|
|
724
|
+
const after: LiveSnapshot = {
|
|
725
|
+
objects: { [request.entityId]: { [request.property]: request.next } },
|
|
726
|
+
};
|
|
727
|
+
await history.recordAppliedTransaction(
|
|
728
|
+
{ label: request.label, resources: [sourceChange.resource, liveKey], scope: 'project' },
|
|
729
|
+
[
|
|
730
|
+
sourceChange,
|
|
731
|
+
{
|
|
732
|
+
resource: liveKey,
|
|
733
|
+
beforeBytes: encoder.encode(JSON.stringify(before)),
|
|
734
|
+
afterBytes: encoder.encode(JSON.stringify(after)),
|
|
735
|
+
contentType: 'application/json',
|
|
736
|
+
},
|
|
737
|
+
],
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
private async post<T>(url: string, body: unknown): Promise<T> {
|
|
742
|
+
const res = await this.fetchImpl(url, {
|
|
743
|
+
method: 'POST',
|
|
744
|
+
headers: { 'Content-Type': 'application/json' },
|
|
745
|
+
body: JSON.stringify(body),
|
|
746
|
+
});
|
|
747
|
+
return (await res.json()) as T;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* The live half's history resource, registered on first use.
|
|
752
|
+
*
|
|
753
|
+
* WHY IT EXISTS AT ALL. In the R3F precedent the source IS the live tree's
|
|
754
|
+
* truth, so restoring the file re-renders the objects and one resource covers
|
|
755
|
+
* both. An unmodified ingested game does not re-derive its scene from source
|
|
756
|
+
* — nothing re-runs `#setupLights()` because a literal changed — so undoing
|
|
757
|
+
* only the file would leave the running game showing the edit it just
|
|
758
|
+
* "undid". This resource is the missing half, and it is deliberately narrow:
|
|
759
|
+
* its snapshots name only the (object, property) pairs a persisted edit
|
|
760
|
+
* touched, never a mirror of the scene.
|
|
761
|
+
*
|
|
762
|
+
* PREFLIGHT ALWAYS PASSES, for the reason `JsonHistoryResource`'s
|
|
763
|
+
* `conflictIdentity` exists (issue #81): an ingested game is a RUNNING game
|
|
764
|
+
* that moves its own objects every frame, so a byte-comparison of live state
|
|
765
|
+
* against a snapshot taken moments ago is guaranteed to differ and would make
|
|
766
|
+
* every undo fail with `content-conflict`. The conflict that actually matters
|
|
767
|
+
* here — someone else editing the file — is caught by the SOURCE resource's
|
|
768
|
+
* preflight, which is in the same transaction.
|
|
769
|
+
*/
|
|
770
|
+
private ensureLiveResource(): ResourceKey {
|
|
771
|
+
if (this.liveResourceKey) return this.liveResourceKey;
|
|
772
|
+
const history = this.history!;
|
|
773
|
+
const descriptor = history.registry.registerProject(
|
|
774
|
+
'overlay',
|
|
775
|
+
'.vgai/ingest-live-objects',
|
|
776
|
+
'Ingested game objects',
|
|
777
|
+
);
|
|
778
|
+
const driver: ResourceDriver = {
|
|
779
|
+
descriptor,
|
|
780
|
+
// Never reached on this resource's own path: it is journaled ONLY through
|
|
781
|
+
// `recordAppliedTransaction`, which supplies both snapshots itself and
|
|
782
|
+
// never captures. It is implemented honestly anyway (empty = "no tracked
|
|
783
|
+
// live values", with a real digest) rather than as a throw, because the
|
|
784
|
+
// driver contract belongs to the history service, not to this caller.
|
|
785
|
+
capture: async () => {
|
|
786
|
+
const bytes = encoder.encode(JSON.stringify({ objects: {} } satisfies LiveSnapshot));
|
|
787
|
+
return {
|
|
788
|
+
revision: 0,
|
|
789
|
+
contentType: 'application/json',
|
|
790
|
+
bytes,
|
|
791
|
+
sha256: await sha256Hex(bytes),
|
|
792
|
+
};
|
|
793
|
+
},
|
|
794
|
+
preflight: async () => ({ ok: true }),
|
|
795
|
+
restore: async (snapshot) => {
|
|
796
|
+
const value = JSON.parse(
|
|
797
|
+
decoder.decode(history.snapshots.read(snapshot).bytes),
|
|
798
|
+
) as LiveSnapshot;
|
|
799
|
+
for (const [entityId, properties] of Object.entries(value.objects)) {
|
|
800
|
+
for (const [property, propertyValue] of Object.entries(properties)) {
|
|
801
|
+
this.live.apply(entityId, property, propertyValue);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
},
|
|
805
|
+
estimateBytes: (snapshot) => snapshot.byteLength,
|
|
806
|
+
};
|
|
807
|
+
this.unregisterLive = history.registerDriver(driver);
|
|
808
|
+
this.liveResourceKey = descriptor.key;
|
|
809
|
+
return descriptor.key;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Report a refusal where the user can see it, once per gesture.
|
|
815
|
+
*
|
|
816
|
+
* A WARNING, not a log line, because of where each one goes: `vgai status`
|
|
817
|
+
* surfaces `sessionWarnings` and shows nothing at all for `log`. A refused
|
|
818
|
+
* write was invisible to every door the product has — the user's edit did not
|
|
819
|
+
* reach their file and the only explanation lived in a panel nobody could read
|
|
820
|
+
* from a terminal, which is the class of gap that sends people to an
|
|
821
|
+
* out-of-band tool. A write the user asked for and did not get is exactly what
|
|
822
|
+
* a warning is for. Successes stay `log`.
|
|
823
|
+
*/
|
|
824
|
+
export function reportIngestSourceRefusal(label: string, reason: string): void {
|
|
825
|
+
editorConsole.warn(`[ingest] ${label} stayed live-only — ${reason}.`);
|
|
826
|
+
}
|