@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,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a project's CONFIGURATION-KIND CONTRIBUTIONS live and how a host
|
|
3
|
+
* takes them in (ARCHITECTURE-CORE §The project model). A module named
|
|
4
|
+
* `editor/contributions/<name>.kind.ts` (or `.tsx`) exports `kind`, a plain
|
|
5
|
+
* `ConfigurationKindContribution`; the HOST that loads the manifest — the
|
|
6
|
+
* editor server through Vite's SSR loader, a project's own validate script
|
|
7
|
+
* through tsx — imports each and registers it in its own registry through
|
|
8
|
+
* {@link registerContributedConfigurationKind}. Node-only: it walks the
|
|
9
|
+
* project folder.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { registerContributedConfigurationKind } from './configuration-kinds';
|
|
15
|
+
|
|
16
|
+
const KIND_SUFFIXES = ['.kind.ts', '.kind.tsx'] as const;
|
|
17
|
+
|
|
18
|
+
/** Whether one file name is a kind contribution by the convention. */
|
|
19
|
+
export function isConfigurationKindModule(fileName: string): boolean {
|
|
20
|
+
return KIND_SUFFIXES.some((suffix) => fileName.endsWith(suffix));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Absolute paths of the project's kind contributions, sorted. */
|
|
24
|
+
export function contributedKindModulePaths(projectDir: string): string[] {
|
|
25
|
+
const found: string[] = [];
|
|
26
|
+
const walk = (directory: string): void => {
|
|
27
|
+
let names: string[];
|
|
28
|
+
try {
|
|
29
|
+
names = readdirSync(directory);
|
|
30
|
+
} catch {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
for (const name of names) {
|
|
34
|
+
if (name.startsWith('.') || name === 'node_modules') continue;
|
|
35
|
+
const absolute = join(directory, name);
|
|
36
|
+
let isDirectory = false;
|
|
37
|
+
try {
|
|
38
|
+
isDirectory = statSync(absolute).isDirectory();
|
|
39
|
+
} catch {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (isDirectory) walk(absolute);
|
|
43
|
+
else if (isConfigurationKindModule(name)) found.push(absolute);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
walk(join(projectDir, 'editor', 'contributions'));
|
|
47
|
+
return found.sort();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Register the `kind` export of an already-imported module; the source
|
|
51
|
+
* path names the module in a refusal. */
|
|
52
|
+
export function registerKindModule(module: unknown, source: string): () => void {
|
|
53
|
+
const exported = (module as { kind?: unknown } | null)?.kind;
|
|
54
|
+
if (exported === undefined) {
|
|
55
|
+
throw new Error(`${source}: a kind contribution must export \`kind\``);
|
|
56
|
+
}
|
|
57
|
+
return registerContributedConfigurationKind(exported, source);
|
|
58
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// T3.1 slice 1 — Node-only path wrapper around `loadGameManifest` (§5).
|
|
2
|
+
//
|
|
3
|
+
// Kept in its own module (rather than folded into `load.ts`) so that
|
|
4
|
+
// `loadGameManifest` itself stays free of `node:fs` — anything that only
|
|
5
|
+
// needs the pure parse/resolve function (e.g. a future browser/hosted-editor
|
|
6
|
+
// caller) can import `./load` without pulling a Node-only module into its
|
|
7
|
+
// bundle.
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { type LoadGameManifestOptions, loadGameManifest, type ResolvedGameManifest } from './load';
|
|
11
|
+
import { resolveManifestPath } from './locate';
|
|
12
|
+
|
|
13
|
+
// The dual-name filesystem lookup lives in `./locate` (Zod-free on purpose);
|
|
14
|
+
// re-exported here so the common "load a project's manifest" import is one
|
|
15
|
+
// module.
|
|
16
|
+
export { hasManifest, manifestWritePath, resolveManifestPath } from './locate';
|
|
17
|
+
|
|
18
|
+
/** Read + JSON.parse `path`, then resolve it via `loadGameManifest`. Node-only. */
|
|
19
|
+
export function loadGameManifestFile(
|
|
20
|
+
path: string,
|
|
21
|
+
options: LoadGameManifestOptions = {},
|
|
22
|
+
): ResolvedGameManifest {
|
|
23
|
+
const raw: unknown = JSON.parse(readFileSync(path, 'utf-8'));
|
|
24
|
+
return loadGameManifest(raw, options);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** {@link loadGameManifestFile} against `dir`'s `volter.project.json`. */
|
|
28
|
+
export function loadGameManifestDir(
|
|
29
|
+
dir: string,
|
|
30
|
+
options: LoadGameManifestOptions = {},
|
|
31
|
+
): ResolvedGameManifest {
|
|
32
|
+
return loadGameManifestFile(resolveManifestPath(dir), options);
|
|
33
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// The game manifest loader/validator.
|
|
2
|
+
//
|
|
3
|
+
// Pure data-in/data-out: `loadGameManifest(raw)` parses `raw` against
|
|
4
|
+
// `GameManifestSchema`, runs the cross-field checks §3 promises beyond what
|
|
5
|
+
// Zod alone can express, resolves every optional/derivable field, and
|
|
6
|
+
// returns a `ResolvedGameManifest` with nothing left implicit. This is the
|
|
7
|
+
// schema's runtime reader (T4.1 policy) — every described field in
|
|
8
|
+
// `schema.ts` either drives a check here or is carried into the resolved
|
|
9
|
+
// output explicitly (see the per-field comments below and
|
|
10
|
+
// `packages/engine/test/schema-consumption-map.ts`'s `manifest.*` entries).
|
|
11
|
+
//
|
|
12
|
+
// No `fs` import here on purpose — `load-file.ts` is the thin Node-only
|
|
13
|
+
|
|
14
|
+
// wrapper that reads a path and calls this function, so browser bundles that
|
|
15
|
+
// only need `loadGameManifest` never pull in `node:fs`.
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
configurationIssues,
|
|
19
|
+
configurationKind,
|
|
20
|
+
type DeclaredConfiguration,
|
|
21
|
+
} from './configuration-kinds';
|
|
22
|
+
import {
|
|
23
|
+
type AdapterRoot,
|
|
24
|
+
type GameManifest,
|
|
25
|
+
GameManifestSchema,
|
|
26
|
+
type LearnMetadata,
|
|
27
|
+
type RootAdapter,
|
|
28
|
+
} from './schema';
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Resolved shapes
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/** D6's resolved adapter identities. */
|
|
35
|
+
export type ResolvedAdapter =
|
|
36
|
+
| {
|
|
37
|
+
readonly type: 'builtin';
|
|
38
|
+
readonly identity: 'three' | 'canvas' | 'dom';
|
|
39
|
+
readonly surface: 'three' | 'canvas' | 'dom';
|
|
40
|
+
}
|
|
41
|
+
| {
|
|
42
|
+
readonly type: 'module';
|
|
43
|
+
readonly identity: 'module';
|
|
44
|
+
readonly module: string;
|
|
45
|
+
readonly surface: 'three' | 'canvas' | 'dom';
|
|
46
|
+
}
|
|
47
|
+
| {
|
|
48
|
+
readonly type: 'ingest';
|
|
49
|
+
// 'ingest-react' (Track N, N1) is assigned here purely structurally
|
|
50
|
+
// (surface === 'dom'), same as 'ingest-three'/'ingest-pixi'.
|
|
51
|
+
readonly identity: 'ingest-three' | 'ingest-pixi' | 'ingest-react';
|
|
52
|
+
readonly surface: 'three' | 'canvas' | 'dom';
|
|
53
|
+
/**
|
|
54
|
+
* Project-relative ES module declaring `window.volterGame` beside a
|
|
55
|
+
* pristine copy. OPTIONAL rather than required-and-possibly-undefined
|
|
56
|
+
* (the shape its siblings above use) because most ingested games have
|
|
57
|
+
* nothing to say about it, and requiring the key would only make every
|
|
58
|
+
* construction site say `undefined`.
|
|
59
|
+
*/
|
|
60
|
+
readonly contractShim?: string | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* Project-relative ES module that writes this game'''s own DATA file back
|
|
63
|
+
* (see the field'''s schema description). Same OPTIONAL shape and same
|
|
64
|
+
* reason as {@link contractShim}: a game whose truth is entirely source
|
|
65
|
+
* declares nothing here.
|
|
66
|
+
*/
|
|
67
|
+
readonly dataWriter?: string | undefined;
|
|
68
|
+
readonly assets: Record<string, string> | undefined;
|
|
69
|
+
readonly domStubs: string[] | undefined;
|
|
70
|
+
readonly captureTimeoutMs: number | undefined;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export interface ResolvedAdapterRoot {
|
|
74
|
+
readonly id: string;
|
|
75
|
+
readonly surface: ResolvedAdapter['surface'];
|
|
76
|
+
readonly description: string | undefined;
|
|
77
|
+
readonly adapter: ResolvedAdapter;
|
|
78
|
+
readonly entry: string | undefined;
|
|
79
|
+
/**
|
|
80
|
+
* The component this root's WORLD is, in the game's own source (see the
|
|
81
|
+
* field's schema description). Present ⇒ Edit mounts THAT component alone as
|
|
82
|
+
* its design-time document and Play mounts the game through `entry`;
|
|
83
|
+
* absent ⇒ `entry` is what both modes mount.
|
|
84
|
+
*/
|
|
85
|
+
readonly world?: { readonly entry: string; readonly export: string | undefined } | undefined;
|
|
86
|
+
readonly zOrder: number;
|
|
87
|
+
readonly pausable: boolean;
|
|
88
|
+
readonly loop: 'gated' | 'self-driven';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ResolvedGameManifest {
|
|
92
|
+
readonly manifestVersion: 2;
|
|
93
|
+
readonly name: string;
|
|
94
|
+
readonly appId: string | undefined;
|
|
95
|
+
readonly version: string;
|
|
96
|
+
readonly engine: { readonly version: string };
|
|
97
|
+
/** Sorted by zOrder; ties broken by original array order (§3). */
|
|
98
|
+
readonly roots: readonly ResolvedAdapterRoot[];
|
|
99
|
+
/** The declared run configurations, validated by kind (`run-kinds.ts`). */
|
|
100
|
+
readonly configurations: readonly DeclaredConfiguration[];
|
|
101
|
+
/** Ids whose kind no registry here knows — only ever non-empty when the
|
|
102
|
+
* caller loaded with `configurationKinds: 'defer'` (a bundled host that
|
|
103
|
+
* cannot evaluate a project's kind contributions); the editor session
|
|
104
|
+
* is the validator for those. */
|
|
105
|
+
readonly deferredConfigurationKinds: readonly string[];
|
|
106
|
+
readonly resolution: { readonly width: number; readonly height: number } | undefined;
|
|
107
|
+
/** Construction-time renderer properties no world can declare for itself — see the schema. */
|
|
108
|
+
readonly rendering: { readonly antialias: boolean } | undefined;
|
|
109
|
+
readonly authoring:
|
|
110
|
+
| {
|
|
111
|
+
readonly hierarchy?:
|
|
112
|
+
| {
|
|
113
|
+
readonly rootLabels?: Readonly<Record<string, string>> | undefined;
|
|
114
|
+
readonly groups?:
|
|
115
|
+
| readonly {
|
|
116
|
+
readonly id: string;
|
|
117
|
+
readonly label: string;
|
|
118
|
+
readonly roots: readonly string[];
|
|
119
|
+
}[]
|
|
120
|
+
| undefined;
|
|
121
|
+
}
|
|
122
|
+
| undefined;
|
|
123
|
+
}
|
|
124
|
+
| undefined;
|
|
125
|
+
/** D18 — read by the debug-bridge installer to gate `?volter-debug=1` in production builds. */
|
|
126
|
+
readonly debug: { readonly allowInProduction: boolean } | undefined;
|
|
127
|
+
/** D15 — read by `mount-manifest.ts`'s boot-time seeding reader (gates
|
|
128
|
+
* whether `ctx.random` is seeded from `defaultSeed`/`?volter-seed=`), the
|
|
129
|
+
* gameplay-rng-ban burn-down scan, and the dev-mode Math.random phase
|
|
130
|
+
* trap.. */
|
|
131
|
+
readonly determinism:
|
|
132
|
+
| { readonly seededRandom: boolean; readonly defaultSeed?: number | undefined }
|
|
133
|
+
| undefined;
|
|
134
|
+
/** W3d (F11 perf gates) — read by `volter perf --assert-budget`
|
|
135
|
+
* (packages/volter-cli/src/perf.ts, assertBudget): each set metric is
|
|
136
|
+
* compared against the seeded headless perf run's measured value and any
|
|
137
|
+
* exceedance fails the gate with a per-metric table + nonzero exit. */
|
|
138
|
+
readonly budget:
|
|
139
|
+
| {
|
|
140
|
+
readonly frameCpuMsP95?: number | undefined;
|
|
141
|
+
readonly physicsMsP95?: number | undefined;
|
|
142
|
+
readonly maxEntities?: number | undefined;
|
|
143
|
+
readonly maxDrawCalls?: number | undefined;
|
|
144
|
+
readonly maxTriangles?: number | undefined;
|
|
145
|
+
}
|
|
146
|
+
| undefined;
|
|
147
|
+
/** Learner-facing metadata on shipped example projects; read by the editor
|
|
148
|
+
* server's registry surface (`GET /__editor/examples`,
|
|
149
|
+
* packages/editor-core/server/editor-server.ts) to generate the
|
|
150
|
+
* gallery/wizard/Learn-catalog registries. */
|
|
151
|
+
readonly learn: LearnMetadata | undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$/;
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Adapter shape guards
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
function isModuleAdapter(
|
|
161
|
+
adapter: RootAdapter,
|
|
162
|
+
): adapter is Extract<RootAdapter, { module: string }> {
|
|
163
|
+
return typeof adapter === 'object' && 'module' in adapter;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Cross-field checks (§3 — beyond what Zod alone enforces)
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
//
|
|
170
|
+
// Root-id uniqueness used to be a cross-field check HERE
|
|
171
|
+
// (`checkUniqueRootIds`, removed) — it moved INTO `GameManifestSchema`'s
|
|
172
|
+
// `roots` `superRefine` (schema.ts) instead, so the generated JSON Schema and
|
|
173
|
+
// any other schema consumer see the same constraint the loader always
|
|
174
|
+
// enforced. `parseManifest` below catches that one recognizable schema-level
|
|
175
|
+
// issue and rethrows it as a clean, single `Error` — the exact message shape
|
|
176
|
+
// `checkUniqueRootIds` used to throw directly — rather than a raw multi-issue
|
|
177
|
+
// `ZodError` dump, so existing callers/tests that pattern-match the message
|
|
178
|
+
// text are unaffected by the enforcement moving one layer down.
|
|
179
|
+
|
|
180
|
+
function checkAdapterEntryRules(root: AdapterRoot): void {
|
|
181
|
+
const { adapter } = root;
|
|
182
|
+
if (typeof adapter === 'string' && !root.entry) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`Game manifest: root "${root.id}" uses the '${adapter}' adapter but declares no ` +
|
|
185
|
+
'`entry` — a built-in adapter root is authored as an entry module.',
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (typeof adapter === 'object' && 'ingest' in adapter && !root.entry) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`Game manifest: root "${root.id}" is an { ingest } root but declares no \`entry\` — the ` +
|
|
191
|
+
"game's own entry module is what the host imports.",
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Resolution (§5)
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
function resolveAdapter(root: AdapterRoot): ResolvedAdapter {
|
|
201
|
+
const { adapter } = root;
|
|
202
|
+
|
|
203
|
+
if (typeof adapter === 'string') {
|
|
204
|
+
return { type: 'builtin', identity: adapter, surface: adapter };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (isModuleAdapter(adapter)) {
|
|
208
|
+
return {
|
|
209
|
+
type: 'module',
|
|
210
|
+
identity: 'module',
|
|
211
|
+
module: adapter.module,
|
|
212
|
+
surface: adapter.surface,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// isIngestAdapter (Track N, N1: 'ingest-react' joins 'ingest-three'/
|
|
217
|
+
// 'ingest-pixi').
|
|
218
|
+
const identity =
|
|
219
|
+
adapter.surface === 'three'
|
|
220
|
+
? 'ingest-three'
|
|
221
|
+
: adapter.surface === 'canvas'
|
|
222
|
+
? 'ingest-pixi'
|
|
223
|
+
: 'ingest-react';
|
|
224
|
+
const ingest = adapter.ingest;
|
|
225
|
+
return {
|
|
226
|
+
type: 'ingest',
|
|
227
|
+
identity,
|
|
228
|
+
surface: adapter.surface,
|
|
229
|
+
contractShim: ingest.contractShim,
|
|
230
|
+
dataWriter: ingest.dataWriter,
|
|
231
|
+
assets: ingest.assets,
|
|
232
|
+
domStubs: ingest.domStubs,
|
|
233
|
+
captureTimeoutMs: ingest.captureTimeoutMs,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function checkEngineVersionPin(version: string): void {
|
|
238
|
+
if (!SEMVER_RE.test(version)) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`Game manifest: engine.version "${version}" is not a valid exact semver string (e.g. ` +
|
|
241
|
+
'"0.1.0") — §1.5 requires an exact identity pin, not a range.',
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Resolve one already-Zod-validated root entry into its `ResolvedAdapterRoot`
|
|
248
|
+
* (adapter identity, and a pass-through of every other field).
|
|
249
|
+
* Every `AdapterRoot` field participates here or in a check above:
|
|
250
|
+
* `id` drives uniqueness; `adapter` (incl. its surface and
|
|
251
|
+
* `module`/`ingest.*` sub-fields) drives adapter identity
|
|
252
|
+
* resolution; `entry` drives the entry cross-field rule and is
|
|
253
|
+
* carried through; `world` is carried through for the ONE reader that mounts a
|
|
254
|
+
* design-time document (the editor's design session) and is deliberately not
|
|
255
|
+
* cross-checked against `entry` — the two name different modules on purpose;
|
|
256
|
+
* `zOrder` drives the final sort; `pausable`/`loop` are
|
|
257
|
+
* carried through as-is (their consumers are the runtime/CLI, T3.2/T3.3);
|
|
258
|
+
* `description` (T3.3
|
|
259
|
+
* slice 3) is carried through as-is — its consumer today is
|
|
260
|
+
* `resolveIngestDescriptor` (packages/editor-game/src/ingest/resolve-three.ts), which
|
|
261
|
+
* threads an ingest-three root's description into `IngestGame.description`.
|
|
262
|
+
*/
|
|
263
|
+
function resolveRoot(root: AdapterRoot): ResolvedAdapterRoot {
|
|
264
|
+
checkAdapterEntryRules(root);
|
|
265
|
+
const adapter = resolveAdapter(root);
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
id: root.id,
|
|
269
|
+
surface: adapter.surface,
|
|
270
|
+
description: root.description,
|
|
271
|
+
adapter,
|
|
272
|
+
entry: root.entry,
|
|
273
|
+
world:
|
|
274
|
+
root.world === undefined ? undefined : { entry: root.world.entry, export: root.world.export },
|
|
275
|
+
zOrder: root.zOrder,
|
|
276
|
+
pausable: root.pausable,
|
|
277
|
+
loop: root.loop,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// Entry point
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Parse + validate + resolve a raw manifest value into a `ResolvedGameManifest`.
|
|
287
|
+
* Pure data-in/data-out (no `fs`) — see `load-file.ts` for the Node path-based
|
|
288
|
+
* wrapper. Throws a descriptive `Error` (naming the offending root/field) on
|
|
289
|
+
* any Zod validation failure or cross-field rule violation.
|
|
290
|
+
*/
|
|
291
|
+
export interface LoadGameManifestOptions {
|
|
292
|
+
/** `require` (default): a declaration of an unregistered kind refuses.
|
|
293
|
+
* `defer`: it is reported in `deferredConfigurationKinds` instead. */
|
|
294
|
+
readonly configurationKinds?: 'require' | 'defer';
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function loadGameManifest(
|
|
298
|
+
raw: unknown,
|
|
299
|
+
options: LoadGameManifestOptions = {},
|
|
300
|
+
): ResolvedGameManifest {
|
|
301
|
+
// Removed-format guard FIRST, on the raw value: a root declaring a removed
|
|
302
|
+
// content field would otherwise fail Zod's `roots` discriminated union with
|
|
303
|
+
// a confusing shape error instead of the named migration message.
|
|
304
|
+
const parsed = GameManifestSchema.safeParse(raw);
|
|
305
|
+
if (!parsed.success) {
|
|
306
|
+
// D-V6: the `roots` `superRefine`'s composition issues — a duplicate root
|
|
307
|
+
// id, or more than one world root of the same medium — are the
|
|
308
|
+
// schema-level violations this loader surfaces as a clean, single `Error`
|
|
309
|
+
// (matching `checkUniqueRootIds`'s pre-D-V6 message shape) rather than a
|
|
310
|
+
// raw multi-issue dump, because each one already reads as a finished
|
|
311
|
+
// sentence naming the offending ids. Every other schema violation keeps
|
|
312
|
+
// propagating as the raw `ZodError` it always has (unchanged behavior,
|
|
313
|
+
// e.g. an unrecognized key or a bad `manifestVersion`).
|
|
314
|
+
const compositionIssue = parsed.error.issues.find(
|
|
315
|
+
(issue) =>
|
|
316
|
+
issue.message.startsWith('Game manifest: duplicate root id') ||
|
|
317
|
+
issue.message.startsWith('Game manifest: a project has at most one'),
|
|
318
|
+
);
|
|
319
|
+
if (compositionIssue) throw new Error(compositionIssue.message);
|
|
320
|
+
throw parsed.error;
|
|
321
|
+
}
|
|
322
|
+
const manifest: GameManifest = parsed.data;
|
|
323
|
+
|
|
324
|
+
checkEngineVersionPin(manifest.engine.version);
|
|
325
|
+
|
|
326
|
+
const resolvedWithIndex = manifest.roots.map((root, index) => ({
|
|
327
|
+
root: resolveRoot(root),
|
|
328
|
+
index,
|
|
329
|
+
}));
|
|
330
|
+
|
|
331
|
+
resolvedWithIndex.sort(
|
|
332
|
+
(a, b) => a.root.zOrder - b.root.zOrder || a.index - b.index, // ties -> array order (§3)
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const roots = resolvedWithIndex.map(({ root }) => root);
|
|
336
|
+
|
|
337
|
+
const configurations = (manifest.configurations ?? []) as DeclaredConfiguration[];
|
|
338
|
+
// Kinds are OPEN (ARCHITECTURE-CORE §The project model): each declaration
|
|
339
|
+
// is checked against its registered kind's own schema HERE, where the
|
|
340
|
+
// host's registry is, not in the Zod schema. A host that cannot evaluate a
|
|
341
|
+
// project's kind contributions defers unknown kinds by name instead of
|
|
342
|
+
// refusing them.
|
|
343
|
+
const deferredConfigurationKinds: string[] = [];
|
|
344
|
+
const kindIssues = configurationIssues(configurations).filter((issue) => {
|
|
345
|
+
if (options.configurationKinds !== 'defer') return true;
|
|
346
|
+
const unknown = /^configurations "([^"]+)": unknown kind/.exec(issue);
|
|
347
|
+
if (!unknown) return true;
|
|
348
|
+
deferredConfigurationKinds.push(unknown[1] as string);
|
|
349
|
+
return false;
|
|
350
|
+
});
|
|
351
|
+
if (kindIssues.length > 0) {
|
|
352
|
+
throw new Error(`Game manifest: ${kindIssues.join('\n ')}`);
|
|
353
|
+
}
|
|
354
|
+
for (const configuration of configurations) {
|
|
355
|
+
if (
|
|
356
|
+
!configurationKind(configuration.kind) &&
|
|
357
|
+
!deferredConfigurationKinds.includes(configuration.id)
|
|
358
|
+
) {
|
|
359
|
+
deferredConfigurationKinds.push(configuration.id);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
manifestVersion: manifest.manifestVersion,
|
|
365
|
+
name: manifest.name,
|
|
366
|
+
appId: manifest.appId,
|
|
367
|
+
version: manifest.version,
|
|
368
|
+
engine: { version: manifest.engine.version },
|
|
369
|
+
roots,
|
|
370
|
+
configurations,
|
|
371
|
+
deferredConfigurationKinds,
|
|
372
|
+
resolution: manifest.resolution,
|
|
373
|
+
rendering: manifest.rendering,
|
|
374
|
+
authoring: manifest.authoring,
|
|
375
|
+
debug: manifest.debug,
|
|
376
|
+
determinism: manifest.determinism,
|
|
377
|
+
budget: manifest.budget,
|
|
378
|
+
learn: manifest.learn,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Finding the project manifest ON DISK. There is ONE filename — see
|
|
2
|
+
// `./filename.ts` for why the old one was removed rather than kept readable.
|
|
3
|
+
//
|
|
4
|
+
// Deliberately dependency-light — `node:fs` + `node:path` + `./filename` and
|
|
5
|
+
// NOTHING else (no Zod, no `./load`). Several callers are explicitly
|
|
6
|
+
// schema-free by design (`packages/editor-core/server/project-root-surface.ts`,
|
|
7
|
+
// `packages/editor/vite-plugin-ui-oid.ts`) and must be able to locate a
|
|
8
|
+
// manifest without dragging the validator in.
|
|
9
|
+
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import {
|
|
13
|
+
MANIFEST_FILENAME,
|
|
14
|
+
REMOVED_MANIFEST_FILENAME,
|
|
15
|
+
removedManifestFilenameMessage,
|
|
16
|
+
} from './filename';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Throw the rename instruction if `dir` carries only the REMOVED manifest
|
|
20
|
+
* filename. The `assertNoRemoved*` shape: a removed thing must reject loudly
|
|
21
|
+
* naming the exact fix, never be silently ignored (which would present as
|
|
22
|
+
* "this folder is not a project" — a diagnosis pointing nowhere).
|
|
23
|
+
*
|
|
24
|
+
* Returns normally when `dir` holds the current manifest, or neither file.
|
|
25
|
+
*/
|
|
26
|
+
export function assertNoRemovedManifestFilename(dir: string): void {
|
|
27
|
+
if (existsSync(join(dir, MANIFEST_FILENAME))) return;
|
|
28
|
+
if (!existsSync(join(dir, REMOVED_MANIFEST_FILENAME))) return;
|
|
29
|
+
throw new Error(removedManifestFilenameMessage(join(dir, REMOVED_MANIFEST_FILENAME)));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The manifest path inside `dir`. Always `volter.project.json` — so a "not
|
|
34
|
+
* found" message names the filename a project must be using — except that a
|
|
35
|
+
* directory carrying only the removed `volter.game.json` THROWS the rename
|
|
36
|
+
* instruction instead of reporting a phantom path.
|
|
37
|
+
*
|
|
38
|
+
* This is the drop-in replacement for a hand-written `join(dir, …)` everywhere
|
|
39
|
+
* a manifest is READ. Writers want {@link manifestWritePath}.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveManifestPath(dir: string): string {
|
|
42
|
+
assertNoRemovedManifestFilename(dir);
|
|
43
|
+
return join(dir, MANIFEST_FILENAME);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** True when `dir` holds a manifest. A lone `volter.game.json` throws. */
|
|
47
|
+
export function hasManifest(dir: string): boolean {
|
|
48
|
+
assertNoRemovedManifestFilename(dir);
|
|
49
|
+
return existsSync(join(dir, MANIFEST_FILENAME));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Where a manifest gets WRITTEN in `dir`. There is one answer. */
|
|
53
|
+
export function manifestWritePath(dir: string): string {
|
|
54
|
+
return join(dir, MANIFEST_FILENAME);
|
|
55
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE RUNTIME HALF OF A PROJECT'S ENVIRONMENT (ARCHITECTURE-CORE §The
|
|
3
|
+
* project model, "environment"): the Node version a project's processes need
|
|
4
|
+
* is declared ONCE, where the ecosystem already declares it —
|
|
5
|
+
* `package.json` `engines.node` — and read here by whatever starts a
|
|
6
|
+
* `process` configuration (the editor server's configurations route, the
|
|
7
|
+
* CLI's autoboot). The manifest carries no `environment` key: a second
|
|
8
|
+
* declaration of the same fact is the drift a single home prevents.
|
|
9
|
+
*
|
|
10
|
+
* Node-only (reads the project folder), like `locate.ts`.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import semver from 'semver';
|
|
15
|
+
|
|
16
|
+
/** `engines.node` from the project's `package.json`, or `null` when the
|
|
17
|
+
* project declares none (or has no package.json). */
|
|
18
|
+
export function declaredNodeRange(projectDir: string): string | null {
|
|
19
|
+
try {
|
|
20
|
+
const raw: unknown = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8'));
|
|
21
|
+
const engines = (raw as { engines?: { node?: unknown } } | null)?.engines;
|
|
22
|
+
return typeof engines?.node === 'string' && engines.node.trim() ? engines.node.trim() : null;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Why a process of this project cannot start on the running Node, spelled
|
|
30
|
+
* for the person who will fix it — or `null` when it can. A range that is
|
|
31
|
+
* not valid semver is itself the issue: a declaration nobody can satisfy is
|
|
32
|
+
* named, never skipped.
|
|
33
|
+
*/
|
|
34
|
+
export function nodeRuntimeIssue(
|
|
35
|
+
projectDir: string,
|
|
36
|
+
running: string = process.versions.node,
|
|
37
|
+
): string | null {
|
|
38
|
+
const range = declaredNodeRange(projectDir);
|
|
39
|
+
if (range === null) return null;
|
|
40
|
+
if (semver.validRange(range) === null) {
|
|
41
|
+
return `package.json declares engines.node "${range}", which is not a semver range`;
|
|
42
|
+
}
|
|
43
|
+
if (semver.satisfies(running, range, { includePrerelease: true })) return null;
|
|
44
|
+
return (
|
|
45
|
+
`package.json declares engines.node "${range}" and this host runs Node ${running} — ` +
|
|
46
|
+
`run the editor and \`volter\` on a Node that satisfies it, or change engines.node`
|
|
47
|
+
);
|
|
48
|
+
}
|