@volter/editor-game 0.5.66 → 0.5.67
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/contributions/audio-unlock.service.ts +2 -2
- package/contributions/autoplay.service.ts +1 -1
- package/contributions/bridge.command.ts +3 -3
- package/contributions/canvas/component-board.service.ts +16 -0
- package/contributions/canvas/design-time-mount.service.ts +45 -0
- package/contributions/canvas-story-capture.service.ts +12 -0
- package/contributions/edit-mode-audio.service.ts +2 -2
- package/contributions/edit-mode-networking.service.ts +1 -1
- package/contributions/gameplay.command.ts +4 -4
- package/contributions/generation.service.ts +1 -1
- package/contributions/godot.style.ts +26 -6
- package/contributions/godot.view.ts +6 -0
- package/contributions/ingest.service.ts +2 -2
- package/contributions/instances.command.ts +1 -1
- package/contributions/navmesh.menu.ts +1 -1
- package/contributions/network-observer.service.ts +14 -0
- package/contributions/play.command.ts +10 -4
- package/contributions/react/component-board.service.ts +1 -1
- package/contributions/react/design-time-mount.service.ts +1 -1
- package/contributions/scene-document.service.ts +3 -3
- package/contributions/state-watch.menu.ts +1 -1
- package/contributions/state-watch.utility.tsx +1 -1
- package/contributions/team-playtest.service.ts +4 -4
- package/contributions/three/component-board.service.ts +1 -1
- package/contributions/three/component-verbs.command.ts +6 -6
- package/contributions/three/three-authoring.service.ts +8 -5
- package/contributions/three-story-capture.service.ts +12 -0
- package/contributions/unity.style.ts +17 -7
- package/contributions/unity.view.ts +6 -0
- package/contributions/unreal.style.ts +10 -2
- package/contributions/unreal.view.ts +5 -0
- package/package.json +20 -10
- package/src/asset-budget/AssetBudgetPanel.tsx +1 -1
- package/src/asset-budget/asset-budget-model.ts +1 -1
- package/src/audio/AudioDebuggerPanel.tsx +1 -1
- package/src/bridge/dispatch.ts +14 -14
- package/src/bridge/live-frames.ts +1 -1
- package/src/bridge/screenshot.ts +3 -3
- package/src/build/BuildProfilesPanel.tsx +3 -3
- package/src/canvas/canvas-board/CanvasBoardDocument.tsx +676 -0
- package/src/canvas/canvas-board/canvas-board-model.ts +407 -0
- package/src/canvas/canvas-board/canvas-component-board.ts +56 -0
- package/src/canvas/canvas-design-mount.ts +524 -0
- package/src/canvas/design-time-canvas-mount.ts +79 -0
- package/src/coverage/live-authoring-surface.ts +5 -5
- package/src/coverage/live-project-verbs.ts +1 -1
- package/src/coverage/native-system-coverage.ts +5 -5
- package/src/coverage/root-coverage.ts +1 -1
- package/src/coverage/session-coverage.ts +3 -3
- package/src/design-system-stories/ApplicationChrome.stories.tsx +5 -5
- package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +7 -7
- package/src/edit-mode/edit-mode-audio.ts +4 -4
- package/src/edit-mode/edit-mode-networking.ts +2 -2
- package/src/game-document/GameCaptureFrameButton.tsx +1 -1
- package/src/game-document/GameDocument.tsx +2 -2
- package/src/game-document/GamePanel.tsx +4 -4
- package/src/game-document/InstanceInspectorPicker.tsx +2 -2
- package/src/game-document/crowd-debug.ts +1 -1
- package/src/game-document/device-preview.ts +10 -11
- package/src/game-document/physics-debug.ts +1 -1
- package/src/generation/GenerationActivity.tsx +3 -3
- package/src/generation/generation-documents.tsx +5 -5
- package/src/generation/generation-jobs.ts +1 -1
- package/src/host/adapter-runtime-bindings.ts +96 -5
- package/src/host/authoring/babylon-authoring-adapter.ts +6 -6
- package/src/host/authoring/gesture-persist.ts +1 -1
- package/src/host/authoring/ingest-data-writer.ts +1 -1
- package/src/host/authoring/ingest-source-persistence.ts +4 -4
- package/src/host/authoring/mounted-authoring.ts +4 -4
- package/src/host/authoring/phaser-live-authoring-adapter.ts +4 -4
- package/src/host/authoring/pixi-authoring-adapter.ts +40 -15
- package/src/host/authoring/pixi-creation-site-write-target.ts +2 -2
- package/src/host/authoring/pixi-live-write-target.ts +4 -4
- package/src/host/authoring/pixi-source-identity.ts +3 -3
- package/src/host/authoring/pixi-source-write-target.ts +1614 -0
- package/src/host/authoring/pixi-still-presentation.ts +1 -1
- package/src/host/authoring/pixi-structure-history.ts +2 -2
- package/src/host/authoring/pixi-transform-channels.ts +16 -14
- package/src/host/authoring/source-persistence-backend.ts +3 -3
- package/src/host/authoring/struct-write-pipe.ts +1 -1
- package/src/host/binding-resolver.ts +8 -9
- package/src/host/browser-transpile.ts +1 -1
- package/src/host/canvas-entry-runtime.ts +58 -47
- package/src/host/canvas-preview-frames.ts +482 -0
- package/src/host/components/CameraAuthoringOverlay.tsx +1 -1
- package/src/host/components/HeaderTelemetry.tsx +4 -4
- package/src/host/components/PixiIsolationSceneContent.tsx +10 -10
- package/src/host/components/ThreeIsolationSceneContent.tsx +3 -3
- package/src/host/components/frame-debugger-model.ts +2 -2
- package/src/host/components/header-telemetry-model.ts +2 -2
- package/src/host/components/scene-document.tsx +14 -14
- package/src/host/components/utility-view-state.ts +1 -1
- package/src/host/components/world-root-stage-binding.tsx +12 -12
- package/src/host/components/world-root-stage.ts +70 -49
- package/src/host/coverage/system-adapter-coverage.ts +3 -4
- package/src/host/design-system-stories/fixtures/editor-runtime.tsx +9 -11
- package/src/host/document-preview-three.ts +1 -1
- package/src/host/entry-adjudication.ts +6 -6
- package/src/host/game-css-scope-transform.ts +4 -0
- package/src/host/game-realm-page.ts +1 -1
- package/src/host/gameplay-export.ts +25 -14
- package/src/host/gameplay-recording.ts +5 -5
- package/src/host/gated-globals.ts +2 -2
- package/src/host/history/json-history-resource.ts +1 -1
- package/src/host/projection/pixi.ts +24 -2
- package/src/host/r3f-entry-runtime.ts +65 -34
- package/src/host/react-mount-runtime.ts +7 -48
- package/src/host/realm-services.ts +1 -1
- package/src/host/roots/canvas-root.tsx +361 -0
- package/src/host/roots/r3f-root.tsx +473 -0
- package/src/host/roots/react-root.ts +9 -43
- package/src/host/served-bundle-runtime-modules.ts +1 -19
- package/src/host/server-log-bridge.ts +2 -2
- package/src/host/stories/mounted-story-viewport-source.ts +1 -1
- package/src/host/stories/pixi-story-model.ts +30 -0
- package/src/host/stories/story-media-captures.ts +46 -0
- package/src/host/stories/story-media-presence.ts +3 -3
- package/src/host/stories/story-pixi-preview.ts +408 -0
- package/src/host/stories/story-three-preview.ts +806 -0
- package/src/host/stories/three-story-captures.ts +35 -0
- package/src/host/story-three-preview-runtime.ts +56 -0
- package/src/host/use-active-performance-source.ts +2 -2
- package/src/host/viewport-pose-memory.ts +1 -1
- package/src/host/viewport-root-presentation.ts +6 -5
- package/src/ingest/active-ingest.ts +1 -1
- package/src/ingest/authoring/ingest-dom-surface-authoring.ts +4 -4
- package/src/ingest/authoring/ingest-root-adapter.ts +8 -8
- package/src/ingest/deferred-ingest-play.ts +6 -6
- package/src/ingest/discovery-public-ingest.ts +2 -2
- package/src/ingest/ingest-boot-viewport.ts +2 -2
- package/src/ingest/ingest-canvas-scene-document.tsx +8 -8
- package/src/ingest/ingest-canvas-scene.ts +3 -3
- package/src/ingest/ingest-evidence-hook.ts +1 -1
- package/src/ingest/ingest-frame-snapshot.ts +1 -1
- package/src/ingest/ingest-play-control.ts +1 -1
- package/src/ingest/ingest-render-debug.ts +10 -10
- package/src/ingest/ingest-siblings.ts +13 -25
- package/src/ingest/module-mode.ts +14 -14
- package/src/ingest/mount-canvas-ingest-root.ts +21 -21
- package/src/ingest/mount-coverage.ts +2 -2
- package/src/ingest/mount-dom-ingest-root.ts +11 -11
- package/src/ingest/mount-ingest-root.ts +8 -8
- package/src/ingest/mount-three-ingest-root.ts +8 -8
- package/src/ingest/resolve-canvas.ts +1 -1
- package/src/ingest/served-html-boot.ts +1 -1
- package/src/ingest/surface-canvas.ts +1 -1
- package/src/ingest/unmount-ingest-root.ts +5 -5
- package/src/navmesh/navmesh-handler.ts +24 -16
- package/src/network/NetworkInspectorPanel.tsx +449 -14
- package/src/network/network-inspector-model.ts +14 -2
- package/src/play/play-log-events.ts +1 -1
- package/src/play/play-mode.ts +76 -133
- package/src/play/play-recording.ts +1 -1
- package/src/play/react-play-live-authoring.ts +3 -3
- package/src/play/run-selection.ts +93 -0
- package/src/play-bar/PlayBar.tsx +20 -39
- package/src/profiler/FrameDebuggerPanel.tsx +1 -1
- package/src/profiler/PerformancePanel.tsx +3 -3
- package/src/react/design-time-react-mount.ts +23 -65
- package/src/react/dom-authoring-adapter.ts +9 -9
- package/src/react/react-inspector-section.tsx +5 -5
- package/src/react/react-world-authoring-adapter.ts +11 -11
- package/src/react/story-documents/story-documents.tsx +9 -9
- package/src/react/ui-board-document.tsx +7 -7
- package/src/react/ui-component-board.ts +2 -2
- package/src/runtime/adapter/audio-meter.ts +21 -0
- package/src/runtime/adapter/first-party-audio-system.ts +230 -0
- package/src/runtime/adapter/ingest/contract-debug-adapter.ts +114 -0
- package/src/runtime/adapter/ingest/contract-system-adapters.ts +256 -0
- package/src/runtime/adapter/ingest/merge-debug-adapters.ts +197 -0
- package/src/runtime/adapter/ingest/observation-debug-adapter.ts +162 -0
- package/src/runtime/adapter/ingest/upstream-pin.ts +51 -0
- package/src/runtime/adapter/native-debug-module.ts +498 -0
- package/src/runtime/audio/bus-mixer.ts +161 -0
- package/src/runtime/audio/pose-guard.ts +80 -0
- package/src/runtime/core/frame-pacing.ts +126 -0
- package/src/runtime/core/game-loop.ts +225 -0
- package/src/runtime/core/game-scoped-slot.ts +28 -0
- package/src/runtime/core/seeded-random.ts +162 -0
- package/src/runtime/core/sim-clock.ts +391 -0
- package/src/runtime/core/system-runner.ts +269 -0
- package/src/runtime/core/types.ts +104 -0
- package/src/runtime/create-runtime.ts +1128 -0
- package/src/runtime/debug-bridge.ts +570 -0
- package/src/runtime/debug-registry.ts +899 -0
- package/src/runtime/dev/chrome-trace.ts +153 -0
- package/src/runtime/dev/instruments.ts +403 -0
- package/src/runtime/dev/logger.ts +119 -0
- package/src/runtime/dev/performance-profiler.ts +367 -0
- package/src/runtime/dev/register-render-vitals.ts +276 -0
- package/src/runtime/dev/render-census.ts +354 -0
- package/src/runtime/dev/render-debug-adapter.ts +218 -0
- package/src/runtime/dev/render-memory.ts +226 -0
- package/src/runtime/dev/render-vitals.ts +338 -0
- package/src/runtime/dev/static-batch-advisor.ts +188 -0
- package/src/runtime/dev/webgl-frame-capture.ts +366 -0
- package/src/runtime/dev/webgl-gpu-timer.ts +53 -0
- package/src/runtime/dev-build.ts +47 -0
- package/src/runtime/game.ts +1636 -0
- package/src/runtime/gameplay-rng-trap.ts +135 -0
- package/src/runtime/host-context.ts +64 -0
- package/src/runtime/input-router.ts +169 -0
- package/src/runtime/mount-manifest.ts +480 -0
- package/src/runtime/pixi/authoring.ts +706 -0
- package/src/runtime/pixi/ingest.ts +116 -0
- package/src/runtime/pixi/physics-registry.ts +49 -0
- package/src/runtime/pixi/render-pass-bracket.ts +117 -0
- package/src/runtime/pixi/scene-capture.ts +179 -0
- package/src/runtime/pixi/system-adapters.ts +69 -0
- package/src/runtime/playtest.ts +22 -0
- package/src/runtime/presentation.ts +141 -0
- package/src/runtime/render-control.ts +642 -0
- package/src/runtime/render-seed.ts +77 -0
- package/src/runtime/run-ticks-settled.ts +73 -0
- package/src/runtime/setup/setup-audio.ts +72 -0
- package/src/services/audio-pose-guard.ts +2 -2
- package/src/services/game-audio.ts +152 -0
- package/src/services/game-network.ts +657 -0
- package/src/services/game-physics.ts +334 -0
- package/src/state-watch/StateWatchPanel.tsx +1 -1
- package/src/three/authoring/camera-runtime-inspector-section.tsx +3 -2
- package/src/three/authoring/constraint-inspector-section.tsx +6 -5
- package/src/three/authoring/model-asset-inspector-section.tsx +7 -6
- package/src/three/authoring/oid-source-persistence.ts +7 -7
- package/src/three/authoring/r3f-design-session.ts +58 -54
- package/src/three/authoring/r3f-source-authoring-adapter.ts +95 -91
- package/src/three/authoring/reflection-probe-inspector-section.tsx +3 -2
- package/src/three/authoring/three-authoring-adapter.ts +29 -29
- package/src/three/component-verbs/extract-menu.ts +7 -6
- package/src/three/component-verbs/fork-menu.ts +7 -6
- package/src/three/component-verbs/internals-menu.ts +2 -2
- package/src/three/story-documents/three-story-documents.tsx +13 -13
- package/src/three/three-board/ThreeBoardDocument.tsx +13 -12
- package/src/three/three-board/board-scene.ts +6 -6
- package/src/three/three-board/three-component-board.ts +2 -2
- package/src/services/game-audio-unlock.ts +0 -48
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The first-party `AudioAdapter` over the game runtime's master-gain bus
|
|
3
|
+
* (`setup/setup-audio.ts`'s `AudioContext.masterGain`) — the seam
|
|
4
|
+
* `Game.play.pause()` calls to silence a first-party world's audio, plus the
|
|
5
|
+
* graph snapshot and the native analyser taps the editor's Audio panel and
|
|
6
|
+
* gameplay recorder read.
|
|
7
|
+
*
|
|
8
|
+
* Its NAVIGATION sibling (`createNavigationAdapter`) is the three.js twin's —
|
|
9
|
+
* `@volter/threejs-runtime/adapter/first-party-navigation-system` — for the same
|
|
10
|
+
* reason this one is here: each lives with the subsystem it wraps.
|
|
11
|
+
*
|
|
12
|
+
* Restoring after a mute puts the gain back at whatever value it held right
|
|
13
|
+
* before muting (not a hardcoded `1`), which composes correctly with an
|
|
14
|
+
* independent manual mute: pausing while already user-muted resumes muted,
|
|
15
|
+
* exactly as a user would expect.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
AudioAdapter,
|
|
20
|
+
AudioGraphNode,
|
|
21
|
+
AudioMeterFrame,
|
|
22
|
+
AudioMeterHandle,
|
|
23
|
+
AudioRecordingHandle,
|
|
24
|
+
} from '@volter/editor-project/adapter/system-adapter';
|
|
25
|
+
import type { AudioContext as GameAudio } from '../setup/setup-audio';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* First-party `AudioAdapter` over the engine's master-gain bus
|
|
29
|
+
* (`setup-audio.ts`'s `AudioContext.masterGain`) — the seam `Game.play.pause()`
|
|
30
|
+
* (D10, T7.6) calls to silence a first-party world's audio. Restoring after a
|
|
31
|
+
* mute puts the gain back at whatever value it held right before muting (not
|
|
32
|
+
* a hardcoded `1`) — this composes correctly with an independent manual mute
|
|
33
|
+
* (the editor's `MuteButton`, `PlayBar.tsx`): pausing while already
|
|
34
|
+
* user-muted resumes muted, exactly as a user would expect.
|
|
35
|
+
*/
|
|
36
|
+
function audioNodeTypeName(value: object): string {
|
|
37
|
+
const name = (value as { constructor?: { name?: string } }).constructor?.name;
|
|
38
|
+
return name && name !== 'Object' ? name : 'AudioNode';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** This world's real native context, when it has one (headless worlds do not). */
|
|
42
|
+
function nativeAudioContext(audio: GameAudio): (BaseAudioContext & Partial<AudioContext>) | null {
|
|
43
|
+
const context = (audio.listener as { context?: BaseAudioContext } | undefined)?.context;
|
|
44
|
+
return context && typeof context.state === 'string'
|
|
45
|
+
? (context as BaseAudioContext & Partial<AudioContext>)
|
|
46
|
+
: null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Live meter handles per adapter, so a world teardown can release analyser
|
|
51
|
+
* taps a consumer forgot to dispose.
|
|
52
|
+
*
|
|
53
|
+
* This registry follows `acquireMeters` out of `audio-introspection.ts`: the
|
|
54
|
+
* safety net has to live wherever the taps are created, and they are created
|
|
55
|
+
* here now. Keyed weakly so an adapter that is simply dropped takes its entry
|
|
56
|
+
* with it.
|
|
57
|
+
*/
|
|
58
|
+
const meterHandlesByAdapter = new WeakMap<AudioAdapter, Set<AudioMeterHandle>>();
|
|
59
|
+
|
|
60
|
+
/** Release every meter tap still open on `adapter` (world stop / teardown). */
|
|
61
|
+
export function releaseAudioMeters(adapter: AudioAdapter): void {
|
|
62
|
+
const handles = meterHandlesByAdapter.get(adapter);
|
|
63
|
+
if (!handles) return;
|
|
64
|
+
for (const handle of [...handles]) handle.dispose();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createAudioSystemAdapter(audio: GameAudio): AudioAdapter {
|
|
68
|
+
let muted = false;
|
|
69
|
+
let priorGain = audio.masterGain.gain.value;
|
|
70
|
+
const liveMeterHandles = new Set<AudioMeterHandle>();
|
|
71
|
+
const adapter: AudioAdapter = {
|
|
72
|
+
resume: () => audio.resume(),
|
|
73
|
+
setMuted(next: boolean) {
|
|
74
|
+
if (next === muted) return;
|
|
75
|
+
muted = next;
|
|
76
|
+
if (next) {
|
|
77
|
+
priorGain = audio.masterGain.gain.value;
|
|
78
|
+
audio.masterGain.gain.value = 0;
|
|
79
|
+
} else {
|
|
80
|
+
audio.masterGain.gain.value = priorGain;
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
isMuted: () => muted,
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The engine's own bus hierarchy.
|
|
87
|
+
*
|
|
88
|
+
* This lived in `audio-introspection.ts` — the Tone-backed module — which
|
|
89
|
+
* meant the editor could only ever show an audio graph for a game that had
|
|
90
|
+
* loaded Tone. Nothing here needs Tone: it is the destination, the master
|
|
91
|
+
* gain, the three engine buses and the THREE listener. The Tone half (nodes
|
|
92
|
+
* a game routed through `connectToneBusToMasterGain`) is APPENDED by that
|
|
93
|
+
* module when it attaches, which is the honest split — Tone routes are
|
|
94
|
+
* Tone's to report.
|
|
95
|
+
*/
|
|
96
|
+
graphSnapshot(): AudioGraphNode[] {
|
|
97
|
+
const contextState = nativeAudioContext(audio)?.state;
|
|
98
|
+
const destination: AudioGraphNode = {
|
|
99
|
+
id: 'destination',
|
|
100
|
+
type: 'AudioDestinationNode',
|
|
101
|
+
label: 'Output',
|
|
102
|
+
outputs: [],
|
|
103
|
+
};
|
|
104
|
+
if (contextState !== undefined) destination.state = contextState;
|
|
105
|
+
const nodes: AudioGraphNode[] = [
|
|
106
|
+
destination,
|
|
107
|
+
{
|
|
108
|
+
id: 'master',
|
|
109
|
+
type: audioNodeTypeName(audio.masterGain),
|
|
110
|
+
label: 'Master',
|
|
111
|
+
outputs: ['destination'],
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: 'bus-music',
|
|
115
|
+
type: audioNodeTypeName(audio.buses.music),
|
|
116
|
+
label: 'Music bus',
|
|
117
|
+
outputs: ['master'],
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
id: 'bus-sfx',
|
|
121
|
+
type: audioNodeTypeName(audio.buses.sfx),
|
|
122
|
+
label: 'SFX bus',
|
|
123
|
+
outputs: ['master'],
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: 'bus-voice',
|
|
127
|
+
type: audioNodeTypeName(audio.buses.voice),
|
|
128
|
+
label: 'Voice bus',
|
|
129
|
+
outputs: ['master'],
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
if (audio.listener) {
|
|
133
|
+
nodes.push({
|
|
134
|
+
id: 'listener',
|
|
135
|
+
type: audioNodeTypeName(audio.listener),
|
|
136
|
+
label: '3D listener (THREE)',
|
|
137
|
+
outputs: ['master'],
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return nodes;
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Per-bus linear-RMS levels, via native `AnalyserNode` taps.
|
|
145
|
+
*
|
|
146
|
+
* Zero Tone — analysers work identically whether or not Tone is bridged,
|
|
147
|
+
* and disposal is a plain disconnect. It was in the Tone module purely by
|
|
148
|
+
* where it was written, so a game that never touches Tone had no meters.
|
|
149
|
+
*/
|
|
150
|
+
acquireMeters(): AudioMeterHandle | null {
|
|
151
|
+
const meterContext = nativeAudioContext(audio);
|
|
152
|
+
if (!meterContext || typeof meterContext.createAnalyser !== 'function') {
|
|
153
|
+
return null; // headless world — metering is impossible, and says so
|
|
154
|
+
}
|
|
155
|
+
const taps = (
|
|
156
|
+
[
|
|
157
|
+
['master', 'Master', audio.masterGain],
|
|
158
|
+
['bus-music', 'Music', audio.buses.music],
|
|
159
|
+
['bus-sfx', 'SFX', audio.buses.sfx],
|
|
160
|
+
['bus-voice', 'Voice', audio.buses.voice],
|
|
161
|
+
] as const
|
|
162
|
+
).map(([id, label, gain]) => {
|
|
163
|
+
const analyser = meterContext.createAnalyser();
|
|
164
|
+
analyser.fftSize = 1024;
|
|
165
|
+
gain.connect(analyser); // a TAP (analyser is a sink), not an insert
|
|
166
|
+
return { id, label, gain, analyser, buffer: new Float32Array(analyser.fftSize) };
|
|
167
|
+
});
|
|
168
|
+
let disposed = false;
|
|
169
|
+
const handle: AudioMeterHandle = {
|
|
170
|
+
read: (): AudioMeterFrame[] =>
|
|
171
|
+
taps.map(({ id, label, analyser, buffer }) => {
|
|
172
|
+
analyser.getFloatTimeDomainData(buffer);
|
|
173
|
+
let sum = 0;
|
|
174
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
175
|
+
const sample = buffer[i] as number;
|
|
176
|
+
sum += sample * sample;
|
|
177
|
+
}
|
|
178
|
+
return { id, label, level: Math.sqrt(sum / buffer.length) };
|
|
179
|
+
}),
|
|
180
|
+
dispose: (): void => {
|
|
181
|
+
if (disposed) return;
|
|
182
|
+
disposed = true;
|
|
183
|
+
for (const tap of taps) {
|
|
184
|
+
try {
|
|
185
|
+
tap.gain.disconnect(tap.analyser);
|
|
186
|
+
} catch {
|
|
187
|
+
// already torn down with the context — nothing to release
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
liveMeterHandles.delete(handle);
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
liveMeterHandles.add(handle);
|
|
194
|
+
return handle;
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
/** Native Web Audio tap used by the editor's gameplay recorder. It is a
|
|
198
|
+
* sink beside the speakers, never an insert, so recording cannot change
|
|
199
|
+
* what the player hears. A suspended context produces no audio frames and
|
|
200
|
+
* stalls MediaRecorder muxing, so it cannot supply a recording track. */
|
|
201
|
+
acquireRecordingStream(): AudioRecordingHandle | null {
|
|
202
|
+
const recordingContext = nativeAudioContext(audio);
|
|
203
|
+
if (
|
|
204
|
+
!recordingContext ||
|
|
205
|
+
recordingContext.state !== 'running' ||
|
|
206
|
+
typeof recordingContext.createMediaStreamDestination !== 'function'
|
|
207
|
+
) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
const destination = recordingContext.createMediaStreamDestination();
|
|
211
|
+
audio.masterGain.connect(destination);
|
|
212
|
+
let disposed = false;
|
|
213
|
+
return {
|
|
214
|
+
stream: destination.stream,
|
|
215
|
+
dispose(): void {
|
|
216
|
+
if (disposed) return;
|
|
217
|
+
disposed = true;
|
|
218
|
+
try {
|
|
219
|
+
audio.masterGain.disconnect(destination);
|
|
220
|
+
} catch {
|
|
221
|
+
// The world/context may already have been torn down.
|
|
222
|
+
}
|
|
223
|
+
for (const track of destination.stream.getTracks()) track.stop();
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
meterHandlesByAdapter.set(adapter, liveMeterHandles);
|
|
229
|
+
return adapter;
|
|
230
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projects a game's DECLARED system surface (`window.vgaiGame.systems`, see
|
|
3
|
+
* `game-contract.ts`) onto the host's existing {@link DebugAdapter} — the one
|
|
4
|
+
* seam `game.commands()` / `game.state()` / `game.providers()` already read
|
|
5
|
+
* through for first-party content (`command-listener.ts`'s
|
|
6
|
+
* `dispatchBridgeMethod` → `getActiveSystems().debug`).
|
|
7
|
+
*
|
|
8
|
+
* Why a projection rather than a second door: an agent driving an ingested
|
|
9
|
+
* game must not have to learn a parallel vocabulary. A first-party game
|
|
10
|
+
* registers verbs with `ctx.debug.registerCommand`; an ingested game declares
|
|
11
|
+
* them in its entry shim. Both arrive at the SAME `DebugAdapter`, so every
|
|
12
|
+
* consumer downstream — the CLI's `vgai eval`, `@vgai/e2e`'s `GameClient`, the
|
|
13
|
+
* editor's Debug Console and State Watch panels — works unchanged and unaware
|
|
14
|
+
* of the provenance.
|
|
15
|
+
*
|
|
16
|
+
* What this deliberately does NOT do:
|
|
17
|
+
* - **Validate arguments.** The first-party path validates against a declared
|
|
18
|
+
* Zod tuple; a shim beside a foreign game is plain JS and has none. A verb
|
|
19
|
+
* validates itself and throws its own error, which surfaces wrapped as
|
|
20
|
+
* `DEBUG_COMMAND_FAILED` — the host never invents a schema to check against.
|
|
21
|
+
* - **Fabricate an event ring.** There is no tick loop behind an ingested
|
|
22
|
+
* game's declarations, so `events()` is empty. That is accurate (nothing was
|
|
23
|
+
* emitted), not a stub standing in for a missing feature.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { VgaiGameSystems } from '@volter/editor-project/adapter/ingest/game-contract';
|
|
27
|
+
import type {
|
|
28
|
+
DebugAdapter,
|
|
29
|
+
DebugCommandInfo,
|
|
30
|
+
TickStampedEvent,
|
|
31
|
+
} from '@volter/editor-project/adapter/system-adapter';
|
|
32
|
+
import { DebugError } from '../../debug-registry';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build a {@link DebugAdapter} over a declared system surface, or `null` when
|
|
36
|
+
* the game declared no verbs and no state. `null` is the honest floor: the
|
|
37
|
+
* bridge then reports `DEBUG_ADAPTER_UNAVAILABLE` exactly as it did before,
|
|
38
|
+
* rather than serving an empty adapter that reads as "this game has no state"
|
|
39
|
+
* when the truth is "this game declared nothing".
|
|
40
|
+
*/
|
|
41
|
+
export function createContractDebugAdapter(
|
|
42
|
+
systems: VgaiGameSystems | undefined | null,
|
|
43
|
+
): DebugAdapter | null {
|
|
44
|
+
const commandList = systems?.commands ?? [];
|
|
45
|
+
const providerList = systems?.state ?? [];
|
|
46
|
+
if (commandList.length === 0 && providerList.length === 0) return null;
|
|
47
|
+
|
|
48
|
+
// Snapshot into name-keyed maps once. A later duplicate shadows an earlier
|
|
49
|
+
// one and the listing de-duplicates with it, so `commands()` can never
|
|
50
|
+
// advertise a name `invoke()` would resolve differently.
|
|
51
|
+
const byCommand = new Map(commandList.map((c) => [c.name, c]));
|
|
52
|
+
const byProvider = new Map(providerList.map((p) => [p.name, p]));
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
providers: () =>
|
|
56
|
+
[...byProvider.values()].map((p) => ({ name: p.name, tier: p.tier ?? 'observable' })),
|
|
57
|
+
|
|
58
|
+
state: (name: string): unknown => {
|
|
59
|
+
const provider = byProvider.get(name);
|
|
60
|
+
if (!provider) {
|
|
61
|
+
throw new DebugError(
|
|
62
|
+
'STATE_PROVIDER_NOT_FOUND',
|
|
63
|
+
`No state provider named "${name}" is declared by this game.`,
|
|
64
|
+
{ registered: [...byProvider.keys()] },
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return provider.read();
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
stateAll: (): Record<string, unknown> => {
|
|
71
|
+
const out: Record<string, unknown> = {};
|
|
72
|
+
for (const [name, provider] of byProvider) {
|
|
73
|
+
// One throwing provider must not cost the caller every other reading —
|
|
74
|
+
// the same per-key isolation the first-party registry gives.
|
|
75
|
+
try {
|
|
76
|
+
out[name] = provider.read();
|
|
77
|
+
} catch (err) {
|
|
78
|
+
out[name] = { __error: String(err) };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
commands: (): DebugCommandInfo[] =>
|
|
85
|
+
[...byCommand.values()].map((c) => ({
|
|
86
|
+
name: c.name,
|
|
87
|
+
description: c.description,
|
|
88
|
+
argsJsonSchema: c.argsJsonSchema,
|
|
89
|
+
// An ingested game runs entirely in the browser realm the host mounted
|
|
90
|
+
// it in; there is no server leg for its verbs to route to.
|
|
91
|
+
locus: 'client' as const,
|
|
92
|
+
})),
|
|
93
|
+
|
|
94
|
+
invoke: async (name: string, args: unknown[]): Promise<unknown> => {
|
|
95
|
+
const command = byCommand.get(name);
|
|
96
|
+
if (!command) {
|
|
97
|
+
throw new DebugError(
|
|
98
|
+
'DEBUG_COMMAND_NOT_REGISTERED',
|
|
99
|
+
`No command named "${name}" is declared by this game.`,
|
|
100
|
+
{ registered: [...byCommand.keys()] },
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
return await command.run(...args);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
throw new DebugError('DEBUG_COMMAND_FAILED', `Command "${name}" threw: ${String(err)}`, {
|
|
107
|
+
name,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
events: (): TickStampedEvent[] => [],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projects a game's declared {@link VgaiGameSystemAdapters}
|
|
3
|
+
* (`window.vgaiGame.systems.systemAdapters`) onto the host's ordinary
|
|
4
|
+
* {@link SystemAdapters} bag — the ingest-realm counterpart of a first-party
|
|
5
|
+
* world's `ctx.registerSystemAdapter`, and the sibling of
|
|
6
|
+
* `contract-debug-adapter.ts` (which owns the `debug` slot).
|
|
7
|
+
*
|
|
8
|
+
* ## Why a projection with a verdict per slot
|
|
9
|
+
*
|
|
10
|
+
* The ingestion bar (docs/BRINGING-AN-EXISTING-GAME.md §The ingestion
|
|
11
|
+
* checklist) admits exactly two terminal states per slot: IMPLEMENTED or
|
|
12
|
+
* IMPLEMENTED-EMPTY. `SystemAdapters` alone cannot express the second — an
|
|
13
|
+
* omitted key means "unsupported", which is the same shape as "nobody looked".
|
|
14
|
+
* So this returns THREE lists, and the caller hands the non-`bound` ones to the
|
|
15
|
+
* coverage report, where a positively-answered absence prints as terminal and an
|
|
16
|
+
* unanswered slot prints as a work order.
|
|
17
|
+
*
|
|
18
|
+
* ## Why the shape check
|
|
19
|
+
*
|
|
20
|
+
* A slot that declares `{}` would otherwise register as "implemented" and light
|
|
21
|
+
* up an editor panel over an object with no methods. Each slot therefore names
|
|
22
|
+
* the members its interface makes REQUIRED, and a declaration missing any of
|
|
23
|
+
* them is `malformed` — reported by name, never silently bound and never
|
|
24
|
+
* silently dropped. This is the same honesty the anti-shim rule asks of the
|
|
25
|
+
* other direction: a wrong "present" claim is as much a defect as a wrong
|
|
26
|
+
* "empty" one.
|
|
27
|
+
*
|
|
28
|
+
* ## Why `physics` also gets a KEYING check
|
|
29
|
+
*
|
|
30
|
+
* The physics slot is the one whose vocabulary follows the SURFACE
|
|
31
|
+
* ({@link PHYSICS_KEYING_BY_SURFACE}): a three-surface game keys by host node
|
|
32
|
+
* id, a canvas-surface game by the display object it already holds. Both shapes
|
|
33
|
+
* carry the same four member names, so presence-and-typeof cannot tell them
|
|
34
|
+
* apart — a carrier keyed for the other surface would pass every check here and
|
|
35
|
+
* then be called with values it has none of, which is the "green coverage row
|
|
36
|
+
* bought with four methods that have no caller" defect this whole slot was left
|
|
37
|
+
* open to avoid. So a mount that KNOWS its surface says so, and a mismatch is
|
|
38
|
+
* `malformed` by name. A caller with no surface fact (`undefined`) does not
|
|
39
|
+
* check — it has nothing to check against, and inventing a default would be a
|
|
40
|
+
* guess wearing a verdict.
|
|
41
|
+
*
|
|
42
|
+
* Nothing here calls a declared method. Presence and shape only.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import type {
|
|
46
|
+
ContractSystemEmptySlot,
|
|
47
|
+
ContractSystemMalformedSlot,
|
|
48
|
+
ContractSystemSlot,
|
|
49
|
+
} from '@volter/editor-project/adapter/ingest/contract-system-slots';
|
|
50
|
+
import type { SystemAdapters } from '@volter/editor-project/adapter/system-adapter';
|
|
51
|
+
|
|
52
|
+
export type {
|
|
53
|
+
ContractSystemEmptySlot,
|
|
54
|
+
ContractSystemMalformedSlot,
|
|
55
|
+
} from '@volter/editor-project/adapter/ingest/contract-system-slots';
|
|
56
|
+
|
|
57
|
+
import type {
|
|
58
|
+
VgaiGameSystemAdapters,
|
|
59
|
+
VgaiGameSystemEmpty,
|
|
60
|
+
VgaiGameSystems,
|
|
61
|
+
} from '@volter/editor-project/adapter/ingest/game-contract';
|
|
62
|
+
import { GAME_SYSTEM_ADAPTERS_SHAPE } from '@volter/editor-project/adapter/ingest/game-contract-seams';
|
|
63
|
+
import {
|
|
64
|
+
AUDIO_ADAPTER_SHAPE,
|
|
65
|
+
CAMERA_ADAPTER_SHAPE,
|
|
66
|
+
NAVIGATION_ADAPTER_SHAPE,
|
|
67
|
+
NETWORKING_ADAPTER_SHAPE,
|
|
68
|
+
PHYSICS_ADAPTER_SHAPE,
|
|
69
|
+
RENDER_DEBUG_ADAPTER_SHAPE,
|
|
70
|
+
} from '@volter/editor-project/adapter/system-seam-contract';
|
|
71
|
+
|
|
72
|
+
/** The slots a game may declare through the contract (`debug` is projected from
|
|
73
|
+
* `commands`/`state` instead — see {@link VgaiGameSystemAdapters}). */
|
|
74
|
+
export type { ContractSystemSlot } from '@volter/editor-project/adapter/ingest/contract-system-slots';
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The members each slot's interface makes NON-optional. A declaration missing
|
|
78
|
+
* one of these cannot honour the interface, whatever else it carries.
|
|
79
|
+
*
|
|
80
|
+
* This table is the single place the required surface is spelled; it is checked
|
|
81
|
+
* against the interfaces by the compiler at the point of use below (each list
|
|
82
|
+
* is typed as keys of its own adapter), so it cannot drift into naming a member
|
|
83
|
+
* that does not exist.
|
|
84
|
+
*/
|
|
85
|
+
const SYSTEM_SHAPES = {
|
|
86
|
+
physics: PHYSICS_ADAPTER_SHAPE,
|
|
87
|
+
networking: NETWORKING_ADAPTER_SHAPE,
|
|
88
|
+
navigation: NAVIGATION_ADAPTER_SHAPE,
|
|
89
|
+
audio: AUDIO_ADAPTER_SHAPE,
|
|
90
|
+
camera: CAMERA_ADAPTER_SHAPE,
|
|
91
|
+
renderDebug: RENDER_DEBUG_ADAPTER_SHAPE,
|
|
92
|
+
} as const satisfies Record<ContractSystemSlot, object>;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The render surface a declaration is being read ON — the same three words
|
|
96
|
+
* `IngestKind`/`AdapterSurface` already use, so nothing has to be declared
|
|
97
|
+
* twice.
|
|
98
|
+
*/
|
|
99
|
+
export type ContractSurface = 'three' | 'canvas' | 'dom';
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* THE surface→physics-vocabulary rule, stated once.
|
|
103
|
+
*
|
|
104
|
+
* `null` for `dom`: that surface has no physics consumer at all, so nothing
|
|
105
|
+
* here can say which keying would be right for it and the check stands down
|
|
106
|
+
* rather than inventing an answer.
|
|
107
|
+
*/
|
|
108
|
+
const PHYSICS_KEYING_BY_SURFACE: Readonly<Record<ContractSurface, 'node-id' | 'display' | null>> = {
|
|
109
|
+
three: 'node-id',
|
|
110
|
+
canvas: 'display',
|
|
111
|
+
dom: null,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** Fixed slot order, so two reports of the same mount are diffable line for
|
|
115
|
+
* line (the same rule the coverage report's row order follows). */
|
|
116
|
+
export const CONTRACT_SYSTEM_SLOTS = Object.keys(
|
|
117
|
+
GAME_SYSTEM_ADAPTERS_SHAPE,
|
|
118
|
+
) as ContractSystemSlot[];
|
|
119
|
+
|
|
120
|
+
export interface ContractSystemAdapterProjection {
|
|
121
|
+
/** Real adapters, ready to spread onto a `MountedRoot`'s `systems`. */
|
|
122
|
+
readonly bound: Partial<Pick<SystemAdapters, ContractSystemSlot>>;
|
|
123
|
+
/** Slots answered IMPLEMENTED-EMPTY, with the evidence. */
|
|
124
|
+
readonly empty: readonly ContractSystemEmptySlot[];
|
|
125
|
+
/** Slots declared but unusable — bound to nothing, reported by name. */
|
|
126
|
+
readonly malformed: readonly ContractSystemMalformedSlot[];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const EMPTY_PROJECTION: ContractSystemAdapterProjection = {
|
|
130
|
+
bound: {},
|
|
131
|
+
empty: [],
|
|
132
|
+
malformed: [],
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
function isEmptyRecord(value: object): value is VgaiGameSystemEmpty {
|
|
136
|
+
return (value as { present?: unknown }).present === false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function missingMembers(slot: ContractSystemSlot, value: object): string[] {
|
|
140
|
+
const missing: string[] = [];
|
|
141
|
+
for (const [member, spec] of Object.entries(
|
|
142
|
+
SYSTEM_SHAPES[slot] as Readonly<Record<string, { readonly optional: boolean }>>,
|
|
143
|
+
)) {
|
|
144
|
+
if (spec.optional) continue;
|
|
145
|
+
if (typeof (value as Record<string, unknown>)[member] !== 'function') {
|
|
146
|
+
missing.push(member);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return missing;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The reason a declared physics carrier cannot serve `surface`, or `null` when
|
|
154
|
+
* it can (including when the caller supplied no surface to check against).
|
|
155
|
+
*
|
|
156
|
+
* The keying a carrier actually has is its tag's own answer, and an ABSENT tag
|
|
157
|
+
* IS `'node-id'` — see `PhysicsAdapter`'s comment for why the original
|
|
158
|
+
* vocabulary is the untagged one.
|
|
159
|
+
*/
|
|
160
|
+
function physicsKeyingMismatch(value: object, surface: ContractSurface | undefined): string | null {
|
|
161
|
+
const want = surface ? PHYSICS_KEYING_BY_SURFACE[surface] : null;
|
|
162
|
+
if (want === null) return null;
|
|
163
|
+
const got = (value as { keyedBy?: unknown }).keyedBy === 'display' ? 'display' : 'node-id';
|
|
164
|
+
if (got === want) return null;
|
|
165
|
+
return (
|
|
166
|
+
`declared a ${got}-keyed physics carrier on the ${surface} surface, whose gizmo path ` +
|
|
167
|
+
`addresses bodies by ${want} — the four members are all present but nothing on this lane ` +
|
|
168
|
+
`can call them with values they accept (a ${surface}-surface carrier declares ` +
|
|
169
|
+
`\`keyedBy: '${want}'\`)`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** One slot's verdict, before it is filed into the three lists. */
|
|
174
|
+
type SlotVerdict =
|
|
175
|
+
| { readonly kind: 'skip' }
|
|
176
|
+
| { readonly kind: 'bound' }
|
|
177
|
+
| { readonly kind: 'empty'; readonly evidence: string }
|
|
178
|
+
| { readonly kind: 'malformed'; readonly reason: string };
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* THE per-slot decision, in the order the module header states: is it declared
|
|
182
|
+
* at all, is it the positively-answered empty, does it carry the members its
|
|
183
|
+
* interface requires, and (physics only) is it keyed for this surface.
|
|
184
|
+
*
|
|
185
|
+
* Split out of {@link projectContractSystemAdapters} so the projection itself
|
|
186
|
+
* is a loop over verdicts — one place decides, one place files.
|
|
187
|
+
*/
|
|
188
|
+
function classifyDeclaredSlot(
|
|
189
|
+
slot: ContractSystemSlot,
|
|
190
|
+
value: unknown,
|
|
191
|
+
surface: ContractSurface | undefined,
|
|
192
|
+
): SlotVerdict {
|
|
193
|
+
if (value === undefined || value === null) return { kind: 'skip' };
|
|
194
|
+
if (typeof value !== 'object') {
|
|
195
|
+
return {
|
|
196
|
+
kind: 'malformed',
|
|
197
|
+
reason: `declared as ${typeof value}; expected an adapter object or { present: false, evidence }`,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
if (isEmptyRecord(value)) {
|
|
201
|
+
const evidence = (value as VgaiGameSystemEmpty).evidence;
|
|
202
|
+
// An empty claim with no evidence is exactly the "wrong empty claim" the
|
|
203
|
+
// bar calls a defect — it asserts an absence nobody can re-check.
|
|
204
|
+
if (typeof evidence !== 'string' || evidence.trim() === '') {
|
|
205
|
+
return {
|
|
206
|
+
kind: 'malformed',
|
|
207
|
+
reason: 'declared `present: false` with no `evidence` string to back the absence',
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return { kind: 'empty', evidence };
|
|
211
|
+
}
|
|
212
|
+
const missing = missingMembers(slot, value);
|
|
213
|
+
if (missing.length > 0) {
|
|
214
|
+
return {
|
|
215
|
+
kind: 'malformed',
|
|
216
|
+
reason: `declared an adapter missing required member(s): ${missing.join(', ')}`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
const mismatch = slot === 'physics' ? physicsKeyingMismatch(value, surface) : null;
|
|
220
|
+
if (mismatch !== null) return { kind: 'malformed', reason: mismatch };
|
|
221
|
+
return { kind: 'bound' };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Read the declared slots and sort each into exactly one verdict. A game that
|
|
226
|
+
* declared no `systemAdapters` at all projects to three empty lists — which is
|
|
227
|
+
* the "nobody answered" case, and is deliberately NOT the same as declaring
|
|
228
|
+
* every slot empty.
|
|
229
|
+
*
|
|
230
|
+
* `surface` is the mount's own surface fact when it has one; see the module
|
|
231
|
+
* header for what it buys and why its absence checks nothing.
|
|
232
|
+
*/
|
|
233
|
+
export function projectContractSystemAdapters(
|
|
234
|
+
systems: VgaiGameSystems | undefined | null,
|
|
235
|
+
surface?: ContractSurface | undefined,
|
|
236
|
+
): ContractSystemAdapterProjection {
|
|
237
|
+
const declared = systems?.systemAdapters;
|
|
238
|
+
if (!declared) return EMPTY_PROJECTION;
|
|
239
|
+
|
|
240
|
+
const bound: Record<string, unknown> = {};
|
|
241
|
+
const empty: ContractSystemEmptySlot[] = [];
|
|
242
|
+
const malformed: ContractSystemMalformedSlot[] = [];
|
|
243
|
+
|
|
244
|
+
for (const slot of CONTRACT_SYSTEM_SLOTS) {
|
|
245
|
+
const verdict = classifyDeclaredSlot(slot, declared[slot], surface);
|
|
246
|
+
if (verdict.kind === 'bound') bound[slot] = declared[slot];
|
|
247
|
+
else if (verdict.kind === 'empty') empty.push({ slot, evidence: verdict.evidence });
|
|
248
|
+
else if (verdict.kind === 'malformed') malformed.push({ slot, reason: verdict.reason });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
bound: bound as ContractSystemAdapterProjection['bound'],
|
|
253
|
+
empty,
|
|
254
|
+
malformed,
|
|
255
|
+
};
|
|
256
|
+
}
|