@volter/project 0.0.0-stage → 0.5.203
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 +202 -0
- package/NOTICE +6 -0
- package/README.md +18 -3
- package/package.json +64 -4
- package/schemas/engine-api.json +57 -0
- package/schemas/engine-api.md +22 -0
- package/schemas/volter-project.schema.json +468 -0
- package/schemas/volter-settings.schema.json +149 -0
- package/src/adapter/adapter-module.ts +997 -0
- package/src/adapter/adapter-surface.ts +34 -0
- package/src/adapter/authoring-seam-contract.ts +224 -0
- package/src/adapter/authoring.ts +1336 -0
- package/src/adapter/binding.ts +445 -0
- package/src/adapter/debug-registry-ref.ts +19 -0
- package/src/adapter/editor-looks.ts +519 -0
- package/src/adapter/finders/finder-result.ts +29 -0
- package/src/adapter/finders/registry.ts +116 -0
- package/src/adapter/frame-capture.ts +80 -0
- package/src/adapter/host-context.ts +114 -0
- package/src/adapter/index.ts +209 -0
- package/src/adapter/ingest/contract-system-slots.ts +28 -0
- package/src/adapter/ingest/game-contract-seams.ts +86 -0
- package/src/adapter/ingest/game-contract.ts +506 -0
- package/src/adapter/ingest/mount-readiness.ts +140 -0
- package/src/adapter/loop-gate-report.ts +53 -0
- package/src/adapter/manifest-interpreter.ts +64 -0
- package/src/adapter/native-entry-surface.ts +100 -0
- package/src/adapter/physics-adapter-2d.ts +30 -0
- package/src/adapter/render-audio.ts +20 -0
- package/src/adapter/render-memory.ts +34 -0
- package/src/adapter/renderer-config.ts +84 -0
- package/src/adapter/root-adapter.ts +243 -0
- package/src/adapter/root-seam-contract.ts +61 -0
- package/src/adapter/root-stacking.ts +67 -0
- package/src/adapter/seam-evidence.ts +336 -0
- package/src/adapter/system-adapter.ts +865 -0
- package/src/adapter/system-seam-contract.ts +138 -0
- package/src/adapter/system-slot.ts +301 -0
- package/src/adapter/transform.ts +35 -0
- package/src/adapter/workspace-arrangement.ts +92 -0
- package/src/core/system-phase.ts +52 -0
- package/src/inspection.ts +281 -0
- package/src/manifest/configuration-kinds.ts +241 -0
- package/src/manifest/editor-port.ts +69 -0
- package/src/manifest/filename.ts +45 -0
- package/src/manifest/kind-modules.ts +58 -0
- package/src/manifest/load-file.ts +33 -0
- package/src/manifest/load.ts +380 -0
- package/src/manifest/locate.ts +55 -0
- package/src/manifest/runtime-environment.ts +48 -0
- package/src/manifest/schema.ts +690 -0
- package/src/settings/keys.ts +152 -0
- package/src/settings/schema.ts +151 -0
|
@@ -0,0 +1,997 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `editor/volter.adapter.ts` — THE ADAPTER MODULE CONTRACT.
|
|
3
|
+
*
|
|
4
|
+
* ARCHITECTURE-CORE §The editor protocol, "The adapter is the game's own
|
|
5
|
+
* module": the editor is a universal CLIENT of a fixed protocol and every game
|
|
6
|
+
* supplies a SERVER — its adapter. That adapter is the game's OWN module,
|
|
7
|
+
* `editor/volter.adapter.ts`, sitting beside `volter.project.json`, default-exporting
|
|
8
|
+
* {@link defineAdapter}'s result.
|
|
9
|
+
*
|
|
10
|
+
* It is CODE (bindings are expressions — a scene entry may carry a closure the
|
|
11
|
+
* host evaluates against the mounted game) with a CONFIG's discipline: the top
|
|
12
|
+
* level is a STATICALLY EVALUABLE BINDING TABLE, readable without booting the
|
|
13
|
+
* game. Concretely, that is the split this module enforces:
|
|
14
|
+
*
|
|
15
|
+
* - the TABLE SHAPE — regions, the scene table, observation declarations —
|
|
16
|
+
* is plain data, validated by {@link AdapterDefinitionSchema} the moment
|
|
17
|
+
* the module is evaluated. `.strict()` throughout: an unrecognized key is
|
|
18
|
+
* an error naming the key, never a silent pass-through (CLAUDE.md,
|
|
19
|
+
* "Unknown input must REJECT LOUDLY").
|
|
20
|
+
* - individual FIELDS may be closures, evaluated lazily against the mounted
|
|
21
|
+
* game (`ObservationDeclaration.answer`). A closure is never required to
|
|
22
|
+
* read the table. `editor.Layout` is an imported React component: the
|
|
23
|
+
* editor invokes it to compose surfaces and edit/play behavior. It is a
|
|
24
|
+
* runtime value, never a serialized manifest setting or a registry key.
|
|
25
|
+
*
|
|
26
|
+
* **Zero inference.** A binding is a DECLARATION or a finder SELECTION the
|
|
27
|
+
* adapter makes — never something the host sniffs out. Finder algorithms
|
|
28
|
+
* (scenes from an entrypoint selection, prefabs from story registrations, …)
|
|
29
|
+
* ship host-side under `adapter/finders/`, and this module names them by
|
|
30
|
+
* SELECTION only ({@link FinderSelection}). That is deliberate and load-bearing
|
|
31
|
+
* twice over:
|
|
32
|
+
* 1. the engine never runs a finder nobody selected — enforced mechanically
|
|
33
|
+
* by `packages/engine/test/finder-import-boundary.test.ts`, whose only
|
|
34
|
+
* sanctioned importer of the finder namespace is the editor's adapter
|
|
35
|
+
* loader;
|
|
36
|
+
* 2. this module therefore imports NOTHING from `adapter/finders/`, so a
|
|
37
|
+
* game's `editor/volter.adapter.ts` pulls no finder implementation (and no source
|
|
38
|
+
* parser) into its own bundle. The dependency runs one way: finders
|
|
39
|
+
* import their parameter types from HERE.
|
|
40
|
+
*
|
|
41
|
+
* The manifest keeps only what must be readable without evaluating any module
|
|
42
|
+
* — identity, roots, boot mode, `server.room`. Everything that binds behavior
|
|
43
|
+
* is the adapter's. This wave adds NO manifest field.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import type { KeymapContribution, StyleContribution } from './editor-looks';
|
|
47
|
+
import type { ComponentType } from 'react';
|
|
48
|
+
import { z } from 'zod';
|
|
49
|
+
import type { ResolvedAdapterRoot } from '../manifest/load';
|
|
50
|
+
import type { AdapterSurface } from './adapter-surface';
|
|
51
|
+
import { WRITE_ANCHOR_KINDS, type WriteAnchorKind } from './authoring';
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Regions
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The native substrate serving a region. A surface is only the host-provided
|
|
59
|
+
* medium (`canvas`); this declaration is what distinguishes Pixi, Babylon and
|
|
60
|
+
* Phaser without runtime sniffing.
|
|
61
|
+
*/
|
|
62
|
+
export const SUBSTRATE_NAMES = ['three', 'pixi', 'dom', 'babylon', 'phaser'] as const;
|
|
63
|
+
export type SubstrateName = (typeof SUBSTRATE_NAMES)[number];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A region's WORLD BASIS — which axis points up, and where its ground sits.
|
|
67
|
+
*
|
|
68
|
+
* The editor needs a ground plane to place a dropped asset or a probe point on.
|
|
69
|
+
* That plane used to be the constant `y = 0` compiled into `editor-viewport.ts`,
|
|
70
|
+
* which is a guess about the GAME's convention: a Z-up world (the CAD/Blender
|
|
71
|
+
* idiom foreign R3F games routinely carry) got its drops silently projected onto
|
|
72
|
+
* the wrong plane. It is exactly the shape "zero inference" names — a fact the
|
|
73
|
+
* game's author can state, so the fix is a declaration slot rather than a
|
|
74
|
+
* smarter sniff (ARCHITECTURE-CORE §The editor protocol).
|
|
75
|
+
*/
|
|
76
|
+
export interface AdapterRegionBasis {
|
|
77
|
+
/** World axis pointing away from the ground. */
|
|
78
|
+
readonly up: 'y' | 'z';
|
|
79
|
+
/** Ground offset along {@link up}, in world units. */
|
|
80
|
+
readonly groundHeight: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The NATIVE basis — what {@link regionsFromManifestRoots} emits and what a
|
|
85
|
+
* region that declares none is read as. +Y up, ground at 0: three.js' own
|
|
86
|
+
* convention, which every first-party root is authored in. Stated as a value so
|
|
87
|
+
* "the default" has one home instead of a copy at each reader.
|
|
88
|
+
*/
|
|
89
|
+
export const NATIVE_REGION_BASIS: AdapterRegionBasis = Object.freeze({
|
|
90
|
+
up: 'y',
|
|
91
|
+
groundHeight: 0,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* One matrix REGION: a root, its surface, and the library selections that
|
|
96
|
+
* serve it. Universality lives at region granularity — which libraries serve a
|
|
97
|
+
* surface is a per-surface ENGINE fact, so a new surface root in an existing
|
|
98
|
+
* game is one manifest line and one derived region, never new adapter code
|
|
99
|
+
* (that is exactly what {@link regionsFromManifestRoots} does).
|
|
100
|
+
*/
|
|
101
|
+
export interface AdapterRegion {
|
|
102
|
+
/** The manifest root this region grades. */
|
|
103
|
+
readonly id: string;
|
|
104
|
+
/** What the host hands this root. */
|
|
105
|
+
readonly surface: AdapterSurface;
|
|
106
|
+
/** Native substrate, by name. */
|
|
107
|
+
readonly substrate: SubstrateName;
|
|
108
|
+
/**
|
|
109
|
+
* Dialect writer, by name (`r3f`, `jsx`, `pixi-react`, …) — the truth
|
|
110
|
+
* family's source writer for this region. `null` states outright that the
|
|
111
|
+
* host writes no source here: the region's truth is the game's own code,
|
|
112
|
+
* which the host does not author (a module or ingest root).
|
|
113
|
+
*/
|
|
114
|
+
readonly dialect: string | null;
|
|
115
|
+
/**
|
|
116
|
+
* Anchor kinds this region's writer serves, drawn from the compile-pinned
|
|
117
|
+
* {@link WRITE_ANCHOR_KINDS} vocabulary. A kind absent here is a red cell in
|
|
118
|
+
* the matrix, which is a work order — never a silently unsupported edit.
|
|
119
|
+
*/
|
|
120
|
+
readonly anchors: readonly WriteAnchorKind[];
|
|
121
|
+
/**
|
|
122
|
+
* This region's world basis. Omitted = {@link NATIVE_REGION_BASIS}, which is
|
|
123
|
+
* a DECLARED default (the native adapter emits it outright), not a silent
|
|
124
|
+
* fallback — same shape as an absent `editor/volter.adapter.ts` meaning
|
|
125
|
+
* `nativeAdapter()`.
|
|
126
|
+
*/
|
|
127
|
+
readonly basis?: AdapterRegionBasis;
|
|
128
|
+
/**
|
|
129
|
+
* Project-relative globs naming source files this region OWNS, for the
|
|
130
|
+
* ambiguous remainder that import reach cannot place.
|
|
131
|
+
*
|
|
132
|
+
* A file's surface/dialect is derived from WHICH REGION'S IMPORT CLOSURE
|
|
133
|
+
* reaches it — a load-bearing fact, because the region's manifest entry is
|
|
134
|
+
* what the host actually executes at mount (the game would break first if it
|
|
135
|
+
* lied). Reach through several regions of the SAME surface is unambiguous;
|
|
136
|
+
* reach through regions of DIFFERING surfaces, or through none at all, is
|
|
137
|
+
* genuinely undecidable from outside, and this is where the game's author
|
|
138
|
+
* states the answer instead of the host guessing it from the file's bytes.
|
|
139
|
+
*
|
|
140
|
+
* READER: `packages/sdk/src/kit/ui-source/file-region-resolver.ts`'s
|
|
141
|
+
* `resolveFileRegion`, consulted BEFORE reach — a declared include wins,
|
|
142
|
+
* because it is a first-party statement about this exact file. Every OID
|
|
143
|
+
* stamping tier, the HMR classifier, the R3F authoring diagnostics and
|
|
144
|
+
* Content's component grouping read that one resolver.
|
|
145
|
+
*
|
|
146
|
+
* {@link regionsFromManifestRoots} emits none: the native default is PURE
|
|
147
|
+
* reach, and a first-party project that needs an include has a module its
|
|
148
|
+
* roots do not reach, which is a fact worth stating rather than absorbing.
|
|
149
|
+
*
|
|
150
|
+
* Glob vocabulary is deliberately the small one every reader already knows:
|
|
151
|
+
* `**` (any depth, including none), `*` (one segment, no `/`), `?` (one
|
|
152
|
+
* character). No brace expansion, no negation — a second syntax to learn is
|
|
153
|
+
* how a declaration slot turns back into a language.
|
|
154
|
+
*/
|
|
155
|
+
readonly include?: readonly string[];
|
|
156
|
+
/**
|
|
157
|
+
* Surfaces this root mounts BESIDE {@link AdapterRegion.surface}, each with
|
|
158
|
+
* the source files that render on it. Together with `surface` — the manifest
|
|
159
|
+
* root's own adapter, which is always the PRIMARY — this states the root's
|
|
160
|
+
* FULL mounted surface set (decision, 2026-08-16; ARCHITECTURE-CORE §The
|
|
161
|
+
* editor protocol).
|
|
162
|
+
*
|
|
163
|
+
* A root is not always one surface. The shipped case is an R3F game whose
|
|
164
|
+
* `three` root mounts a canvas AND adopts a DOM HUD beside it
|
|
165
|
+
* (`adoptGameDomRoot` already adopts the pair together): its HUD modules
|
|
166
|
+
* render react-dom host elements, and `userData-oid` on a `<div>` is a prop
|
|
167
|
+
* react-dom does not recognize, so the editor must know they are the `dom`
|
|
168
|
+
* half. Before this slot there was nowhere to say so — `id` names the
|
|
169
|
+
* manifest root this region grades, so a second `dom` region for such a root
|
|
170
|
+
* would either name a root the host would try to MOUNT (fabrication, which
|
|
171
|
+
* the anti-shim rule forbids) or name no root at all.
|
|
172
|
+
*
|
|
173
|
+
* Each mount's `include` is REQUIRED and carries the same glob vocabulary as
|
|
174
|
+
* {@link AdapterRegion.include} (`**`, `*`, `?`). A mount with no files would
|
|
175
|
+
* declare a surface without saying which source renders on it, which answers
|
|
176
|
+
* no question any reader asks.
|
|
177
|
+
*
|
|
178
|
+
* READER: the same one — `resolveFileRegion`'s rung 1. A file claimed by a
|
|
179
|
+
* region's own `include` AND by a mount is a contradiction in the game's own
|
|
180
|
+
* declaration and settles nothing, exactly as two regions claiming one file
|
|
181
|
+
* do; it falls through to reach, and failing that to the caller's loud
|
|
182
|
+
* ambiguous path.
|
|
183
|
+
*
|
|
184
|
+
* {@link regionsFromManifestRoots} emits none: the manifest states one
|
|
185
|
+
* adapter per root and NOTHING infers a second surface from source text.
|
|
186
|
+
* Where the declaration and what a file renders disagree, the disagreement is
|
|
187
|
+
* reported as drift (the editor's `OID003`), never resolved by reading bytes.
|
|
188
|
+
*/
|
|
189
|
+
readonly mounts?: readonly AdapterRegionMount[];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* One ADDITIONAL surface a root mounts, and the source files that render on it.
|
|
194
|
+
* See {@link AdapterRegion.mounts}.
|
|
195
|
+
*/
|
|
196
|
+
export interface AdapterRegionMount {
|
|
197
|
+
/** The surface this part of the root mounts on. */
|
|
198
|
+
readonly surface: AdapterSurface;
|
|
199
|
+
/** Project-relative globs (`**`, `*`, `?`) naming the files that render here. */
|
|
200
|
+
readonly include: readonly string[];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const AdapterRegionMountSchema = z
|
|
204
|
+
.object({
|
|
205
|
+
surface: z
|
|
206
|
+
.enum(['three', 'canvas', 'dom'])
|
|
207
|
+
.describe('An additional surface this root mounts, beside the manifest root’s own adapter'),
|
|
208
|
+
include: z
|
|
209
|
+
.array(z.string().min(1))
|
|
210
|
+
.min(1)
|
|
211
|
+
.describe(
|
|
212
|
+
'Project-relative globs (`**`, `*`, `?`) naming the source files that render on this ' +
|
|
213
|
+
'additional surface',
|
|
214
|
+
),
|
|
215
|
+
})
|
|
216
|
+
.strict();
|
|
217
|
+
|
|
218
|
+
const AdapterRegionBasisSchema = z
|
|
219
|
+
.object({
|
|
220
|
+
up: z.enum(['y', 'z']).describe('World axis pointing away from the ground'),
|
|
221
|
+
groundHeight: z.number().describe('Ground offset along `up`, in world units'),
|
|
222
|
+
})
|
|
223
|
+
.strict();
|
|
224
|
+
|
|
225
|
+
const AdapterRegionSchema = z
|
|
226
|
+
.object({
|
|
227
|
+
id: z.string().min(1).describe('Manifest root id this region grades'),
|
|
228
|
+
surface: z.enum(['three', 'canvas', 'dom']).describe('Render surface the host hands this root'),
|
|
229
|
+
substrate: z.enum(SUBSTRATE_NAMES).describe('Native substrate serving this region, by name'),
|
|
230
|
+
dialect: z
|
|
231
|
+
.string()
|
|
232
|
+
.min(1)
|
|
233
|
+
.nullable()
|
|
234
|
+
.describe(
|
|
235
|
+
'Dialect writer serving this region, by name; null states outright that the host writes ' +
|
|
236
|
+
"no source here (the region's truth is the game's own code)",
|
|
237
|
+
),
|
|
238
|
+
anchors: z
|
|
239
|
+
.array(z.enum(WRITE_ANCHOR_KINDS as [WriteAnchorKind, ...WriteAnchorKind[]]))
|
|
240
|
+
.describe('Anchor kinds this region’s writer serves'),
|
|
241
|
+
basis: AdapterRegionBasisSchema.optional().describe(
|
|
242
|
+
'World basis (up axis + ground height); omit for the native +Y / 0 default',
|
|
243
|
+
),
|
|
244
|
+
include: z
|
|
245
|
+
.array(z.string().min(1))
|
|
246
|
+
.min(1)
|
|
247
|
+
.optional()
|
|
248
|
+
.describe(
|
|
249
|
+
'Project-relative globs (`**`, `*`, `?`) naming source files this region owns, for files ' +
|
|
250
|
+
'no root entry’s import closure reaches; a declared include wins over reach',
|
|
251
|
+
),
|
|
252
|
+
mounts: z
|
|
253
|
+
.array(AdapterRegionMountSchema)
|
|
254
|
+
.min(1)
|
|
255
|
+
.optional()
|
|
256
|
+
.describe(
|
|
257
|
+
'Surfaces this root mounts BESIDE its own adapter, each with the files that render on ' +
|
|
258
|
+
'them; with `surface`, the root’s full mounted surface set',
|
|
259
|
+
),
|
|
260
|
+
})
|
|
261
|
+
.strict();
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* How a definition supplies its regions: the RULE `'manifest-roots'` (the
|
|
265
|
+
* native default — a declaration may be a rule rather than a list, the way
|
|
266
|
+
* `roots[]` never enumerates a world's contents), or an explicit list a game
|
|
267
|
+
* with novel bindings states outright.
|
|
268
|
+
*/
|
|
269
|
+
export type RegionBinding = 'manifest-roots' | readonly AdapterRegion[];
|
|
270
|
+
|
|
271
|
+
const RegionBindingSchema = z.union([
|
|
272
|
+
z.literal('manifest-roots'),
|
|
273
|
+
z.array(AdapterRegionSchema).min(1),
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The per-region PARAMETERS the `'manifest-roots'` rule takes — see
|
|
278
|
+
* {@link AdapterDefinition.regionIncludes}. Exactly the two fields of an
|
|
279
|
+
* {@link AdapterRegion} the mechanical derivation cannot produce, because
|
|
280
|
+
* neither is a fact the manifest states: which files a region owns beyond
|
|
281
|
+
* its entry's reach, and which surfaces its root mounts beside its own.
|
|
282
|
+
*/
|
|
283
|
+
export interface AdapterRegionOverlay {
|
|
284
|
+
/** See {@link AdapterRegion.include}. */
|
|
285
|
+
readonly include?: readonly string[];
|
|
286
|
+
/** See {@link AdapterRegion.mounts}. */
|
|
287
|
+
readonly mounts?: readonly AdapterRegionMount[];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const AdapterRegionOverlaySchema = z
|
|
291
|
+
.object({
|
|
292
|
+
include: z
|
|
293
|
+
.array(z.string().min(1))
|
|
294
|
+
.min(1)
|
|
295
|
+
.optional()
|
|
296
|
+
.describe(
|
|
297
|
+
'Project-relative globs (`**`, `*`, `?`) naming source files this derived region owns, ' +
|
|
298
|
+
'for files no root entry’s import closure reaches; a declared include wins over reach',
|
|
299
|
+
),
|
|
300
|
+
mounts: z
|
|
301
|
+
.array(AdapterRegionMountSchema)
|
|
302
|
+
.min(1)
|
|
303
|
+
.optional()
|
|
304
|
+
.describe(
|
|
305
|
+
'Surfaces this root mounts BESIDE its own adapter, each with the files that render on ' +
|
|
306
|
+
'them; with the derived region’s own surface, the root’s full mounted surface set',
|
|
307
|
+
),
|
|
308
|
+
})
|
|
309
|
+
.strict()
|
|
310
|
+
.refine((value) => value.include !== undefined || value.mounts !== undefined, {
|
|
311
|
+
message:
|
|
312
|
+
'regionIncludes entry declares neither `include` nor `mounts` — an empty overlay states ' +
|
|
313
|
+
'nothing the derivation did not already say. Remove it, or name the files it owns.',
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* PARAMETERS FOR THE RULE, keyed by manifest root id (decision, 2026-08-17;
|
|
318
|
+
* ARCHITECTURE-CORE §The editor protocol, "Declared includes LAYER onto the
|
|
319
|
+
* manifest-roots rule").
|
|
320
|
+
*
|
|
321
|
+
* The finder-selection shape, one level up: `regions: 'manifest-roots'` SELECTS
|
|
322
|
+
* the mechanical derivation, and this table DECLARES the per-region parameters
|
|
323
|
+
* it cannot derive. The two compose — {@link regionsFromManifestRoots} merges
|
|
324
|
+
* them — so a first-party project states one include without hand-writing its
|
|
325
|
+
* whole region table, and adding a root still grows the table for free.
|
|
326
|
+
*
|
|
327
|
+
* Illegal beside an explicit `regions` LIST, and rejected by name: that form is
|
|
328
|
+
* the full REPLACEMENT, and its entries carry `include`/`mounts` themselves.
|
|
329
|
+
* Two homes for one fact is how a declaration goes stale in one of them.
|
|
330
|
+
*
|
|
331
|
+
* MEASURED BASIS: a replace-only region binding means no `nativeAdapter()`
|
|
332
|
+
* project can declare a single include without restating every region.
|
|
333
|
+
*/
|
|
334
|
+
const RegionIncludesSchema = z
|
|
335
|
+
.record(z.string().min(1), AdapterRegionOverlaySchema)
|
|
336
|
+
.describe(
|
|
337
|
+
'Per-region parameters for the `manifest-roots` rule, keyed by manifest root id: the ' +
|
|
338
|
+
'`include` globs and `mounts` surfaces the mechanical derivation cannot produce',
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The MECHANICAL derivation behind the `'manifest-roots'` rule: one region per
|
|
343
|
+
* declared root, its surface and adapter identity read straight off the
|
|
344
|
+
* RESOLVED manifest (`@volter/project/manifest/load` already decided both — this never
|
|
345
|
+
* re-derives them).
|
|
346
|
+
*
|
|
347
|
+
* The per-surface library table below is an ENGINE fact, stated once:
|
|
348
|
+
*
|
|
349
|
+
* | root `adapter` | projector | dialect | anchors |
|
|
350
|
+
* |-----------------------|-----------|---------------|------------------------------------------------------|
|
|
351
|
+
* | `three` | `three` | `r3f` | source-prop, source-structure, construction-literal |
|
|
352
|
+
* | `canvas` | `pixi` | `pixi-react` | source-prop, source-structure, construction-literal |
|
|
353
|
+
* | `dom` | `dom` | `jsx` | source-prop, source-structure |
|
|
354
|
+
* | `{module,surface}` | (surface) | `null` | live-only |
|
|
355
|
+
* | `{ingest,surface}` | (surface) | `null` | live-only |
|
|
356
|
+
*
|
|
357
|
+
* `source-structure` rides with every first-party dialect because all three
|
|
358
|
+
* write through the SAME structural door (`/__ui-source/struct`) — a region
|
|
359
|
+
* that can rewrite a JSX attribute in a file can rewrite that file's element
|
|
360
|
+
* tree. It is listed separately rather than folded into `source-prop` because
|
|
361
|
+
* they are different doors: a lane could lose one and keep the other, and the
|
|
362
|
+
* matrix has to be able to say so.
|
|
363
|
+
*
|
|
364
|
+
* A module/ingest root's truth is the game's own source, which no first-party
|
|
365
|
+
* dialect writer authors — so it declares `live-only` rather than implying a
|
|
366
|
+
* write path it does not have. That is a red cell and therefore a work order,
|
|
367
|
+
* which is the point of stating it.
|
|
368
|
+
*
|
|
369
|
+
* Every derived region carries {@link NATIVE_REGION_BASIS} outright, so the
|
|
370
|
+
* editor's ground plane READS a declaration on the native path too — the
|
|
371
|
+
* default is published, never assumed at the read site.
|
|
372
|
+
*
|
|
373
|
+
* It DERIVES no {@link AdapterRegion.include} and no {@link AdapterRegion.mounts},
|
|
374
|
+
* and there is no derivation that could: the manifest states ONE adapter per
|
|
375
|
+
* root and names only its entry, so a root's extra owned files and its second
|
|
376
|
+
* mounted surface are facts only the game can state. It states them as
|
|
377
|
+
* `regionIncludes` PARAMETERS, which this function merges onto the region it
|
|
378
|
+
* derives for that root — the rule stays selected, the parameters are declared
|
|
379
|
+
* (ARCHITECTURE-CORE §The editor protocol, "Declared includes LAYER onto the
|
|
380
|
+
* manifest-roots rule"). A key naming no declared root merges onto nothing;
|
|
381
|
+
* the LOADER reports that, because only it can see both sides.
|
|
382
|
+
*/
|
|
383
|
+
export function regionsFromManifestRoots(
|
|
384
|
+
roots: readonly ResolvedAdapterRoot[],
|
|
385
|
+
regionIncludes: Readonly<Record<string, AdapterRegionOverlay>> = {},
|
|
386
|
+
): AdapterRegion[] {
|
|
387
|
+
return roots.map((root) => ({
|
|
388
|
+
...derivedRegion(root),
|
|
389
|
+
...overlayOf(regionIncludes[root.id]),
|
|
390
|
+
}));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** The declared half of a merged region — omitted keys leave the derived
|
|
394
|
+
* region untouched, so an overlay can only ADD what the rule cannot derive. */
|
|
395
|
+
function overlayOf(overlay: AdapterRegionOverlay | undefined): Partial<AdapterRegion> {
|
|
396
|
+
if (!overlay) return {};
|
|
397
|
+
return {
|
|
398
|
+
...(overlay.include ? { include: overlay.include } : {}),
|
|
399
|
+
...(overlay.mounts ? { mounts: overlay.mounts } : {}),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Region ids an overlay names that no declared root answers to. A typo here
|
|
404
|
+
* would otherwise be a declaration that silently does nothing, which is the
|
|
405
|
+
* failure class the whole declaration lane exists to end. */
|
|
406
|
+
export function unmatchedRegionIncludeIds(
|
|
407
|
+
roots: readonly ResolvedAdapterRoot[],
|
|
408
|
+
regionIncludes: Readonly<Record<string, AdapterRegionOverlay>>,
|
|
409
|
+
): string[] {
|
|
410
|
+
const declared = new Set(roots.map((root) => root.id));
|
|
411
|
+
return Object.keys(regionIncludes).filter((id) => !declared.has(id));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** One root's region, before any declared parameters are merged onto it. */
|
|
415
|
+
function derivedRegion(root: ResolvedAdapterRoot): AdapterRegion {
|
|
416
|
+
const surface = root.surface;
|
|
417
|
+
const firstParty = root.adapter.type === 'builtin';
|
|
418
|
+
const substrate: SubstrateName = surface === 'canvas' ? 'pixi' : surface;
|
|
419
|
+
const basis = NATIVE_REGION_BASIS;
|
|
420
|
+
if (!firstParty) {
|
|
421
|
+
return {
|
|
422
|
+
id: root.id,
|
|
423
|
+
surface,
|
|
424
|
+
substrate,
|
|
425
|
+
dialect: null,
|
|
426
|
+
anchors: ['live-only'] as const,
|
|
427
|
+
basis,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
if (surface === 'dom') {
|
|
431
|
+
return {
|
|
432
|
+
id: root.id,
|
|
433
|
+
surface,
|
|
434
|
+
substrate,
|
|
435
|
+
dialect: 'jsx',
|
|
436
|
+
anchors: ['source-prop', 'source-structure'] as const,
|
|
437
|
+
basis,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
return {
|
|
441
|
+
id: root.id,
|
|
442
|
+
surface,
|
|
443
|
+
substrate,
|
|
444
|
+
dialect: surface === 'three' ? 'r3f' : 'pixi-react',
|
|
445
|
+
anchors: ['source-prop', 'source-structure', 'construction-literal'] as const,
|
|
446
|
+
basis,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
// Finder selections
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The rule form of a finder's region targeting: every region the host mounts
|
|
456
|
+
* from an exported composition — a root with an `entry`.
|
|
457
|
+
*/
|
|
458
|
+
export const EXPORTED_COMPOSITION_REGIONS = 'exported-composition-regions';
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* `scenesFromEntrypointSelection` — read the entrypoint's ACTIVE-SCENE
|
|
462
|
+
* SELECTION, the load-bearing reference (the game itself executes it, so it
|
|
463
|
+
* cannot drift: the game would break first).
|
|
464
|
+
*/
|
|
465
|
+
export interface ScenesFromEntrypointSelectionParams {
|
|
466
|
+
readonly finder: 'scenesFromEntrypointSelection';
|
|
467
|
+
/**
|
|
468
|
+
* Which regions' entrypoints to read: named region ids, or the rule
|
|
469
|
+
* {@link EXPORTED_COMPOSITION_REGIONS}.
|
|
470
|
+
*/
|
|
471
|
+
readonly regions: readonly string[] | typeof EXPORTED_COMPOSITION_REGIONS;
|
|
472
|
+
/**
|
|
473
|
+
* Identifier of the entrypoint's module-level selection table — the
|
|
474
|
+
* `const <selection> = { <sceneId>: <Component>, … }` binding the entrypoint
|
|
475
|
+
* renders at its swap slot.
|
|
476
|
+
*
|
|
477
|
+
* OMIT for an entrypoint that mounts ONE composition: the finder then answers
|
|
478
|
+
* with the single-scene degenerate table (the root's own composition, opened
|
|
479
|
+
* by mounting the region). Only legal alongside exactly one named region —
|
|
480
|
+
* a selection identifier is a fact about ONE entrypoint's source, so pairing
|
|
481
|
+
* it with the rule form is rejected by name rather than applied to whichever
|
|
482
|
+
* region happened to match.
|
|
483
|
+
*/
|
|
484
|
+
readonly selection?: string;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* `prefabsFromStories` — read the colocated portable-CSF story registrations.
|
|
489
|
+
* A prefab is an ordinary source component a designer independently places,
|
|
490
|
+
* declared by a story whose `meta.component` names it; the story registration
|
|
491
|
+
* is the load-bearing reference here (removing it removes the declaration).
|
|
492
|
+
* Zero parameters: the selection IS the whole configuration.
|
|
493
|
+
*/
|
|
494
|
+
export interface PrefabsFromStoriesParams {
|
|
495
|
+
readonly finder: 'prefabsFromStories';
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* A finder selection: the finder's registered NAME plus its own parameters.
|
|
500
|
+
* The set of finders is OPEN (ARCHITECTURE-CORE §The editor protocol,
|
|
501
|
+
* "Documents, not scenes"): the engine's own register in
|
|
502
|
+
* `adapter/finders/registry.ts`, a contribution registers more, and this
|
|
503
|
+
* module validates only the envelope — each finder's parameters are checked
|
|
504
|
+
* against ITS schema the moment the loader runs it, loudly and by name.
|
|
505
|
+
*/
|
|
506
|
+
export type FinderSelection = {
|
|
507
|
+
readonly finder: string;
|
|
508
|
+
readonly [param: string]: unknown;
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const FinderSelectionSchema = z
|
|
512
|
+
.object({ finder: z.string().min(1).describe('Registered finder name') })
|
|
513
|
+
.passthrough();
|
|
514
|
+
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
// The scene table
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* How the RUNNING game is navigated to an entry — the `reachable` honesty
|
|
521
|
+
* clause, stated rather than assumed. An entry whose reach is `none` is a
|
|
522
|
+
* declared-but-unreachable scene: a red cell carrying its own reason.
|
|
523
|
+
*/
|
|
524
|
+
export type SceneReach =
|
|
525
|
+
/** Mounting the region IS opening it — the single-composition degenerate case. */
|
|
526
|
+
| { readonly kind: 'root-mount' }
|
|
527
|
+
/**
|
|
528
|
+
* The entrypoint's own selection table reaches it under `key`.
|
|
529
|
+
*
|
|
530
|
+
* `active` is the SECOND load-bearing fact about the same slot: the
|
|
531
|
+
* entrypoint indexes its table with one key right now (`scenes[activeScene]`),
|
|
532
|
+
* and that key's composition is literally what mounting the region renders.
|
|
533
|
+
* So an active entry is the region's own standing document — it NAMES that
|
|
534
|
+
* document rather than earning a second one beside it, exactly as
|
|
535
|
+
* `root-mount` does. Absent means "not known to be the one at the slot",
|
|
536
|
+
* which is the honest answer whenever the key cannot be read statically; it
|
|
537
|
+
* is never a guess, and it is per-entry because two regions each have their
|
|
538
|
+
* own slot with its own occupant.
|
|
539
|
+
*/
|
|
540
|
+
| {
|
|
541
|
+
readonly kind: 'entrypoint-selection';
|
|
542
|
+
readonly selection: string;
|
|
543
|
+
readonly key: string;
|
|
544
|
+
readonly active?: boolean;
|
|
545
|
+
}
|
|
546
|
+
/** A portable story mounts it in isolation. */
|
|
547
|
+
| { readonly kind: 'story'; readonly storyId: string }
|
|
548
|
+
/**
|
|
549
|
+
* The RUNNING game's own published scenes contract reaches it, under
|
|
550
|
+
* `sceneId` (`adapter/ingest/game-contract.ts`'s `scenes.list()` ids).
|
|
551
|
+
*
|
|
552
|
+
* This is the LIVE reach: opening it while playing means asking the game to
|
|
553
|
+
* navigate, the way its own buttons do. An authorable entry that also names
|
|
554
|
+
* `source` can independently own an Edit isolation document; reach never
|
|
555
|
+
* decides whether a source composition can be mounted as a piece.
|
|
556
|
+
*
|
|
557
|
+
* A DECLARATION, never an inference: the host does not sniff a mapping
|
|
558
|
+
* between a table entry and a contract id, and it does not assume the two
|
|
559
|
+
* vocabularies coincide. The adapter states which contract id this entry is,
|
|
560
|
+
* and the host validates that id against the game's OWN `list()` at the
|
|
561
|
+
* moment it navigates — so a game that stops publishing the scene refuses by
|
|
562
|
+
* name instead of navigating somewhere else.
|
|
563
|
+
*/
|
|
564
|
+
| { readonly kind: 'game-contract'; readonly sceneId: string }
|
|
565
|
+
/** Declared, with no code path that opens it. */
|
|
566
|
+
| { readonly kind: 'none'; readonly reason: string };
|
|
567
|
+
|
|
568
|
+
const SceneReachSchema = z.union([
|
|
569
|
+
z.object({ kind: z.literal('root-mount') }).strict(),
|
|
570
|
+
z
|
|
571
|
+
.object({
|
|
572
|
+
kind: z.literal('entrypoint-selection'),
|
|
573
|
+
selection: z.string().min(1),
|
|
574
|
+
key: z.string().min(1),
|
|
575
|
+
active: z.boolean().optional(),
|
|
576
|
+
})
|
|
577
|
+
.strict(),
|
|
578
|
+
z.object({ kind: z.literal('story'), storyId: z.string().min(1) }).strict(),
|
|
579
|
+
z.object({ kind: z.literal('game-contract'), sceneId: z.string().min(1) }).strict(),
|
|
580
|
+
z.object({ kind: z.literal('none'), reason: z.string().min(1) }).strict(),
|
|
581
|
+
]);
|
|
582
|
+
|
|
583
|
+
/** Where an entry's composition lives in the game's own source. */
|
|
584
|
+
export interface SceneSource {
|
|
585
|
+
/** Project-relative path. */
|
|
586
|
+
readonly path: string;
|
|
587
|
+
/** Named export; omit when the module default-exports it. */
|
|
588
|
+
readonly export?: string;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const SceneSourceSchema = z
|
|
592
|
+
.object({ path: z.string().min(1), export: z.string().min(1).optional() })
|
|
593
|
+
.strict();
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* One entry of the scene table. Scenes and prefabs are SIBLINGS here — both
|
|
597
|
+
* are registered compositions the editor opens in isolation through the same
|
|
598
|
+
* verb, differing only in instance site (ARCHITECTURE-CORE §Roots: "A scene is
|
|
599
|
+
* a ROLE, not a kind … the same kind of thing as a prefab").
|
|
600
|
+
*/
|
|
601
|
+
export interface DocumentEntry {
|
|
602
|
+
readonly id: string;
|
|
603
|
+
readonly label: string;
|
|
604
|
+
/** `scene` (swaps at the entrypoint's slot) or `prefab` (placed by a designer). */
|
|
605
|
+
/** OPEN: `scene` and `prefab` are the first two kinds; a page, a component,
|
|
606
|
+
* a model, a bake are kinds the same way. */
|
|
607
|
+
readonly kind: string;
|
|
608
|
+
/** Owning region id; `null` for a project-scoped entry bound to no one root. */
|
|
609
|
+
readonly region: string | null;
|
|
610
|
+
/**
|
|
611
|
+
* `true` = a composition a designer authors (it earns a matrix column).
|
|
612
|
+
* `false` = TRAVERSAL: boot/loading choreography, which gets no column.
|
|
613
|
+
*/
|
|
614
|
+
readonly authorable: boolean;
|
|
615
|
+
readonly reach: SceneReach;
|
|
616
|
+
readonly source?: SceneSource;
|
|
617
|
+
/** Optional adapter-declared prerequisite run before `source` is constructed
|
|
618
|
+
* in Edit isolation. The export is a zero-argument function; the host never
|
|
619
|
+
* infers setup from neighboring filenames or game globals. */
|
|
620
|
+
readonly isolationSetup?: SceneSource;
|
|
621
|
+
/** Which finder produced it; absent = the adapter stated it outright. */
|
|
622
|
+
readonly finder?: string;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const DocumentEntrySchema = z
|
|
626
|
+
.object({
|
|
627
|
+
id: z.string().min(1),
|
|
628
|
+
label: z.string().min(1),
|
|
629
|
+
kind: z.string().min(1),
|
|
630
|
+
region: z.string().min(1).nullable(),
|
|
631
|
+
authorable: z.boolean(),
|
|
632
|
+
reach: SceneReachSchema,
|
|
633
|
+
source: SceneSourceSchema.optional(),
|
|
634
|
+
isolationSetup: SceneSourceSchema.optional(),
|
|
635
|
+
finder: z.string().min(1).optional(),
|
|
636
|
+
})
|
|
637
|
+
.strict();
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* The adapter's scene table: entries stated outright, finder selections the
|
|
641
|
+
* host runs to produce the rest, and the entry open by default.
|
|
642
|
+
*/
|
|
643
|
+
export interface AdapterDocumentTable {
|
|
644
|
+
readonly entries?: readonly DocumentEntry[];
|
|
645
|
+
readonly find?: readonly FinderSelection[];
|
|
646
|
+
/**
|
|
647
|
+
* Id of the entry open by default. Omit to let the finder's own answer
|
|
648
|
+
* stand — the host never picks one on its own, so "no default" stays a fact
|
|
649
|
+
* rather than becoming whichever entry sorted first.
|
|
650
|
+
*/
|
|
651
|
+
readonly default?: string;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const AdapterDocumentTableSchema = z
|
|
655
|
+
.object({
|
|
656
|
+
entries: z.array(DocumentEntrySchema).optional(),
|
|
657
|
+
find: z.array(FinderSelectionSchema).optional(),
|
|
658
|
+
default: z.string().min(1).optional(),
|
|
659
|
+
})
|
|
660
|
+
.strict();
|
|
661
|
+
|
|
662
|
+
// ---------------------------------------------------------------------------
|
|
663
|
+
// Observation
|
|
664
|
+
// ---------------------------------------------------------------------------
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* The two faces a declaration projects onto. They are exactly the two
|
|
668
|
+
* `DebugAdapter` halves (`adapter/system-adapter.ts`): `state` becomes a
|
|
669
|
+
* provider (`providers()` / `state(name)`), `command` becomes a verb
|
|
670
|
+
* (`commands()` / `invoke(name, args)`). There is deliberately no third kind —
|
|
671
|
+
* see {@link OBSERVATION_KIND_REFUSAL}.
|
|
672
|
+
*/
|
|
673
|
+
export const OBSERVATION_KINDS = ['command', 'state'] as const;
|
|
674
|
+
export type ObservationKind = (typeof OBSERVATION_KINDS)[number];
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Why a declaration may not name a `SystemAdapters` slot here.
|
|
678
|
+
*
|
|
679
|
+
* A game's system slots are already declared through the game contract's own
|
|
680
|
+
* `systemAdapters` carrier (`adapter/ingest/game-contract.ts`), which validates
|
|
681
|
+
* each slot per-surface and reports a malformed one BY NAME
|
|
682
|
+
* (`contract-system-adapters.ts`). A second door onto the same slots is exactly
|
|
683
|
+
* the two-declarations-one-slot problem `game-contract.ts` records for `debug`,
|
|
684
|
+
* where it says outright that a second door "would let a game declare two
|
|
685
|
+
* different debug planes with no rule for which wins".
|
|
686
|
+
*/
|
|
687
|
+
const OBSERVATION_KIND_REFUSAL =
|
|
688
|
+
"observation.kind must be 'command' or 'state' — the two DebugAdapter faces a declaration " +
|
|
689
|
+
'projects onto. A SystemAdapters slot is declared through the game contract’s `systemAdapters` ' +
|
|
690
|
+
'carrier, which validates it per-surface; declaring one here would be a second door onto the ' +
|
|
691
|
+
'same slot with no rule for which wins.';
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* One statically declared observation slot. Declaring a slot and ANSWERING it
|
|
695
|
+
* live are separate facts (the two-state rule): a slot declared here whose
|
|
696
|
+
* `answer` throws, whose `answer` is absent, or whose game never booted reads as
|
|
697
|
+
* broken, never as "the game has none". The projector
|
|
698
|
+
* (`adapter/ingest/observation-debug-adapter.ts`) keeps that distinction: an
|
|
699
|
+
* unanswerable slot is still LISTED, and refuses by name when read.
|
|
700
|
+
*/
|
|
701
|
+
export interface ObservationDeclaration {
|
|
702
|
+
readonly id: string;
|
|
703
|
+
readonly kind: ObservationKind;
|
|
704
|
+
readonly description?: string | undefined;
|
|
705
|
+
/** Command execution side; omitted means a local app-owned command. */
|
|
706
|
+
readonly locus?: 'client' | 'server' | undefined;
|
|
707
|
+
/**
|
|
708
|
+
* Lazily evaluated against the mounted game. The field may be a closure; the
|
|
709
|
+
* TABLE around it stays statically readable, which is the whole discipline.
|
|
710
|
+
*
|
|
711
|
+
* What `game` IS is the host's to hand over and is stated at the projector:
|
|
712
|
+
* for an ingest mount it is the REALM the game's own modules ran in, because
|
|
713
|
+
* a foreign game's public handles are the only thing the host can honestly
|
|
714
|
+
* pass (`observation-debug-adapter.ts`).
|
|
715
|
+
*/
|
|
716
|
+
readonly answer?: (game: unknown, ...args: unknown[]) => unknown;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const ObservationDeclarationSchema = z
|
|
720
|
+
.object({
|
|
721
|
+
id: z.string().min(1),
|
|
722
|
+
kind: z.enum(OBSERVATION_KINDS, { error: () => OBSERVATION_KIND_REFUSAL }),
|
|
723
|
+
description: z.string().min(1).optional(),
|
|
724
|
+
locus: z.enum(['client', 'server']).optional(),
|
|
725
|
+
answer: z
|
|
726
|
+
.custom<(game: unknown) => unknown>((value) => typeof value === 'function', {
|
|
727
|
+
message: 'observation.answer must be a function evaluated against the mounted game',
|
|
728
|
+
})
|
|
729
|
+
.optional(),
|
|
730
|
+
})
|
|
731
|
+
.strict()
|
|
732
|
+
.refine((value) => value.kind === 'command' || value.locus === undefined, {
|
|
733
|
+
path: ['locus'],
|
|
734
|
+
message: 'observation.locus is valid only for a command declaration',
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
// ---------------------------------------------------------------------------
|
|
738
|
+
// Native input
|
|
739
|
+
// ---------------------------------------------------------------------------
|
|
740
|
+
|
|
741
|
+
/** The action value shapes the session input door already speaks. */
|
|
742
|
+
export const ADAPTER_INPUT_VALUE_TYPES = [
|
|
743
|
+
'digital',
|
|
744
|
+
'scalar',
|
|
745
|
+
'vector2',
|
|
746
|
+
'pointerDelta',
|
|
747
|
+
'pointerPosition',
|
|
748
|
+
] as const;
|
|
749
|
+
export type AdapterInputValueType = (typeof ADAPTER_INPUT_VALUE_TYPES)[number];
|
|
750
|
+
export type AdapterInputValue = boolean | number | { readonly x: number; readonly y: number };
|
|
751
|
+
|
|
752
|
+
/** One action in an app-owned input store. */
|
|
753
|
+
export interface AdapterInputAction {
|
|
754
|
+
readonly name: string;
|
|
755
|
+
readonly valueType: AdapterInputValueType;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Adapter-side binding from the universal session input door to a game's
|
|
760
|
+
* ordinary input store. The game owns both its physical-device listeners and
|
|
761
|
+
* its state; this table only tells the host how to drive that same state for a
|
|
762
|
+
* bot or live REPL session. No component imports this contract.
|
|
763
|
+
*/
|
|
764
|
+
export interface AdapterInputBinding {
|
|
765
|
+
/** Manifest root whose native scheduler consumes these actions. */
|
|
766
|
+
readonly root: string;
|
|
767
|
+
/** Current action vocabulary. A closure permits an app-owned dynamic map. */
|
|
768
|
+
readonly actions: (game: unknown) => readonly AdapterInputAction[];
|
|
769
|
+
/** Apply one action through the app's own input-store write path. */
|
|
770
|
+
readonly set: (game: unknown, action: string, value: AdapterInputValue) => void;
|
|
771
|
+
/** Release every app-owned virtual action. */
|
|
772
|
+
readonly clear: (game: unknown) => void;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const AdapterInputBindingSchema = z
|
|
776
|
+
.object({
|
|
777
|
+
root: z.string().min(1),
|
|
778
|
+
actions: z.custom<AdapterInputBinding['actions']>((value) => typeof value === 'function', {
|
|
779
|
+
message: 'input.actions must be a function returning the app-owned action vocabulary',
|
|
780
|
+
}),
|
|
781
|
+
set: z.custom<AdapterInputBinding['set']>((value) => typeof value === 'function', {
|
|
782
|
+
message: 'input.set must be a function writing the app-owned input store',
|
|
783
|
+
}),
|
|
784
|
+
clear: z.custom<AdapterInputBinding['clear']>((value) => typeof value === 'function', {
|
|
785
|
+
message: 'input.clear must be a function releasing app-owned virtual actions',
|
|
786
|
+
}),
|
|
787
|
+
})
|
|
788
|
+
.strict();
|
|
789
|
+
|
|
790
|
+
// ---------------------------------------------------------------------------
|
|
791
|
+
// The definition
|
|
792
|
+
// ---------------------------------------------------------------------------
|
|
793
|
+
|
|
794
|
+
/** Project-owned editor defaults. User layout choices remain session state. */
|
|
795
|
+
export interface AdapterEditorLayoutProps {
|
|
796
|
+
readonly playing: boolean;
|
|
797
|
+
readonly paused: boolean;
|
|
798
|
+
}
|
|
799
|
+
export interface AdapterEditorConfiguration {
|
|
800
|
+
/** Imported React implementation; owns both composition and edit/play presentation. */
|
|
801
|
+
readonly Layout?: ComponentType<AdapterEditorLayoutProps>;
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* The project's LOOK, imported from the package that carries it
|
|
805
|
+
* (`@volter/editor-blender`'s `blenderStyle`). Palette, material, icon set and chrome
|
|
806
|
+
* regions in one object — the same `StyleContribution` the package's
|
|
807
|
+
* `workspace.style` contribution registers, named here as the project's
|
|
808
|
+
* choice. Beneath a person's own settings and above their cross-project
|
|
809
|
+
* ones (`settings-store.ts`).
|
|
810
|
+
*/
|
|
811
|
+
readonly style?: StyleContribution;
|
|
812
|
+
/** The project's KEY BINDINGS, imported the same way (`blenderKeymap`). */
|
|
813
|
+
readonly keymap?: KeymapContribution;
|
|
814
|
+
|
|
815
|
+
/** Registered workspace to open when this checkout has no saved workspace. */
|
|
816
|
+
readonly workspace?: string;
|
|
817
|
+
/** Inspector layout beneath the active workspace and the user's override. */
|
|
818
|
+
readonly inspector?: 'column' | 'properties' | 'card';
|
|
819
|
+
/** Utility registry ids to reveal when Play settles; omitted means none. */
|
|
820
|
+
readonly playUtilities?: readonly string[];
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const AdapterEditorConfigurationSchema = z
|
|
824
|
+
.object({
|
|
825
|
+
Layout: z
|
|
826
|
+
.custom<ComponentType<AdapterEditorLayoutProps>>(
|
|
827
|
+
(value) => typeof value === 'function',
|
|
828
|
+
'Layout must be an imported React component',
|
|
829
|
+
)
|
|
830
|
+
.optional(),
|
|
831
|
+
style: z
|
|
832
|
+
.custom<StyleContribution>(
|
|
833
|
+
(value) =>
|
|
834
|
+
typeof value === 'object' &&
|
|
835
|
+
value !== null &&
|
|
836
|
+
typeof Reflect.get(value, 'id') === 'string',
|
|
837
|
+
'style must be an imported StyleContribution object',
|
|
838
|
+
)
|
|
839
|
+
.optional(),
|
|
840
|
+
keymap: z
|
|
841
|
+
.custom<KeymapContribution>(
|
|
842
|
+
(value) =>
|
|
843
|
+
typeof value === 'object' &&
|
|
844
|
+
value !== null &&
|
|
845
|
+
typeof Reflect.get(value, 'id') === 'string',
|
|
846
|
+
'keymap must be an imported KeymapContribution object',
|
|
847
|
+
)
|
|
848
|
+
.optional(),
|
|
849
|
+
workspace: z.string().min(1).optional(),
|
|
850
|
+
inspector: z.enum(['column', 'properties', 'card']).optional(),
|
|
851
|
+
playUtilities: z.array(z.string().min(1)).optional(),
|
|
852
|
+
})
|
|
853
|
+
.strict();
|
|
854
|
+
|
|
855
|
+
/** The adapter module's default export, after {@link defineAdapter}. */
|
|
856
|
+
export interface AdapterDefinition {
|
|
857
|
+
readonly editor?: AdapterEditorConfiguration;
|
|
858
|
+
readonly regions: RegionBinding;
|
|
859
|
+
/**
|
|
860
|
+
* Per-region parameters for the `'manifest-roots'` RULE, keyed by manifest
|
|
861
|
+
* root id — the `include` globs and `mounts` surfaces the mechanical
|
|
862
|
+
* derivation cannot produce. {@link regionsFromManifestRoots} merges them;
|
|
863
|
+
* an explicit `regions` LIST carries its own and rejects these by name.
|
|
864
|
+
*/
|
|
865
|
+
readonly regionIncludes: Readonly<Record<string, AdapterRegionOverlay>>;
|
|
866
|
+
readonly documents: AdapterDocumentTable;
|
|
867
|
+
readonly observation: readonly ObservationDeclaration[];
|
|
868
|
+
readonly input?: AdapterInputBinding | undefined;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** What a game writes. Every field optional — the near-empty adapter is the
|
|
872
|
+
* first-party case, and an adapter's SIZE measures the game's distance from
|
|
873
|
+
* native. */
|
|
874
|
+
export interface AdapterDefinitionInput {
|
|
875
|
+
readonly editor?: AdapterEditorConfiguration;
|
|
876
|
+
readonly regions?: RegionBinding;
|
|
877
|
+
readonly regionIncludes?: Readonly<Record<string, AdapterRegionOverlay>>;
|
|
878
|
+
readonly documents?: AdapterDocumentTable;
|
|
879
|
+
readonly observation?: readonly ObservationDeclaration[];
|
|
880
|
+
readonly input?: AdapterInputBinding | undefined;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Why the two forms may not be combined, said in the error a game's author
|
|
885
|
+
* reads. An explicit region list REPLACES the derivation and its entries carry
|
|
886
|
+
* `include`/`mounts` themselves; layering a second table over it would put one
|
|
887
|
+
* fact in two homes, with no rule for which wins.
|
|
888
|
+
*/
|
|
889
|
+
const REGION_INCLUDES_WITH_LIST_REFUSAL =
|
|
890
|
+
'`regionIncludes` states parameters for the `manifest-roots` RULE, so it cannot be paired ' +
|
|
891
|
+
'with an explicit `regions` list — that form is the full replacement, and each entry carries ' +
|
|
892
|
+
'its own `include`/`mounts`. Move these globs onto the matching `regions[]` entry, or drop the ' +
|
|
893
|
+
'list and let the derivation run.';
|
|
894
|
+
|
|
895
|
+
export const AdapterDefinitionSchema = z
|
|
896
|
+
.object({
|
|
897
|
+
editor: AdapterEditorConfigurationSchema.optional(),
|
|
898
|
+
regions: RegionBindingSchema.optional(),
|
|
899
|
+
regionIncludes: RegionIncludesSchema.optional(),
|
|
900
|
+
documents: AdapterDocumentTableSchema.optional(),
|
|
901
|
+
observation: z.array(ObservationDeclarationSchema).optional(),
|
|
902
|
+
input: AdapterInputBindingSchema.optional(),
|
|
903
|
+
})
|
|
904
|
+
.strict()
|
|
905
|
+
// An EMPTY table beside a list is fine and must stay fine: `defineAdapter`
|
|
906
|
+
// publishes `regionIncludes: {}` on every definition, and its output is
|
|
907
|
+
// parsed a second time by the host's loader — a refusal on the key's mere
|
|
908
|
+
// presence would reject the module's own valid output on the round trip.
|
|
909
|
+
.refine(
|
|
910
|
+
(value) =>
|
|
911
|
+
value.regionIncludes === undefined ||
|
|
912
|
+
Object.keys(value.regionIncludes).length === 0 ||
|
|
913
|
+
!Array.isArray(value.regions),
|
|
914
|
+
{ message: REGION_INCLUDES_WITH_LIST_REFUSAL },
|
|
915
|
+
);
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Validate and freeze a game's binding table.
|
|
919
|
+
*
|
|
920
|
+
* Called TWICE by design and with one schema: once here, in the game's own
|
|
921
|
+
* module, so a malformed table fails at the author's own file with the key
|
|
922
|
+
* named; and once in the host's loader, because the loaded default export is
|
|
923
|
+
* untrusted input crossing a module boundary. Two call sites, one rule — not
|
|
924
|
+
* two ways to say the same thing.
|
|
925
|
+
*/
|
|
926
|
+
export function defineAdapter(input: AdapterDefinitionInput = {}): AdapterDefinition {
|
|
927
|
+
const parsed = AdapterDefinitionSchema.safeParse(input);
|
|
928
|
+
if (!parsed.success) {
|
|
929
|
+
throw new Error(
|
|
930
|
+
`editor/volter.adapter.ts: invalid adapter definition — ${parsed.error.issues
|
|
931
|
+
.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
|
932
|
+
.join('; ')}`,
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
return Object.freeze({
|
|
936
|
+
...(input.editor
|
|
937
|
+
? {
|
|
938
|
+
editor: Object.freeze({
|
|
939
|
+
...input.editor,
|
|
940
|
+
...(input.editor.playUtilities
|
|
941
|
+
? { playUtilities: Object.freeze([...input.editor.playUtilities]) }
|
|
942
|
+
: {}),
|
|
943
|
+
}),
|
|
944
|
+
}
|
|
945
|
+
: {}),
|
|
946
|
+
regions: input.regions ?? 'manifest-roots',
|
|
947
|
+
regionIncludes: Object.freeze({ ...(input.regionIncludes ?? {}) }),
|
|
948
|
+
documents: Object.freeze({ ...(input.documents ?? {}) }),
|
|
949
|
+
observation: Object.freeze([...(input.observation ?? [])]),
|
|
950
|
+
...(input.input ? { input: input.input } : {}),
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** Extra bindings a near-native game adds without restating the native ones. */
|
|
955
|
+
export interface NativeAdapterOptions {
|
|
956
|
+
/** Scene-table entries this game states outright, beside what the finders find. */
|
|
957
|
+
readonly scenes?: readonly DocumentEntry[];
|
|
958
|
+
/** Per-region `include`/`mounts` parameters for the derivation — see
|
|
959
|
+
* {@link AdapterDefinition.regionIncludes}. */
|
|
960
|
+
readonly regionIncludes?: Readonly<Record<string, AdapterRegionOverlay>>;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* THE NATIVE DEFAULT — regions derived mechanically from the manifest's
|
|
965
|
+
* `roots[]`, and a scene table produced by the two shipped finders. This is
|
|
966
|
+
* what a first-party project ships (`export default nativeAdapter()`), and it
|
|
967
|
+
* is also what a project with NO `editor/volter.adapter.ts` gets: that absence is the
|
|
968
|
+
* declared native default, not a silent fallback.
|
|
969
|
+
*/
|
|
970
|
+
export function nativeAdapter(options: NativeAdapterOptions = {}): AdapterDefinition {
|
|
971
|
+
return defineAdapter({
|
|
972
|
+
regions: 'manifest-roots',
|
|
973
|
+
...(options.regionIncludes ? { regionIncludes: options.regionIncludes } : {}),
|
|
974
|
+
documents: {
|
|
975
|
+
...(options.scenes ? { entries: options.scenes } : {}),
|
|
976
|
+
find: [
|
|
977
|
+
{ finder: 'scenesFromEntrypointSelection', regions: EXPORTED_COMPOSITION_REGIONS },
|
|
978
|
+
{ finder: 'prefabsFromStories' },
|
|
979
|
+
],
|
|
980
|
+
},
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Parse an untrusted default export (the loaded `editor/volter.adapter.ts`) into a
|
|
986
|
+
* definition. Rejects loudly — an unknown key, a bad finder name, a
|
|
987
|
+
* `selection` paired with the rule form all fail by name.
|
|
988
|
+
*/
|
|
989
|
+
export function parseAdapterDefinition(value: unknown): AdapterDefinition {
|
|
990
|
+
if (value === null || typeof value !== 'object') {
|
|
991
|
+
throw new Error(
|
|
992
|
+
'editor/volter.adapter.ts must default-export defineAdapter({…}) / nativeAdapter() — ' +
|
|
993
|
+
`got ${value === null ? 'null' : typeof value}`,
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
return defineAdapter(value as AdapterDefinitionInput);
|
|
997
|
+
}
|