@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.
Files changed (53) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +6 -0
  3. package/README.md +18 -3
  4. package/package.json +64 -4
  5. package/schemas/engine-api.json +57 -0
  6. package/schemas/engine-api.md +22 -0
  7. package/schemas/volter-project.schema.json +468 -0
  8. package/schemas/volter-settings.schema.json +149 -0
  9. package/src/adapter/adapter-module.ts +997 -0
  10. package/src/adapter/adapter-surface.ts +34 -0
  11. package/src/adapter/authoring-seam-contract.ts +224 -0
  12. package/src/adapter/authoring.ts +1336 -0
  13. package/src/adapter/binding.ts +445 -0
  14. package/src/adapter/debug-registry-ref.ts +19 -0
  15. package/src/adapter/editor-looks.ts +519 -0
  16. package/src/adapter/finders/finder-result.ts +29 -0
  17. package/src/adapter/finders/registry.ts +116 -0
  18. package/src/adapter/frame-capture.ts +80 -0
  19. package/src/adapter/host-context.ts +114 -0
  20. package/src/adapter/index.ts +209 -0
  21. package/src/adapter/ingest/contract-system-slots.ts +28 -0
  22. package/src/adapter/ingest/game-contract-seams.ts +86 -0
  23. package/src/adapter/ingest/game-contract.ts +506 -0
  24. package/src/adapter/ingest/mount-readiness.ts +140 -0
  25. package/src/adapter/loop-gate-report.ts +53 -0
  26. package/src/adapter/manifest-interpreter.ts +64 -0
  27. package/src/adapter/native-entry-surface.ts +100 -0
  28. package/src/adapter/physics-adapter-2d.ts +30 -0
  29. package/src/adapter/render-audio.ts +20 -0
  30. package/src/adapter/render-memory.ts +34 -0
  31. package/src/adapter/renderer-config.ts +84 -0
  32. package/src/adapter/root-adapter.ts +243 -0
  33. package/src/adapter/root-seam-contract.ts +61 -0
  34. package/src/adapter/root-stacking.ts +67 -0
  35. package/src/adapter/seam-evidence.ts +336 -0
  36. package/src/adapter/system-adapter.ts +865 -0
  37. package/src/adapter/system-seam-contract.ts +138 -0
  38. package/src/adapter/system-slot.ts +301 -0
  39. package/src/adapter/transform.ts +35 -0
  40. package/src/adapter/workspace-arrangement.ts +92 -0
  41. package/src/core/system-phase.ts +52 -0
  42. package/src/inspection.ts +281 -0
  43. package/src/manifest/configuration-kinds.ts +241 -0
  44. package/src/manifest/editor-port.ts +69 -0
  45. package/src/manifest/filename.ts +45 -0
  46. package/src/manifest/kind-modules.ts +58 -0
  47. package/src/manifest/load-file.ts +33 -0
  48. package/src/manifest/load.ts +380 -0
  49. package/src/manifest/locate.ts +55 -0
  50. package/src/manifest/runtime-environment.ts +48 -0
  51. package/src/manifest/schema.ts +690 -0
  52. package/src/settings/keys.ts +152 -0
  53. package/src/settings/schema.ts +151 -0
@@ -0,0 +1,690 @@
1
+ // T3.1 slice 1 — the game manifest schema (`volter.project.json`).
2
+ //
3
+ // This is the serialized form of the Game's root list for the adjudicated
4
+ // field list this file implements exactly. `load.ts` is this schema's runtime
5
+ // reader (T4.1 policy): it parses via this schema, then layers the
6
+ // cross-field checks and default resolution §3/§5 describe. Every field below
7
+ // has a `.describe()` (repo policy — powers `scripts/generate-schema.ts` and
8
+ // the T4.1 schema-walk coverage test).
9
+ //
10
+ // Naming: `XxxSchema` is the Zod schema, `Xxx` the inferred type. Every schema
11
+ // file in this repo reads that way, so the bare names stay reserved for the
12
+ // types.
13
+ //
14
+ // Adapter surface values mirror `AdapterSurface` in
15
+ // `packages/editor-game/src/runtime/game.ts` (`'three' | 'canvas' | 'dom'`)
16
+ // by value, not by import. The adapter remains the sole root discriminator.
17
+
18
+ import { z } from 'zod';
19
+
20
+ /**
21
+ * Current on-disk `volter.project.json` format. Exported so editor/server
22
+ * compatibility checks do not duplicate the schema's literal value.
23
+ */
24
+ export const GAME_MANIFEST_VERSION = 2 as const;
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Root adapter identity. The adapter is the sole discriminator: a root never
28
+ // repeats its rendering substrate in a sibling `kind` field. Module and ingest
29
+ // adapters carry the surface their adapter contract exposes because the host
30
+ // must allocate a layer before the module has mounted.
31
+ //
32
+ // The surface vocabulary is `three | canvas | dom` — what the HOST HANDS the
33
+ // root, not which library it uses. `canvas` and `dom` are platform primitives:
34
+ // a canvas root may be Pixi, Phaser, Babylon, raw WebGL/WebGPU or a 2D context,
35
+ // and a dom root may be React, Vue, Svelte or plain HTML, all through the
36
+ // identical host. The former `canvas`/`react` named two libraries in a seam
37
+ // that has nothing to do with either, which foreclosed the rest by
38
+ // construction.
39
+ //
40
+ // All three surfaces are legal as a BARE adapter string: each names a root the
41
+ // editor itself mounts from an `entry` module — `three` through the R3F lane
42
+ // (`editor-game/src/host/roots/r3f-root.tsx`), `canvas` through the Pixi lane
43
+ // (`@volter/game-runtime/canvas-react`), `dom` through the React lane. In every case the
44
+ // entry's document is its own TSX source. A root the engine does NOT mount
45
+ // arrives as an `{ ingest }` root or a `{ module }` adapter the project
46
+ // supplies, both of which name their `surface` explicitly.
47
+ //
48
+ // `three` earns a kind of its own because the host must provide something the
49
+ // platform cannot: the ONE shared `three` instance (identity matters — ingest
50
+ // games bring their own, and r3f depends on dedupe). React needs no equivalent,
51
+ // which is exactly why react is a `dom` root rather than its own kind.
52
+ //
53
+ // The pre-rename spellings `threejs`/`pixijs`/`react` are REMOVED, not
54
+ // normalized (the legacy-removal doctrine at
55
+ // ARCHITECTURE-CORE §Vocabulary). Normalization was a second way to say the
56
+ // same thing: two spellings both parsed, so both kept being written, and
57
+ // SECOND readers grew (the dev server's surface probe, the hosted-editor
58
+ // bundler's alias sets) each re-implementing the same map. A manifest naming
59
+ // an old spelling now fails to parse with an issue that names the canonical
60
+ // value and the exact one-line edit.
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /** Removed spellings → the canonical value each one must be rewritten to. */
64
+ const REMOVED_SURFACE_SPELLING: Readonly<Record<string, 'three' | 'canvas' | 'dom'>> = {
65
+ threejs: 'three',
66
+ pixijs: 'canvas',
67
+ react: 'dom',
68
+ };
69
+
70
+ /** Removed spellings legal as a BARE adapter (a root the engine itself mounts). */
71
+ const REMOVED_BARE_ADAPTER_SPELLING: Readonly<Record<string, 'three' | 'canvas' | 'dom'>> = {
72
+ threejs: 'three',
73
+ pixijs: 'canvas',
74
+ react: 'dom',
75
+ };
76
+
77
+ function removedSpellingMessage(bad: string, where: 'adapter' | 'adapter.surface'): string {
78
+ const canonical =
79
+ where === 'adapter' ? REMOVED_BARE_ADAPTER_SPELLING[bad] : REMOVED_SURFACE_SPELLING[bad];
80
+ return (
81
+ `Game manifest: \`${where}\`: "${bad}" — that spelling was REMOVED ` +
82
+ '(ARCHITECTURE-CORE §Vocabulary). The surface vocabulary is ' +
83
+ "'three' | 'canvas' | 'dom' — what the HOST HANDS the root, never which " +
84
+ `library it uses.\nFix: change \`${where}\` to "${canonical}".`
85
+ );
86
+ }
87
+
88
+ /** Reject a removed surface spelling wherever one may appear — the bare
89
+ * shorthand, or the `surface` field of a module/ingest adapter — with an issue
90
+ * naming the canonical value. Runs as a Zod preprocess so the message wins
91
+ * over the union's generic discriminant error. */
92
+ function rejectRemovedRootAdapterSpelling(value: unknown, ctx: z.core.$RefinementCtx): unknown {
93
+ if (typeof value === 'string' && REMOVED_BARE_ADAPTER_SPELLING[value]) {
94
+ ctx.addIssue({ code: 'custom', message: removedSpellingMessage(value, 'adapter') });
95
+ return z.NEVER;
96
+ }
97
+ if (value && typeof value === 'object' && 'surface' in value) {
98
+ const surface = (value as { surface?: unknown }).surface;
99
+ if (typeof surface === 'string' && REMOVED_SURFACE_SPELLING[surface]) {
100
+ ctx.addIssue({
101
+ code: 'custom',
102
+ message: removedSpellingMessage(surface, 'adapter.surface'),
103
+ });
104
+ return z.NEVER;
105
+ }
106
+ }
107
+ return value;
108
+ }
109
+
110
+ export const RootAdapterSchema = z
111
+ .preprocess(
112
+ rejectRemovedRootAdapterSpelling,
113
+ z.union([
114
+ z.enum(['three', 'canvas', 'dom']),
115
+ z
116
+ .object({
117
+ module: z
118
+ .string()
119
+ .describe('Game-folder-relative path to a custom adapter module (trust boundary)'),
120
+ surface: z
121
+ .enum(['three', 'canvas', 'dom'])
122
+ .describe('Native surface exposed by this custom adapter contract'),
123
+ })
124
+ .strict(),
125
+ z
126
+ .object({
127
+ surface: z
128
+ .enum(['three', 'canvas', 'dom'])
129
+ .describe('Native surface captured from the unmodified game'),
130
+ ingest: z
131
+ .object({
132
+ contractShim: z
133
+ .string()
134
+ .optional()
135
+ .describe(
136
+ "Project-relative path to a HOST-ADDED ES module that declares this game's " +
137
+ '`window.volterGame` contract (adapter/ingest/game-contract.ts) WITHOUT editing a ' +
138
+ 'vendored byte — the pristine-copy door. The host imports it immediately BEFORE ' +
139
+ "the game's own entry module, in the editor's realm. " +
140
+ 'TIMING CONTRACT, which a shim author must obey: because it runs before the ' +
141
+ 'game`s entry, it MUST assign `window.volterGame` SYNCHRONOUSLY at top level. ' +
142
+ 'Anything that needs the game`s own modules must be LAZY: a dynamic `import()` ' +
143
+ 'INSIDE each verb/provider closure, resolved at call time. Importing a game module ' +
144
+ 'eagerly from the shim would evaluate it ahead of the game`s own entry and reorder ' +
145
+ 'its module side effects. ' +
146
+ 'Every such `import()` MUST take a STRING LITERAL, never a variable/table lookup. ' +
147
+ 'A variable specifier is not statically analyzable, so the dev server leaves it ' +
148
+ 'alone and it resolves at runtime against the shim`s own `/@fs/` url — while the ' +
149
+ 'game`s entry has had ITS relative imports rewritten to whatever url the server ' +
150
+ 'considers canonical for those files. Module identity is per-url, so the shim ' +
151
+ 'silently binds a SECOND, freshly-evaluated copy of the whole game and every ' +
152
+ 'provider reports a game that never started. Measured on a vendored three ' +
153
+ 'game: 285 world segments through the game`s own graph, 0 through the shim`s, ' +
154
+ 'and no error anywhere.',
155
+ ),
156
+ dataWriter: z
157
+ .string()
158
+ .optional()
159
+ .describe(
160
+ "Project-relative path to a HOST-ADDED ES module that writes this game's own " +
161
+ 'DATA file back — the sibling of a source write for a game whose authorable ' +
162
+ 'truth is not source. A level-based game places its enemies, pickups and ' +
163
+ 'objectives in binary records inside a level file, reaching no source ' +
164
+ 'literal, so an ' +
165
+ 'authored move of one is an edit to that file and nothing else can write it. ' +
166
+ 'The module declares two exports and the host knows nothing else about it: ' +
167
+ '`dataFile` (a project-relative string — the ONE file this writer edits) and ' +
168
+ '`planDataEdit(bytes, {record, property, baseline, next})`, which returns ' +
169
+ '`{changed: true, bytes}` or `{changed: false, reason}` and may be async. ' +
170
+ 'It is loaded in the editor realm beside the game, so it may `import()` the ' +
171
+ "game's own modules to resolve what only a running game knows (a level " +
172
+ 'writer asks the game`s own spatial lookup which cell a moved object ' +
173
+ 'landed in, and refuses "outside the level" rather than writing data the game ' +
174
+ 'would read back wrong). It NEVER fabricates: a record it cannot address, or ' +
175
+ 'a property it does not model, is a named refusal. ' +
176
+ 'An object is anchored to a record by carrying `userData.volterRecordIndex` — ' +
177
+ 'the game declares that identity itself (a recorded patch or a shim); the ' +
178
+ 'host never infers one.',
179
+ ),
180
+ assets: z
181
+ .record(z.string(), z.string())
182
+ .optional()
183
+ .describe(
184
+ 'Path-substring -> served-URL rewrites for this ingested game (IngestGame.assets today)',
185
+ ),
186
+ moduleAliases: z
187
+ .record(z.string(), z.string())
188
+ .optional()
189
+ .describe(
190
+ "The game's OWN bundler path aliases, restated as a mounting fact so its " +
191
+ 'source resolves unedited. A game built with its own Vite/webpack config ' +
192
+ 'routinely imports itself through an alias (`~/lib/config`, `@/store/x`); the ' +
193
+ "editor boots Vite from the ENGINE's config and never reads a game's own, so " +
194
+ 'without this every such import is an unresolvable specifier and the mount dies ' +
195
+ 'at transform time. Key = the specifier PREFIX exactly as the game writes it ' +
196
+ '("~/"); value = the repo-root-relative directory it means ' +
197
+ '("/vendor/games/<id>/src/"). SELF-SCOPING: the host honors an entry only for ' +
198
+ "importers inside the vendored game folder the value points into, so one game's " +
199
+ "alias can never capture another module's import. Rewriting the game's own " +
200
+ 'imports instead would be a large non-seam-shaped diff against unmodified ' +
201
+ 'source — this declares the fact rather than editing it away.',
202
+ ),
203
+ domStubs: z
204
+ .array(z.string())
205
+ .optional()
206
+ .describe(
207
+ 'DOM API stub ids this ingested game requires to run headlessly/in-realm',
208
+ ),
209
+ captureTimeoutMs: z
210
+ .number()
211
+ .optional()
212
+ .describe(
213
+ "How long the host waits for this game's first captured frame before the mount " +
214
+ 'FAILS by name. A game that needs a long boot (streamed world assets, a menu ' +
215
+ 'the player must clear) raises it; the default is 10s.',
216
+ ),
217
+ })
218
+ .strict()
219
+ .describe(
220
+ 'Ingest adapter configuration for an unmodified game (a repo-vendored game or your ' +
221
+ 'own external folder)',
222
+ ),
223
+ })
224
+ .strict(),
225
+ ]),
226
+ )
227
+ .describe(
228
+ "Root adapter identity: built-in 'three'/'canvas'/'dom' (what the host hands " +
229
+ "the root — 'canvas' covers Pixi/Phaser/Babylon/raw WebGL, 'dom' covers " +
230
+ 'React/Vue/plain HTML), a custom { module, surface } adapter, or an ' +
231
+ '{ ingest, surface } adapter for an unmodified game.',
232
+ );
233
+ export type RootAdapter = z.infer<typeof RootAdapterSchema>;
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Root entry
237
+ // ---------------------------------------------------------------------------
238
+
239
+ export const AdapterRootSchema = z
240
+ .object({
241
+ id: z
242
+ .string()
243
+ .describe(
244
+ 'Unique id for this root within the manifest (enforced-unique, D-V6 — GameManifestSchema.roots rejects duplicates)',
245
+ ),
246
+ description: z
247
+ .string()
248
+ .optional()
249
+ .describe(
250
+ "Optional one-line description of this root's game/content (e.g. an ingested game's " +
251
+ "blurb — the manifest-native replacement for a registry entry's description field)",
252
+ ),
253
+ adapter: RootAdapterSchema.describe(
254
+ "This root's adapter and sole rendering/lifecycle discriminator",
255
+ ),
256
+ entry: z
257
+ .string()
258
+ .optional()
259
+ .describe(
260
+ 'Module exporting a world root — a TSX/R3F component or a setup()/adapter (three), or a ' +
261
+ 'React component (dom). Required for every built-in adapter root.',
262
+ ),
263
+ styles: z
264
+ .array(z.string())
265
+ .optional()
266
+ .describe(
267
+ "Project-root-relative CSS files this root's mount needs — the PAGE-level stylesheet a " +
268
+ 'game written to own a browser tab keeps its layout in (an ingested game is the ' +
269
+ 'standing case: its own entry imported the sheet, the host shim that replaces that ' +
270
+ 'entry cannot, because `html`/`body`/`*` rules would restyle the editor). Declared ' +
271
+ 'here, the editor serves each file rewritten to apply ONLY inside the container it ' +
272
+ "mounts the root's DOM in (`@scope`; packages/editor/server/scoped-game-css.ts), so " +
273
+ "the HUD is styled on the UI board's story cards and on the game surface without the " +
274
+ 'editor seeing a single one of those rules. The files themselves are never modified.',
275
+ ),
276
+ world: z
277
+ .object({
278
+ entry: z
279
+ .string()
280
+ .describe("Project-relative path to the game's OWN module exporting that component"),
281
+ export: z
282
+ .string()
283
+ .optional()
284
+ .describe(
285
+ 'Named export to read from that module; omit when the module default-exports it',
286
+ ),
287
+ })
288
+ .strict()
289
+ .optional()
290
+ .describe(
291
+ "The component this root's WORLD is, in the game's own source. Two readers, and they " +
292
+ "mount different things: the EDITOR mounts THIS component alone as the root's " +
293
+ 'design-time document, while `entry` above stays what PLAY mounts (the whole game — ' +
294
+ 'its own composition, its own HUD, its own loop). It sits on the ROOT rather than ' +
295
+ 'inside any one adapter because the split it names is not about ingest: a game whose ' +
296
+ 'runtime entry is a SHELL over the thing an author actually edits has this shape ' +
297
+ 'whether the source is vendored or first-party. Two shipped cases, one field: an ' +
298
+ 'ingested game whose `entry` mounts game+HUD+loop while the named export is the world ' +
299
+ 'alone; and a translated Unity/Godot port, whose `entry` is a scene HOST that mounts ' +
300
+ 'the scene the built player starts on (`EditorBuildSettings` index 0 — routinely a ' +
301
+ 'menu with no 3D content at all) while the named export opens the scene an author ' +
302
+ "edits. That is Unity's own split: build settings say where the PLAYER starts; the " +
303
+ 'Scene view opens a scene, and Unity keeps that fact somewhere else entirely. A game ' +
304
+ 'whose world is not a separate component omits this and keeps mounting through ' +
305
+ '`entry` in both modes.',
306
+ ),
307
+ zOrder: z
308
+ .number()
309
+ .int()
310
+ .default(0)
311
+ .describe('Canvas stacking order; ties broken by array order'),
312
+ pausable: z
313
+ .boolean()
314
+ .default(true)
315
+ .describe('Whether play-mode pause/step applies to this root'),
316
+ loop: z
317
+ .enum(['gated', 'self-driven'])
318
+ .default('gated')
319
+ .describe(
320
+ 'gated: host-driven loop (default). self-driven: this root drives its own loop ' +
321
+ '(composited, unsynchronized)',
322
+ ),
323
+ })
324
+ .strict();
325
+ export type AdapterRoot = z.infer<typeof AdapterRootSchema>;
326
+
327
+ /**
328
+ * The MEDIUM a root occupies — its surface, i.e. what the host hands it. A
329
+ * bare adapter string names its own surface; `{ module, surface }` and
330
+ * `{ ingest, surface }` name it explicitly. Bucketing by surface is what makes
331
+ * a first-party `@pixi/react` world, an ingested canvas game and a custom
332
+ * canvas module ONE medium: they all compete for the same canvas surface, and
333
+ * which library draws on it is not what the rule below is about.
334
+ */
335
+ function rootMedium(root: AdapterRoot): 'three' | 'canvas' | 'dom' {
336
+ return typeof root.adapter === 'string' ? root.adapter : root.adapter.surface;
337
+ }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Learn metadata (FT-5)
341
+ // ---------------------------------------------------------------------------
342
+
343
+ export const LearnKindSchema = z
344
+ .enum(['starter', 'feature', 'sample-game', 'lesson-companion'])
345
+ .describe(
346
+ 'Content kind in the FTUE taxonomy (§3): starter (minimal per-genre starting point), ' +
347
+ 'feature (demonstrates a subsystem), sample-game (complete game as an architectural ' +
348
+ 'reference), lesson-companion (exists to back a Learn-site lesson)',
349
+ );
350
+ export type LearnKind = z.infer<typeof LearnKindSchema>;
351
+
352
+ export const LearnDifficultySchema = z
353
+ .enum(['beginner', 'intermediate', 'advanced'])
354
+ .describe('Learner-facing difficulty rating');
355
+ export type LearnDifficulty = z.infer<typeof LearnDifficultySchema>;
356
+
357
+ export const LearnMetadataSchema = z
358
+ .object({
359
+ kind: LearnKindSchema,
360
+ title: z.string().min(1).describe('Learner-facing display title (gallery card headline)'),
361
+ summary: z.string().min(1).describe('One-sentence learner-facing summary (gallery card body)'),
362
+ difficulty: LearnDifficultySchema,
363
+ features: z
364
+ .array(z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'features tags must be kebab-case'))
365
+ .describe('Short kebab-case feature tags used as gallery/catalog filter facets'),
366
+ thumbnail: z
367
+ .string()
368
+ .optional()
369
+ .describe(
370
+ 'Optional thumbnail image reference, PROJECT-relative (canonical path: ' +
371
+ 'learn/thumbnail.png — the §3 schema path). Generated per FT-11 by the deterministic ' +
372
+ 'render tooling (scripts/generate-learn-thumbnails.ts), never hand-made',
373
+ ),
374
+ preview: z
375
+ .string()
376
+ .optional()
377
+ .describe(
378
+ 'Optional short preview video/loop reference, PROJECT-relative (canonical path: ' +
379
+ 'learn/preview.mp4; generated per FT-11)',
380
+ ),
381
+ lesson: z
382
+ .string()
383
+ .optional()
384
+ .describe(
385
+ 'Optional Learn-site lesson URL that teaches this project 1:1 ' +
386
+ '(e.g. https://example.com/manual/physics/joints/)',
387
+ ),
388
+ })
389
+ .strict()
390
+ .describe(
391
+ 'Learner-facing metadata (FT-5) — powers the example ' +
392
+ 'gallery, New Project wizard, and Learn-site catalog. Registries are generated from this ' +
393
+ 'block; there is no parallel hand-edited catalog.',
394
+ );
395
+ export type LearnMetadata = z.infer<typeof LearnMetadataSchema>;
396
+
397
+ // ---------------------------------------------------------------------------
398
+ // Game manifest (volter.project.json)
399
+ // ---------------------------------------------------------------------------
400
+
401
+ /** The envelope of one run configuration; the kind's schema takes the rest. */
402
+ const ConfigurationEnvelopeSchema = z
403
+ .object({
404
+ id: z.string().min(1).describe('Configuration id, unique in the manifest'),
405
+ kind: z.string().min(1).describe('A registered run kind'),
406
+ })
407
+ .passthrough();
408
+
409
+ export const GameManifestSchema = z
410
+ .object({
411
+ $schema: z
412
+ .string()
413
+ .optional()
414
+ .describe('Optional JSON Schema URI for editor autocomplete and validation'),
415
+ manifestVersion: z
416
+ .literal(GAME_MANIFEST_VERSION)
417
+ .describe('Manifest file format version (v2 clean roots)'),
418
+ name: z.string().describe('Game display name'),
419
+ appId: z
420
+ .string()
421
+ .optional()
422
+ .describe('Stable reverse-domain application id used by packaged targets'),
423
+ version: z.string().describe("The game's own version (used for orphan detection)"),
424
+ engine: z
425
+ .object({
426
+ version: z
427
+ .string()
428
+ .describe(
429
+ 'Exact @volter/project semver this game was scaffolded/last upgraded against — an ' +
430
+ 'identity pin, not a range',
431
+ ),
432
+ })
433
+ .strict()
434
+ .describe('Engine version pin'),
435
+ roots: z
436
+ .array(AdapterRootSchema)
437
+ .superRefine((roots, ctx) => {
438
+ // D-V6 (debt): root ids are described as unique above but that was
439
+ // describe-only at the SCHEMA level — only load.ts's loader-level
440
+ // cross-field check caught a collision, so any OTHER consumer of this
441
+ // schema (the generated JSON Schema, editor tooling) saw duplicate
442
+ // ids as legal. Enforced HERE instead, naming every colliding id (not
443
+ // just the first found) — a root id is the key every per-world editor
444
+ // surface is addressed by, so a silently-permitted duplicate would
445
+ // have two roots silently collide.
446
+ const counts = new Map<string, number>();
447
+ for (const root of roots) counts.set(root.id, (counts.get(root.id) ?? 0) + 1);
448
+ const duplicates = [...counts.entries()].filter(([, n]) => n > 1).map(([id]) => id);
449
+ if (duplicates.length > 0) {
450
+ const idsText = duplicates.map((id) => `"${id}"`).join(', ');
451
+ ctx.addIssue({
452
+ code: z.ZodIssueCode.custom,
453
+ message:
454
+ duplicates.length === 1
455
+ ? `Game manifest: duplicate root id ${idsText} — root ids must be unique across the manifest (enforced-unique).`
456
+ : `Game manifest: duplicate root ids: ${idsText} — root ids must be unique across the manifest (enforced-unique).`,
457
+ });
458
+ }
459
+
460
+ // A project has at most ONE world root per MEDIUM (owner-ratified).
461
+ // The medium is the surface (see `rootMedium`), so `three`, `dom` and
462
+ // the canvas surface are the three buckets — a first-party canvas
463
+ // world, a canvas ingest and a canvas module are the SAME medium.
464
+ //
465
+ // Enforced here rather than left to the reader because the editor
466
+ // mounts exactly one root per medium (`resolveFocusedThreejsRootId`
467
+ // is a `find`, and the design-time layer stack hosts the one dom
468
+ // root): a second root of the same medium parsed cleanly and was then
469
+ // silently never mounted, which is the failure mode this rejects.
470
+ //
471
+ const idsByMedium = new Map<string, string[]>();
472
+ for (const root of roots) {
473
+ const medium = rootMedium(root);
474
+ const ids = idsByMedium.get(medium);
475
+ if (ids) ids.push(root.id);
476
+ else idsByMedium.set(medium, [root.id]);
477
+ }
478
+ for (const [medium, ids] of idsByMedium) {
479
+ if (ids.length < 2) continue;
480
+ ctx.addIssue({
481
+ code: z.ZodIssueCode.custom,
482
+ message:
483
+ `Game manifest: a project has at most one ${medium} world root — ` +
484
+ `${ids.map((id) => `"${id}"`).join(', ')} all occupy the ${medium} surface. ` +
485
+ 'Keep one and remove the rest, or move a root to a different medium ' +
486
+ "(three / canvas / dom); only one root per medium is ever mounted, so a second one's " +
487
+ 'content would never appear.',
488
+ });
489
+ }
490
+ })
491
+ .describe(
492
+ "The project's explicit adapter-root composition — ZERO OR MORE roots, and no implicit root: a project with no roots mounts nothing (a library of models, a folder of documents) and the editor derives its chrome from what IS declared. " +
493
+ 'Root ids are unique, and a project declares at most ONE world root per medium — ' +
494
+ 'one `three`, one `dom`, one canvas-surface root (first-party canvas, canvas ingest ' +
495
+ 'and canvas module are the same medium).',
496
+ ),
497
+ authoring: z
498
+ .object({
499
+ hierarchy: z
500
+ .object({
501
+ rootLabels: z
502
+ .record(z.string(), z.string())
503
+ .optional()
504
+ .describe('Optional editor labels keyed by adapter-root id'),
505
+ groups: z
506
+ .array(
507
+ z
508
+ .object({
509
+ id: z.string().describe('Unique editor-only group id'),
510
+ label: z.string().describe('Editor-only group label'),
511
+ roots: z
512
+ .array(z.string())
513
+ .min(1)
514
+ .describe('Adapter-root ids shown under this editor-only group'),
515
+ })
516
+ .strict(),
517
+ )
518
+ .optional()
519
+ .describe('Optional editor-only hierarchy groups'),
520
+ })
521
+ .strict()
522
+ .optional()
523
+ .describe('Editor projection for native adapter roots'),
524
+ })
525
+ .strict()
526
+ .optional()
527
+ .describe('Editor-only authoring metadata; never runtime ownership'),
528
+ configurations: z
529
+ .array(ConfigurationEnvelopeSchema)
530
+ .optional()
531
+ .describe(
532
+ "The project's run configurations — its ENTRYPOINTS beyond the host mount (`play`): each an " +
533
+ 'id and a registered kind (`process`, `compound`, and whatever a capability registers) with ' +
534
+ "that kind's own fields. The editor's transport and a harness start the same declaration.",
535
+ ),
536
+ resolution: z
537
+ .object({
538
+ width: z.number().describe('Canvas width in pixels'),
539
+ height: z.number().describe('Canvas height in pixels'),
540
+ })
541
+ .strict()
542
+ .optional()
543
+ .describe('Canvas resolution'),
544
+ rendering: z
545
+ .object({
546
+ antialias: z
547
+ .boolean()
548
+ .describe(
549
+ 'Whether every three root is built with a multisampled drawing buffer. This is the ' +
550
+ 'ONE render property a world cannot declare for itself (adapter/' +
551
+ 'renderer-config.ts): a WebGL context fixes its sample count at CREATION from this ' +
552
+ 'boolean, long before a world mounts, so it belongs to the PROJECT. The runtime ' +
553
+ 'reader is mount-manifest.ts, which threads it into createHostRenderer for each ' +
554
+ 'three root. WebGL exposes no sample COUNT — the implementation picks one (4x on ' +
555
+ 'every desktop browser measured), so a source engine that authored 8x/16x gets ' +
556
+ 'multisampling but not its exact count.',
557
+ ),
558
+ })
559
+ .strict()
560
+ .optional()
561
+ .describe(
562
+ 'Construction-time renderer properties, which no world can declare after the fact. ' +
563
+ 'Everything a world CAN declare (tone mapping, output colour space, clear colour, ' +
564
+ "shadow filter) lives on the world's own renderer config instead.",
565
+ ),
566
+ debug: z
567
+ .object({
568
+ allowInProduction: z
569
+ .boolean()
570
+ .describe(
571
+ 'Allow the ?volter-debug=1 introspection bridge and debug-command invocation in ' +
572
+ 'production builds. Default false: the bridge only installs in dev builds. The ' +
573
+ 'runtime reader is the debug-bridge installer (D18).',
574
+ ),
575
+ })
576
+ .strict()
577
+ .optional()
578
+ .describe(
579
+ 'Debug-bridge production gating (D18) — governs whether ?volter-debug=1 installs ' +
580
+ 'window.__volter outside dev builds.',
581
+ ),
582
+ determinism: z
583
+ .object({
584
+ seededRandom: z
585
+ .boolean()
586
+ .describe(
587
+ 'Declares that ALL gameplay RNG in this project flows through ctx.random (the ' +
588
+ 'named-stream seeded PRNG) rather than raw ' +
589
+ 'Math.random/Date.now/performance.now. Three runtime enforcers key off this flag: ' +
590
+ 'the boot-time seeding reader (mount-manifest.ts seeds ctx.random from ' +
591
+ 'defaultSeed/?volter-seed=/explicit config, in that precedence, only when true), the ' +
592
+ 'gameplay-rng-ban burn-down scan (test/gameplay-rng-ban.test.ts — lints this ' +
593
+ "project's src/ for raw RNG/wall-clock calls), and the dev-mode Math.random phase " +
594
+ 'trap (runtime/gameplay-rng-trap.ts — warns once per call site during a gameplay ' +
595
+ 'frame, never throws). Default false: an undeclared project gets no determinism ' +
596
+ 'contract at all (that IS the opt-out — no dead field).',
597
+ ),
598
+ defaultSeed: z
599
+ .number()
600
+ .int()
601
+ .optional()
602
+ .describe(
603
+ 'The seed ctx.random boots from when seededRandom is true, unless overridden by ' +
604
+ '?volter-seed=<int> (the query param always wins over this manifest default) or an ' +
605
+ 'even higher-precedence explicit config value a host passes directly. Optional — ' +
606
+ "omit to fall back to the runtime's own fixed default seed.",
607
+ ),
608
+ })
609
+ .strict()
610
+ .optional()
611
+ .describe(
612
+ 'Seeded-RNG determinism contract (D15) — governs ' +
613
+ 'whether ctx.random is boot-seeded from a reproducible seed and whether the ' +
614
+ 'gameplay-rng-ban scan / dev-mode Math.random phase trap are active for this project.',
615
+ ),
616
+ budget: z
617
+ .object({
618
+ frameCpuMsP95: z
619
+ .number()
620
+ .positive()
621
+ .optional()
622
+ .describe(
623
+ 'Maximum allowed p95 of per-frame CPU time (ms) across the measured frames of a ' +
624
+ '`volter perf` seeded headless run. CPU-side only: headless runs rasterize under ' +
625
+ 'SwiftShader, so this is main-thread frame cost, NOT real GPU frame time. ' +
626
+ 'Machine-dependent — prefer the baseline-diff tolerance band for regression ' +
627
+ 'gating and use this as a coarse absolute ceiling.',
628
+ ),
629
+ physicsMsP95: z
630
+ .number()
631
+ .positive()
632
+ .optional()
633
+ .describe(
634
+ "Maximum allowed p95 of the profiler's `physics` phase time (ms) per fixed-step " +
635
+ 'frame in a `volter perf` run. Same CPU-timing caveats as frameCpuMsP95.',
636
+ ),
637
+ maxEntities: z
638
+ .number()
639
+ .int()
640
+ .nonnegative()
641
+ .optional()
642
+ .describe(
643
+ 'Maximum allowed entity-tagged scene-graph nodes (nodes carrying ' +
644
+ "userData.entityId) summed across the game's three/canvas worlds at the end " +
645
+ 'of a `volter perf` run. Exact and deterministic under a seed — no tolerance band.',
646
+ ),
647
+ maxDrawCalls: z
648
+ .number()
649
+ .int()
650
+ .positive()
651
+ .optional()
652
+ .describe(
653
+ 'Maximum allowed renderer draw calls in any measured frame of a `volter perf` run ' +
654
+ "(the profiler's per-frame render.drawCalls, from renderer.info). Exact and " +
655
+ 'deterministic under a seed.',
656
+ ),
657
+ maxTriangles: z
658
+ .number()
659
+ .int()
660
+ .positive()
661
+ .optional()
662
+ .describe(
663
+ 'Maximum allowed rendered triangles in any measured frame of a `volter perf` run ' +
664
+ "(the profiler's per-frame render.triangles, from renderer.info). Exact and " +
665
+ 'deterministic under a seed.',
666
+ ),
667
+ })
668
+ .strict()
669
+ .refine((budget) => Object.values(budget).some((value) => value !== undefined), {
670
+ message:
671
+ 'Game manifest: `budget` must set at least one metric (frameCpuMsP95, physicsMsP95, ' +
672
+ 'maxEntities, maxDrawCalls, maxTriangles) — an empty budget block gates nothing ' +
673
+ '(the no-dead-fields policy).',
674
+ })
675
+ .optional()
676
+ .describe(
677
+ 'Performance budget (W3d, F11 perf regression gates) asserted by ' +
678
+ '`volter perf --assert-budget`: a seeded, fixed-step headless run samples the engine ' +
679
+ 'profiler and fails (nonzero exit) when any metric here is exceeded. Only ' +
680
+ 'headlessly-measurable metrics are budgetable; real GPU frame time is not (SwiftShader).',
681
+ ),
682
+ learn: LearnMetadataSchema.optional().describe(
683
+ 'Optional learner-facing metadata (FT-5) — set on shipped example projects so the ' +
684
+ 'gallery/wizard/Learn-catalog registries can be generated from the manifest itself',
685
+ ),
686
+ })
687
+ .strict()
688
+ .describe('Game manifest (volter.project.json) — the complete v2 project configuration');
689
+
690
+ export type GameManifest = z.infer<typeof GameManifestSchema>;