@vgai/engine 0.2.0 → 0.4.0-canary.20260715.0

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 (123) hide show
  1. package/README.md +3 -1
  2. package/package.json +24 -4
  3. package/schemas/engine-api.json +124 -0
  4. package/schemas/engine-api.md +53 -0
  5. package/schemas/engine-capabilities.json +124 -0
  6. package/schemas/inputmap.schema.json +314 -0
  7. package/schemas/mat.schema.json +286 -0
  8. package/schemas/prefab.schema.json +10148 -0
  9. package/schemas/scn2d.schema.json +475 -0
  10. package/schemas/vgai-game.schema.json +383 -0
  11. package/schemas/vscn.schema.json +11007 -0
  12. package/src/adapter/{world-kind.ts → adapter-surface.ts} +6 -6
  13. package/src/adapter/authoring.ts +77 -0
  14. package/src/adapter/first-party-systems.ts +23 -34
  15. package/src/adapter/game-adapter.ts +8 -8
  16. package/src/adapter/host-context.ts +2 -4
  17. package/src/adapter/index.ts +4 -4
  18. package/src/adapter/system-adapter.ts +88 -22
  19. package/src/adapter/vgai-scene-game-adapter.ts +244 -194
  20. package/src/animation/anim-graph-types.ts +12 -43
  21. package/src/animation/animation-clock.ts +479 -0
  22. package/src/animation/camera-ownership.ts +467 -0
  23. package/src/animation/cinematic-cues.ts +451 -0
  24. package/src/animation/clip-map.ts +41 -0
  25. package/src/animation/gsap-registration.ts +184 -0
  26. package/src/animation/theatre-clock-binding.ts +111 -0
  27. package/src/animation/theatre-director.ts +347 -0
  28. package/src/animation/theatre-object-binding.ts +661 -0
  29. package/src/animation/xstate-animation-binding.ts +436 -0
  30. package/src/animation/xstate-animation-meta.ts +319 -0
  31. package/src/audio/index.ts +39 -7
  32. package/src/audio/tone-clock-binding.ts +98 -0
  33. package/src/audio/tone-context.ts +129 -0
  34. package/src/audio/tone-offline-render.ts +167 -0
  35. package/src/audio/wav-encode.ts +119 -0
  36. package/src/character/cloth-sim.ts +533 -0
  37. package/src/character/spring-chain.ts +307 -0
  38. package/src/core/game-loop.ts +57 -2
  39. package/src/core/seeded-random.ts +161 -0
  40. package/src/core/system-runner.ts +20 -3
  41. package/src/core/types.ts +50 -0
  42. package/src/data/data-asset.ts +167 -0
  43. package/src/data/data-check-core.ts +242 -0
  44. package/src/data/data-ref.ts +145 -0
  45. package/src/data/vite-plugin-data.ts +290 -0
  46. package/src/dev/performance-profiler.ts +213 -0
  47. package/src/dev/webgl-gpu-timer.ts +53 -0
  48. package/src/ecs/component-manager.ts +45 -12
  49. package/src/ecs/game-component.ts +95 -11
  50. package/src/humanoid/bake.operation.ts +326 -0
  51. package/src/humanoid/body.ts +663 -0
  52. package/src/humanoid/clips.ts +149 -0
  53. package/src/humanoid/compose.ts +209 -0
  54. package/src/humanoid/generate.ts +189 -0
  55. package/src/humanoid/index.ts +36 -0
  56. package/src/humanoid/schema.ts +108 -0
  57. package/src/humanoid/skeleton.ts +345 -0
  58. package/src/index.ts +48 -0
  59. package/src/input/input-manager.ts +1886 -33
  60. package/src/input/input-types.ts +158 -3
  61. package/src/input/prompt-labels.ts +122 -0
  62. package/src/input/rebind-controller.ts +105 -0
  63. package/src/input/schema.ts +206 -52
  64. package/src/manifest/index.ts +5 -5
  65. package/src/manifest/load.ts +125 -72
  66. package/src/manifest/schema.ts +362 -255
  67. package/src/react/game-state.tsx +135 -32
  68. package/src/react/root-adapter.tsx +49 -0
  69. package/src/react/unmanaged-root-detector.ts +66 -0
  70. package/src/react/use-data.ts +124 -0
  71. package/src/react/use-selection.tsx +135 -0
  72. package/src/runtime/create-runtime.ts +112 -273
  73. package/src/runtime/debug-bridge.ts +483 -0
  74. package/src/runtime/debug-registry.ts +856 -0
  75. package/src/runtime/game.ts +342 -93
  76. package/src/runtime/gameplay-rng-trap.ts +134 -0
  77. package/src/runtime/input-router.ts +7 -7
  78. package/src/runtime/mount-game.ts +40 -38
  79. package/src/runtime/mount-manifest.ts +169 -37
  80. package/src/runtime/render-audio-control.ts +168 -0
  81. package/src/runtime/render-control.ts +522 -0
  82. package/src/runtime/render-seed.ts +79 -0
  83. package/src/runtime/state-bridge.ts +24 -10
  84. package/src/runtime/types.ts +110 -33
  85. package/src/scene/asset-loaders.ts +10 -36
  86. package/src/scene/asset-paths.ts +0 -2
  87. package/src/scene/asset-ref-check.ts +248 -0
  88. package/src/scene/asset-registry.ts +22 -0
  89. package/src/scene/component-registry.ts +14 -3
  90. package/src/scene/defaults.ts +1 -0
  91. package/src/scene/light-camera-factory.ts +11 -3
  92. package/src/scene/parse.ts +133 -0
  93. package/src/scene/scene-apply.ts +55 -4
  94. package/src/scene/scene-loader.ts +91 -123
  95. package/src/scene/scene-types.ts +0 -1
  96. package/src/scene/schema/animation.ts +30 -79
  97. package/src/scene/schema/entity.ts +20 -0
  98. package/src/scene/schema/index.ts +2 -46
  99. package/src/scene/schema/light.ts +16 -1
  100. package/src/scene/schema/material.ts +96 -91
  101. package/src/scene/schema/scene-file.ts +1 -7
  102. package/src/scene/user-data.ts +22 -10
  103. package/src/setup/setup-renderer.ts +10 -3
  104. package/src/tools/define-tool.ts +191 -0
  105. package/src/world2d/authoring-2d.ts +17 -1
  106. package/src/world2d/collision-2d.ts +1 -1
  107. package/src/world2d/pixi-game-adapter.ts +19 -17
  108. package/src/world2d/scene2d-loader.ts +1 -0
  109. package/src/world2d/types.ts +8 -2
  110. package/src/animation/anim-graph.ts +0 -406
  111. package/src/animation/anim-system.ts +0 -28
  112. package/src/animation/property-track.ts +0 -178
  113. package/src/animation/schema.ts +0 -204
  114. package/src/audio/ambient.ts +0 -300
  115. package/src/audio/impacts.ts +0 -212
  116. package/src/audio/movement.ts +0 -140
  117. package/src/audio/musical.ts +0 -200
  118. package/src/audio/ui-sounds.ts +0 -171
  119. package/src/audio/vehicle.ts +0 -235
  120. package/src/audio/weapons.ts +0 -152
  121. package/src/runtime/scene-ui-bridge.ts +0 -86
  122. package/src/runtime/scene-ui-data.ts +0 -119
  123. package/src/scene/schema/ui.ts +0 -602
@@ -1,6 +1,6 @@
1
1
  // T3.1 slice 1 — the game manifest schema (`vgai.game.json`).
2
2
  //
3
- // This is the serialized form of the Game's world list
3
+ // This is the serialized form of the Game's root list
4
4
  // (docs/GAME-ROOT-DESIGN.md §1, D6) — see docs/GAME-MANIFEST-DESIGN.md §3 for
5
5
  // the adjudicated field list this file implements exactly. `load.ts` is this
6
6
  // schema's runtime reader (T4.1 policy): it parses via this schema, then
@@ -11,20 +11,24 @@
11
11
  // Naming: this file follows the repo's `XxxSchema` (Zod schema) / `Xxx`
12
12
  // (inferred type) convention used throughout `scene/schema/` — the design
13
13
  // doc's §3 sketch names the bare consts (`Tier`, `IngestStrategy`,
14
- // `WorldEntry`) without the `Schema` suffix; that sketch is explicitly a
14
+ // `AdapterRoot`) without the `Schema` suffix; that sketch is explicitly a
15
15
  // "Zod sketch" the doc tells implementers to adapt to real code style
16
16
  // ("mirror the style in scene/schema/scene-file.ts" per CLAUDE.md), so the
17
17
  // schema consts here are suffixed and the bare names are reserved for the
18
18
  // inferred types, matching every other schema file in the repo.
19
19
  //
20
- // World `kind` intentionally mirrors `WorldKind` in
20
+ // Adapter surface values mirror `AdapterSurface` in
21
21
  // `packages/engine/src/runtime/game.ts` (`'threejs' | 'pixijs' | 'react'`)
22
- // by value, not by import this schema module has no runtime dependency
23
- // beyond `zod` (kept parseable/bundlable in any context), matching the
24
- // `scene/schema/` precedent.
22
+ // by value, not by import. The adapter remains the sole root discriminator.
25
23
 
26
24
  import { z } from 'zod';
27
25
 
26
+ /**
27
+ * Current on-disk `vgai.game.json` format. Exported so editor/server
28
+ * compatibility checks do not duplicate the schema's literal value.
29
+ */
30
+ export const GAME_MANIFEST_VERSION = 2 as const;
31
+
28
32
  // ---------------------------------------------------------------------------
29
33
  // Ingest capture strategy (world2d today; the general vocabulary D6/§4 names)
30
34
  // ---------------------------------------------------------------------------
@@ -42,266 +46,274 @@ export type IngestStrategy = z.infer<typeof IngestStrategySchema>;
42
46
  // ---------------------------------------------------------------------------
43
47
 
44
48
  export const TierSchema = z.enum([
45
- 'first-party', // vgai-native world: full editor, full loop control
49
+ 'first-party', // vgai-native root: full editor, full loop control
46
50
  'shared', // = IngestStrategy 'shared' rung
47
51
  'deduped', // = IngestStrategy 'deduped' rung
48
52
  'iframe-reachable', // = IngestStrategy 'iframe-reachable' rung
49
53
  'opaque-embed', // = IngestStrategy 'opaque-embed' rung
50
- 'unsupported', // this world does not function in this delivery context
54
+ 'unsupported', // this root does not function in this delivery context
51
55
  ]);
52
56
  export type Tier = z.infer<typeof TierSchema>;
53
57
 
54
58
  // ---------------------------------------------------------------------------
55
- // World adapter identity "make invalid states unrepresentable" (§2): a
56
- // world names its `kind` once, and its `adapter` is either resolved BY kind
57
- // (D6's default-*), a custom module, or an ingest strategy. There is no field
58
- // that lets a world spell out a kind/adapter combination that cannot exist.
59
+ // Root adapter identity. The adapter is the sole discriminator: a root never
60
+ // repeats its rendering substrate in a sibling `kind` field. Module and ingest
61
+ // adapters carry the surface their adapter contract exposes because the host
62
+ // must allocate a layer before the module has mounted.
59
63
  // ---------------------------------------------------------------------------
60
64
 
61
- export const WorldAdapterSchema = z
65
+ export const RootAdapterSchema = z
62
66
  .union([
63
- z.literal('default'),
64
- z.object({
65
- module: z
66
- .string()
67
- .describe('Game-folder-relative path to a custom adapter module (trust boundary)'),
68
- }),
69
- z.object({
70
- ingest: z
71
- .object({
72
- strategy: IngestStrategySchema.describe(
73
- 'Capture mechanics rung this ingested world uses (§4) — also the world`s natural local capability tier',
74
- ),
75
- entryHtml: z
76
- .string()
77
- .optional()
78
- .describe(
79
- 'Entry HTML path, project-relative. WIRED for opaque-embed (D-W2, ' +
80
- 'docs/WAVE4-FTUE-HARDENING-DESIGN.md): the file`s bytes are read verbatim through ' +
81
- 'the project-static route and hosted in a sandboxed iframe (embed-only, no scene ' +
82
- 'introspection), for both pixijs and threejs worlds. Legal but NOT wired to a ' +
83
- 'mount for iframe-reachable (a named error at resolve names this — use `bundleUrl` ' +
84
- 'instead, the wired reachable route).',
85
- ),
86
- assets: z
87
- .record(z.string(), z.string())
88
- .optional()
89
- .describe(
90
- 'Path-substring -> served-URL rewrites for this ingested game (IngestGame.assets today)',
91
- ),
92
- domStubs: z
93
- .array(z.string())
94
- .optional()
95
- .describe('DOM API stub ids this ingested game requires to run headlessly/in-realm'),
96
- captureTimeoutMs: z
97
- .number()
98
- .optional()
99
- .describe('Max time to wait for in-realm/cross-realm capture before failing loudly'),
100
- // Track P (docs/PIXI-INGEST-LANDING-DESIGN.md §2) — the six
101
- // iframe-reachable-multi mount fields (`ingest-iframe-2d.ts`'s
102
- // `IframeReachableMultiOpts`), legal ONLY when `strategy` is
103
- // 'iframe-reachable' (enforced by the `.superRefine` below).
104
- bundleUrl: z
105
- .string()
106
- .optional()
107
- .describe(
108
- 'Absolute URL of the externalized built game bundle — the WIRED iframe-reachable ' +
109
- 'mount route (iframe-reachable only), for BOTH pixijs (Track P, ' +
110
- "ingest-iframe-2d.ts's mountIngestGame2DIframeReachableMulti) and threejs (D-W1, " +
111
- 'docs/WAVE4-FTUE-HARDENING-DESIGN.md, ' +
112
- "ingest-iframe-reachable-adapter.ts's mountIngestGameIframeReachableBundle). " +
113
- 'PRECONDITION: the bundle must externalize its world`s own runtime as a bare, ' +
114
- 'un-rewritten import (`pixi.js` for pixijs, `three` for threejs — `vgai bundle` ' +
115
- 'does this for you) so it resolves through the iframe importmap to the HOST ' +
116
- 'instance the capture trap is installed on; a bundle with an inlined runtime is ' +
117
- 'unreachable and degrades to the embed-only floor. Exactly one of bundleUrl/' +
118
- 'entryHtml is required when strategy is iframe-reachable.',
119
- ),
120
- baseHref: z
121
- .string()
122
- .optional()
123
- .describe(
124
- "Iframe <base href> so the game's relative asset URLs resolve (iframe-reachable only).",
125
- ),
126
- assetBaseUrl: z
127
- .string()
128
- .optional()
129
- .describe(
130
- "Host-side basePath forced onto the trapped runtime's own asset resolution — " +
131
- "pixi's `Assets.init` for a pixijs world, three's " +
132
- '`DefaultLoadingManager.setURLModifier` for a threejs world (D-W1) — absolute, or ' +
133
- "root-relative (resolved against the editor page's own origin at mount time, " +
134
- 'ingest-mode.ts, so a static manifest never has to know the dev-server port) ' +
135
- '(iframe-reachable only — a document.write iframe keeps the parent window.location).',
136
- ),
137
- extraDeps: z
138
- .array(z.string())
139
- .optional()
140
- .describe(
141
- "Bare specifiers of this world's externalized deps beyond its own runtime " +
142
- "(pixi.js/three — e.g. '@pixi/sound', 'gsap'). Resolved by the editor's " +
143
- 'host-namespace registry into module namespaces when known (D-P1 — the manifest ' +
144
- 'carries data, not code); an unregistered specifier falls back to the open ' +
145
- "project's own node_modules (D-Z3, docs/WAVE3-ADAPTER-PLUMBING-DESIGN.md), served " +
146
- 'as a verbatim iframe importmap URL. Throws a named error if neither resolves it ' +
147
- '(iframe-reachable only).',
148
- ),
149
- pixiModuleUrl: z
150
- .string()
151
- .optional()
152
- .describe(
153
- 'PIXI-ONLY: standalone matching-major pixi ESM URL to trap instead of the host ' +
154
- "pixi, for version-skewed games (e.g. a v6 game on a v8 host); this field's " +
155
- 'PRESENCE is how the skew is expressed (D-P2 — no separate strategy/tier for ' +
156
- 'version skew) (iframe-reachable only). Declaring this on a `kind: "threejs"` ' +
157
- 'world is schema-legal (this field is kind-agnostic here) but throws a NAMED ' +
158
- 'not-supported error at resolve — three has no version-skew mount this wave (D-W7, ' +
159
- 'docs/WAVE4-FTUE-HARDENING-DESIGN.md: a hypothetical `threeModuleUrl` is explicitly ' +
160
- 'parked, no consumer exists).',
161
- ),
162
- bodyHtml: z
163
- .string()
164
- .optional()
165
- .describe(
166
- 'HTML injected into the iframe body before the game script boots (e.g. a ' +
167
- '`<div id="game-root">` the game expects to find) (iframe-reachable only).',
67
+ z.enum(['threejs', 'pixijs', 'react']),
68
+ z
69
+ .object({
70
+ module: z
71
+ .string()
72
+ .describe('Game-folder-relative path to a custom adapter module (trust boundary)'),
73
+ surface: z
74
+ .enum(['threejs', 'pixijs', 'react'])
75
+ .describe('Native surface exposed by this custom adapter contract'),
76
+ })
77
+ .strict(),
78
+ z
79
+ .object({
80
+ surface: z
81
+ .enum(['threejs', 'pixijs', 'react'])
82
+ .describe('Native surface captured from the unmodified game'),
83
+ ingest: z
84
+ .object({
85
+ strategy: IngestStrategySchema.describe(
86
+ 'Capture mechanics rung this ingested root uses (§4) also the root`s natural local capability tier',
168
87
  ),
169
- })
170
- .describe(
171
- 'Ingest adapter configuration for an unmodified game (a repo-vendored game or your ' +
172
- 'own external folder)',
173
- )
174
- .superRefine((ingest, ctx) => {
175
- // Track P (docs/PIXI-INGEST-LANDING-DESIGN.md §2/D-P4): the six
176
- // iframe-reachable-multi fields only make sense for the
177
- // iframe-reachable-multi mountreject them under any other
178
- // strategy (mirrors ui.ts's dead-field-under-wrong-variant pattern).
179
- const iframeOnlyFields: ReadonlyArray<
180
- readonly [
181
- key:
182
- | 'bundleUrl'
183
- | 'baseHref'
184
- | 'assetBaseUrl'
185
- | 'extraDeps'
186
- | 'pixiModuleUrl'
187
- | 'bodyHtml',
188
- present: boolean,
189
- ]
190
- > = [
191
- ['bundleUrl', ingest.bundleUrl !== undefined],
192
- ['baseHref', ingest.baseHref !== undefined],
193
- ['assetBaseUrl', ingest.assetBaseUrl !== undefined],
194
- ['extraDeps', ingest.extraDeps !== undefined],
195
- ['pixiModuleUrl', ingest.pixiModuleUrl !== undefined],
196
- ['bodyHtml', ingest.bodyHtml !== undefined],
197
- ];
198
- if (ingest.strategy !== 'iframe-reachable') {
199
- for (const [key, present] of iframeOnlyFields) {
200
- if (present) {
201
- ctx.addIssue({
202
- code: z.ZodIssueCode.custom,
203
- message:
204
- `\`${key}\` is only legal when strategy is 'iframe-reachable' (got ` +
205
- `"${ingest.strategy}") bundleUrl/baseHref/assetBaseUrl/extraDeps/` +
206
- "pixiModuleUrl/bodyHtml are the iframe-reachable-multi mount's options " +
207
- '(pixijs: ingest-iframe-2d.ts IframeReachableMultiOpts; threejs, D-W1: ' +
208
- 'ingest-iframe-reachable-adapter.ts IframeReachableBundleOpts) and have no ' +
209
- 'meaning under any other strategy.',
210
- path: [key],
211
- });
88
+ entryHtml: z
89
+ .string()
90
+ .optional()
91
+ .describe(
92
+ 'Entry HTML path, project-relative. WIRED for opaque-embed (D-W2, ' +
93
+ 'docs/WAVE4-FTUE-HARDENING-DESIGN.md): the file`s bytes are read verbatim through ' +
94
+ 'the project-static route and hosted in a sandboxed iframe (embed-only, no scene ' +
95
+ 'introspection), for both pixijs and threejs roots. Legal but NOT wired to a ' +
96
+ 'mount for iframe-reachable (a named error at resolve names this use `bundleUrl` ' +
97
+ 'instead, the wired reachable route).',
98
+ ),
99
+ assets: z
100
+ .record(z.string(), z.string())
101
+ .optional()
102
+ .describe(
103
+ 'Path-substring -> served-URL rewrites for this ingested game (IngestGame.assets today)',
104
+ ),
105
+ domStubs: z
106
+ .array(z.string())
107
+ .optional()
108
+ .describe('DOM API stub ids this ingested game requires to run headlessly/in-realm'),
109
+ captureTimeoutMs: z
110
+ .number()
111
+ .optional()
112
+ .describe('Max time to wait for in-realm/cross-realm capture before failing loudly'),
113
+ // Track P (docs/PIXI-INGEST-LANDING-DESIGN.md §2) — the six
114
+ // iframe-reachable-multi mount fields (`ingest-iframe-2d.ts`'s
115
+ // `IframeReachableMultiOpts`), legal ONLY when `strategy` is
116
+ // 'iframe-reachable' (enforced by the `.superRefine` below).
117
+ bundleUrl: z
118
+ .string()
119
+ .optional()
120
+ .describe(
121
+ 'Absolute URL of the externalized built game bundle — the WIRED iframe-reachable ' +
122
+ 'mount route (iframe-reachable only), for BOTH pixijs (Track P, ' +
123
+ "ingest-iframe-2d.ts's mountIngestGame2DIframeReachableMulti) and threejs (D-W1, " +
124
+ 'docs/WAVE4-FTUE-HARDENING-DESIGN.md, ' +
125
+ "ingest-iframe-reachable-adapter.ts's mountIngestGameIframeReachableBundle). " +
126
+ 'PRECONDITION: the bundle must externalize its root`s own runtime as a bare, ' +
127
+ 'un-rewritten import (`pixi.js` for pixijs, `three` for threejs — `vgai bundle` ' +
128
+ 'does this for you) so it resolves through the iframe importmap to the HOST ' +
129
+ 'instance the capture trap is installed on; a bundle with an inlined runtime is ' +
130
+ 'unreachable and degrades to the embed-only floor. Exactly one of bundleUrl/' +
131
+ 'entryHtml is required when strategy is iframe-reachable.',
132
+ ),
133
+ baseHref: z
134
+ .string()
135
+ .optional()
136
+ .describe(
137
+ "Iframe <base href> so the game's relative asset URLs resolve (iframe-reachable only).",
138
+ ),
139
+ assetBaseUrl: z
140
+ .string()
141
+ .optional()
142
+ .describe(
143
+ "Host-side basePath forced onto the trapped runtime's own asset resolution — " +
144
+ "pixi's `Assets.init` for a pixijs root, three's " +
145
+ '`DefaultLoadingManager.setURLModifier` for a threejs root (D-W1) — absolute, or ' +
146
+ "root-relative (resolved against the editor page's own origin at mount time, " +
147
+ 'ingest-mode.ts, so a static manifest never has to know the dev-server port) ' +
148
+ '(iframe-reachable only — a document.write iframe keeps the parent window.location).',
149
+ ),
150
+ extraDeps: z
151
+ .array(z.string())
152
+ .optional()
153
+ .describe(
154
+ "Bare specifiers of this root's externalized deps beyond its own runtime " +
155
+ "(pixi.js/three — e.g. '@pixi/sound', 'gsap'). Resolved by the editor's " +
156
+ 'host-namespace registry into module namespaces when known (D-P1 — the manifest ' +
157
+ 'carries data, not code); an unregistered specifier falls back to the open ' +
158
+ "project's own node_modules (D-Z3, docs/WAVE3-ADAPTER-PLUMBING-DESIGN.md), served " +
159
+ 'as a verbatim iframe importmap URL. Throws a named error if neither resolves it ' +
160
+ '(iframe-reachable only).',
161
+ ),
162
+ pixiModuleUrl: z
163
+ .string()
164
+ .optional()
165
+ .describe(
166
+ 'PIXI-ONLY: standalone matching-major pixi ESM URL to trap instead of the host ' +
167
+ "pixi, for version-skewed games (e.g. a v6 game on a v8 host); this field's " +
168
+ 'PRESENCE is how the skew is expressed (D-P2 — no separate strategy/tier for ' +
169
+ 'version skew) (iframe-reachable only). Declaring this on a `kind: "threejs"` ' +
170
+ 'root is schema-legal (this field is kind-agnostic here) but throws a NAMED ' +
171
+ 'not-supported error at resolve — three has no version-skew mount this wave (D-W7, ' +
172
+ 'docs/WAVE4-FTUE-HARDENING-DESIGN.md: a hypothetical `threeModuleUrl` is explicitly ' +
173
+ 'parked, no consumer exists).',
174
+ ),
175
+ bodyHtml: z
176
+ .string()
177
+ .optional()
178
+ .describe(
179
+ 'HTML injected into the iframe body before the game script boots (e.g. a ' +
180
+ '`<div id="game-root">` the game expects to find) (iframe-reachable only).',
181
+ ),
182
+ })
183
+ .describe(
184
+ 'Ingest adapter configuration for an unmodified game (a repo-vendored game or your ' +
185
+ 'own external folder)',
186
+ )
187
+ .superRefine((ingest, ctx) => {
188
+ // Track P (docs/PIXI-INGEST-LANDING-DESIGN.md §2/D-P4): the six
189
+ // iframe-reachable-multi fields only make sense for the
190
+ // iframe-reachable-multi mount — reject them under any other
191
+ // strategy (mirrors ui.ts's dead-field-under-wrong-variant pattern).
192
+ const iframeOnlyFields: ReadonlyArray<
193
+ readonly [
194
+ key:
195
+ | 'bundleUrl'
196
+ | 'baseHref'
197
+ | 'assetBaseUrl'
198
+ | 'extraDeps'
199
+ | 'pixiModuleUrl'
200
+ | 'bodyHtml',
201
+ present: boolean,
202
+ ]
203
+ > = [
204
+ ['bundleUrl', ingest.bundleUrl !== undefined],
205
+ ['baseHref', ingest.baseHref !== undefined],
206
+ ['assetBaseUrl', ingest.assetBaseUrl !== undefined],
207
+ ['extraDeps', ingest.extraDeps !== undefined],
208
+ ['pixiModuleUrl', ingest.pixiModuleUrl !== undefined],
209
+ ['bodyHtml', ingest.bodyHtml !== undefined],
210
+ ];
211
+ if (ingest.strategy !== 'iframe-reachable') {
212
+ for (const [key, present] of iframeOnlyFields) {
213
+ if (present) {
214
+ ctx.addIssue({
215
+ code: z.ZodIssueCode.custom,
216
+ message:
217
+ `\`${key}\` is only legal when strategy is 'iframe-reachable' (got ` +
218
+ `"${ingest.strategy}") — bundleUrl/baseHref/assetBaseUrl/extraDeps/` +
219
+ "pixiModuleUrl/bodyHtml are the iframe-reachable-multi mount's options " +
220
+ '(pixijs: ingest-iframe-2d.ts IframeReachableMultiOpts; threejs, D-W1: ' +
221
+ 'ingest-iframe-reachable-adapter.ts IframeReachableBundleOpts) and have no ' +
222
+ 'meaning under any other strategy.',
223
+ path: [key],
224
+ });
225
+ }
212
226
  }
227
+ return;
213
228
  }
214
- return;
215
- }
216
- const hasBundleUrl = ingest.bundleUrl !== undefined;
217
- const hasEntryHtml = ingest.entryHtml !== undefined;
218
- if (hasBundleUrl === hasEntryHtml) {
219
- ctx.addIssue({
220
- code: z.ZodIssueCode.custom,
221
- message:
222
- "strategy 'iframe-reachable' requires EXACTLY ONE of `bundleUrl` (externalized " +
223
- 'multi-file bundle, the iframe-reachable-multi mount) or `entryHtml` (single-' +
224
- `file/legacy iframe entry) — ${hasBundleUrl ? 'both are present' : 'neither is present'}.`,
225
- path: hasBundleUrl ? ['bundleUrl'] : ['entryHtml'],
226
- });
227
- }
228
- }),
229
- }),
229
+ const hasBundleUrl = ingest.bundleUrl !== undefined;
230
+ const hasEntryHtml = ingest.entryHtml !== undefined;
231
+ if (hasBundleUrl === hasEntryHtml) {
232
+ ctx.addIssue({
233
+ code: z.ZodIssueCode.custom,
234
+ message:
235
+ "strategy 'iframe-reachable' requires EXACTLY ONE of `bundleUrl` (externalized " +
236
+ 'multi-file bundle, the iframe-reachable-multi mount) or `entryHtml` (single-' +
237
+ `file/legacy iframe entry) ${hasBundleUrl ? 'both are present' : 'neither is present'}.`,
238
+ path: hasBundleUrl ? ['bundleUrl'] : ['entryHtml'],
239
+ });
240
+ }
241
+ }),
242
+ })
243
+ .strict(),
230
244
  ])
231
245
  .describe(
232
- "World adapter identity: 'default' (resolved by world kind to the built-in default " +
233
- 'adapter), a custom { module } adapter, or an { ingest } strategy for an unmodified ' +
234
- 'game (a repo-vendored game or your own external folder)',
246
+ "Root adapter identity: built-in 'threejs'/'pixijs'/'react', a custom " +
247
+ '{ module, surface } adapter, or an { ingest, surface } adapter for an unmodified game',
235
248
  );
236
- export type WorldAdapter = z.infer<typeof WorldAdapterSchema>;
249
+ export type RootAdapter = z.infer<typeof RootAdapterSchema>;
237
250
 
238
251
  // ---------------------------------------------------------------------------
239
- // World entry
252
+ // Root entry
240
253
  // ---------------------------------------------------------------------------
241
254
 
242
- export const WorldEntrySchema = z.object({
243
- id: z
244
- .string()
245
- .describe(
246
- 'Unique id for this world within the manifest (enforced-unique, D-V6 — GameManifestSchema.worlds rejects duplicates)',
247
- ),
248
- kind: z
249
- .enum(['threejs', 'pixijs', 'react'])
250
- .describe('World render substrate kind (mirrors runtime/game.ts WorldKind)'),
251
- description: z
252
- .string()
253
- .optional()
254
- .describe(
255
- "Optional one-line description of this world's game/content (e.g. an ingested game's " +
256
- "blurb — the manifest-native replacement for a registry entry's description field)",
257
- ),
258
- adapter: WorldAdapterSchema.default('default').describe(
259
- "This world's adapter — see WorldAdapterSchema; defaults to 'default' (resolved by kind)",
260
- ),
261
- scene: z
262
- .string()
263
- .optional()
264
- .describe(
265
- '.vscn.json / .scn2d.json scene path (default adapters only — ingest adapters keep their own formats)',
266
- ),
267
- entry: z
268
- .string()
269
- .optional()
270
- .describe(
271
- "Module exporting setup()/adapter (default adapters' code-authored alternative to `scene`)",
272
- ),
273
- zOrder: z
274
- .number()
275
- .int()
276
- .default(0)
277
- .describe('Canvas stacking order (COMPOSITION-DESIGN); ties broken by array order'),
278
- pausable: z
279
- .boolean()
280
- .default(true)
281
- .describe('Whether play-mode pause/step applies to this world'),
282
- loop: z
283
- .enum(['gated', 'self-driven'])
284
- .default('gated')
285
- .describe(
286
- 'gated: host-driven loop (default). self-driven: this world drives its own loop ' +
287
- '(the "composited, unsynchronized" tier) — an axis independent of capability tier',
288
- ),
289
- capabilities: z
290
- .object({
291
- local: TierSchema.optional().describe(
292
- 'Declared capability tier when served via the local CLI (Vite pipeline); omitted -> derived (§4)',
255
+ export const AdapterRootSchema = z
256
+ .object({
257
+ id: z
258
+ .string()
259
+ .describe(
260
+ 'Unique id for this root within the manifest (enforced-unique, D-V6 — GameManifestSchema.roots rejects duplicates)',
293
261
  ),
294
- hosted: TierSchema.optional().describe(
295
- 'Declared capability tier when served hosted (no bundler: esbuild-wasm + externals only); omitted -> derived (§4)',
262
+ description: z
263
+ .string()
264
+ .optional()
265
+ .describe(
266
+ "Optional one-line description of this root's game/content (e.g. an ingested game's " +
267
+ "blurb — the manifest-native replacement for a registry entry's description field)",
296
268
  ),
297
- })
298
- .optional()
299
- .describe(
300
- "Per-delivery-context capability tier declarations (§4). Declaring above the adapter's " +
301
- 'ceiling for that context is an error; declaring at or below it (including `unsupported`) is legal.',
269
+ adapter: RootAdapterSchema.describe(
270
+ "This root's adapter and sole rendering/lifecycle discriminator",
302
271
  ),
303
- });
304
- export type WorldEntry = z.infer<typeof WorldEntrySchema>;
272
+ scene: z
273
+ .string()
274
+ .optional()
275
+ .describe(
276
+ '.vscn.json / .scn2d.json scene path (default adapters only — ingest adapters keep their own formats)',
277
+ ),
278
+ entry: z
279
+ .string()
280
+ .optional()
281
+ .describe(
282
+ "Module exporting setup()/adapter (default adapters' code-authored alternative to `scene`)",
283
+ ),
284
+ zOrder: z
285
+ .number()
286
+ .int()
287
+ .default(0)
288
+ .describe('Canvas stacking order (COMPOSITION-DESIGN); ties broken by array order'),
289
+ pausable: z
290
+ .boolean()
291
+ .default(true)
292
+ .describe('Whether play-mode pause/step applies to this root'),
293
+ loop: z
294
+ .enum(['gated', 'self-driven'])
295
+ .default('gated')
296
+ .describe(
297
+ 'gated: host-driven loop (default). self-driven: this root drives its own loop ' +
298
+ '(the "composited, unsynchronized" tier) — an axis independent of capability tier',
299
+ ),
300
+ capabilities: z
301
+ .object({
302
+ local: TierSchema.optional().describe(
303
+ 'Declared capability tier when served via the local CLI (Vite pipeline); omitted -> derived (§4)',
304
+ ),
305
+ hosted: TierSchema.optional().describe(
306
+ 'Declared capability tier when served hosted (no bundler: esbuild-wasm + externals only); omitted -> derived (§4)',
307
+ ),
308
+ })
309
+ .optional()
310
+ .describe(
311
+ "Per-delivery-context capability tier declarations (§4). Declaring above the adapter's " +
312
+ 'ceiling for that context is an error; declaring at or below it (including `unsupported`) is legal.',
313
+ ),
314
+ })
315
+ .strict();
316
+ export type AdapterRoot = z.infer<typeof AdapterRootSchema>;
305
317
 
306
318
  // ---------------------------------------------------------------------------
307
319
  // Game manifest (vgai.game.json)
@@ -309,8 +321,25 @@ export type WorldEntry = z.infer<typeof WorldEntrySchema>;
309
321
 
310
322
  export const GameManifestSchema = z
311
323
  .object({
312
- manifestVersion: z.literal(1).describe('Manifest file format version (currently always 1)'),
324
+ $schema: z
325
+ .string()
326
+ .optional()
327
+ .describe('Optional JSON Schema URI for editor autocomplete and validation'),
328
+ manifestVersion: z
329
+ .literal(GAME_MANIFEST_VERSION)
330
+ .describe('Manifest file format version (v2 clean roots)'),
313
331
  name: z.string().describe('Game display name'),
332
+ appId: z
333
+ .string()
334
+ .optional()
335
+ .describe('Stable reverse-domain application id used by packaged targets'),
336
+ editorPort: z
337
+ .number()
338
+ .int()
339
+ .min(1024)
340
+ .max(65535)
341
+ .optional()
342
+ .describe('Stable preferred local editor port; launchers may fall forward when occupied'),
314
343
  version: z.string().describe("The game's own version (used for orphan detection)"),
315
344
  engine: z
316
345
  .object({
@@ -322,22 +351,22 @@ export const GameManifestSchema = z
322
351
  ),
323
352
  })
324
353
  .describe('Engine version pin'),
325
- worlds: z
326
- .array(WorldEntrySchema)
354
+ roots: z
355
+ .array(AdapterRootSchema)
327
356
  .min(1)
328
- .superRefine((worlds, ctx) => {
357
+ .superRefine((roots, ctx) => {
329
358
  // D-V6 (docs/WAVE5-MULTIWORLD-INGEST-DESIGN.md §1G debt,
330
- // docs/MULTI-WORLD-DESIGN.md §1G): world ids are described as unique
359
+ // docs/MULTI-WORLD-DESIGN.md §1G): root ids are described as unique
331
360
  // above but that was describe-only at the SCHEMA level — only
332
361
  // load.ts's loader-level cross-field check caught a collision, so
333
362
  // any OTHER consumer of this schema (the generated JSON Schema,
334
363
  // editor tooling) saw duplicate ids as legal. Enforced HERE instead,
335
364
  // naming every colliding id (not just the first found) — overlay
336
- // files are keyed by world id (`.vgai/overlays/<id>.json`), so a
337
- // silently-permitted duplicate would have two worlds silently share
365
+ // files are keyed by root id (`.vgai/overlays/<id>.json`), so a
366
+ // silently-permitted duplicate would have two roots silently share
338
367
  // one overlay.
339
368
  const counts = new Map<string, number>();
340
- for (const world of worlds) counts.set(world.id, (counts.get(world.id) ?? 0) + 1);
369
+ for (const root of roots) counts.set(root.id, (counts.get(root.id) ?? 0) + 1);
341
370
  const duplicates = [...counts.entries()].filter(([, n]) => n > 1).map(([id]) => id);
342
371
  if (duplicates.length === 0) return;
343
372
  const idsText = duplicates.map((id) => `"${id}"`).join(', ');
@@ -345,11 +374,40 @@ export const GameManifestSchema = z
345
374
  code: z.ZodIssueCode.custom,
346
375
  message:
347
376
  duplicates.length === 1
348
- ? `Game manifest: duplicate world id ${idsText} — world ids must be unique across the manifest (enforced-unique).`
349
- : `Game manifest: duplicate world ids: ${idsText} — world ids must be unique across the manifest (enforced-unique).`,
377
+ ? `Game manifest: duplicate root id ${idsText} — root ids must be unique across the manifest (enforced-unique).`
378
+ : `Game manifest: duplicate root ids: ${idsText} — root ids must be unique across the manifest (enforced-unique).`,
350
379
  });
351
380
  })
352
- .describe("The Game's world list — this manifest is its serialized form"),
381
+ .describe(
382
+ "The Game's explicit, non-empty adapter-root composition. There is no implicit root.",
383
+ ),
384
+ authoring: z
385
+ .object({
386
+ hierarchy: z
387
+ .object({
388
+ rootLabels: z
389
+ .record(z.string(), z.string())
390
+ .optional()
391
+ .describe('Optional editor labels keyed by adapter-root id'),
392
+ groups: z
393
+ .array(
394
+ z.object({
395
+ id: z.string().describe('Unique editor-only group id'),
396
+ label: z.string().describe('Editor-only group label'),
397
+ roots: z
398
+ .array(z.string())
399
+ .min(1)
400
+ .describe('Adapter-root ids shown under this editor-only group'),
401
+ }),
402
+ )
403
+ .optional()
404
+ .describe('Optional editor-only hierarchy groups'),
405
+ })
406
+ .optional()
407
+ .describe('Editor projection for native adapter roots'),
408
+ })
409
+ .optional()
410
+ .describe('Editor-only authoring metadata; never runtime ownership'),
353
411
  server: z
354
412
  .object({
355
413
  room: z.string().describe('Colyseus room name'),
@@ -368,8 +426,57 @@ export const GameManifestSchema = z
368
426
  height: z.number().describe('Canvas height in pixels'),
369
427
  })
370
428
  .optional()
371
- .describe("Canvas resolution (subsumed from today's project.json)"),
429
+ .describe('Canvas resolution'),
430
+ debug: z
431
+ .object({
432
+ allowInProduction: z
433
+ .boolean()
434
+ .describe(
435
+ 'Allow the ?vgai-debug=1 introspection bridge and debug-command invocation in ' +
436
+ 'production builds. Default false: the bridge only installs in dev builds. The ' +
437
+ 'runtime reader is the debug-bridge installer (D18).',
438
+ ),
439
+ })
440
+ .optional()
441
+ .describe(
442
+ 'Debug-bridge production gating (D18) — governs whether ?vgai-debug=1 installs ' +
443
+ 'window.__vgai outside dev builds.',
444
+ ),
445
+ determinism: z
446
+ .object({
447
+ seededRandom: z
448
+ .boolean()
449
+ .describe(
450
+ 'Declares that ALL gameplay RNG in this project flows through ctx.random (the ' +
451
+ 'named-stream seeded PRNG, docs/D15-DETERMINISM-DESIGN.md) rather than raw ' +
452
+ 'Math.random/Date.now/performance.now. Three runtime enforcers key off this flag: ' +
453
+ 'the boot-time seeding reader (mount-manifest.ts seeds ctx.random from ' +
454
+ 'defaultSeed/?vgai-seed=/explicit config, in that precedence, only when true), the ' +
455
+ 'gameplay-rng-ban burn-down scan (test/gameplay-rng-ban.test.ts — lints this ' +
456
+ "project's src/ for raw RNG/wall-clock calls), and the dev-mode Math.random phase " +
457
+ 'trap (runtime/gameplay-rng-trap.ts — warns once per call site during a gameplay ' +
458
+ 'frame, never throws). Default false: an undeclared project gets no determinism ' +
459
+ 'contract at all (that IS the opt-out — no dead field, per the T4.1 policy).',
460
+ ),
461
+ defaultSeed: z
462
+ .number()
463
+ .int()
464
+ .optional()
465
+ .describe(
466
+ 'The seed ctx.random boots from when seededRandom is true, unless overridden by ' +
467
+ '?vgai-seed=<int> (the query param always wins over this manifest default) or an ' +
468
+ 'even higher-precedence explicit config value a host passes directly. Optional — ' +
469
+ "omit to fall back to the runtime's own fixed default seed.",
470
+ ),
471
+ })
472
+ .optional()
473
+ .describe(
474
+ 'Seeded-RNG determinism contract (D15, docs/D15-DETERMINISM-DESIGN.md) — governs ' +
475
+ 'whether ctx.random is boot-seeded from a reproducible seed and whether the ' +
476
+ 'gameplay-rng-ban scan / dev-mode Math.random phase trap are active for this project.',
477
+ ),
372
478
  })
373
- .describe('Game manifest (vgai.game.json) — the serialized form of the Game world list');
479
+ .strict()
480
+ .describe('Game manifest (vgai.game.json) — the complete v2 project configuration');
374
481
 
375
482
  export type GameManifest = z.infer<typeof GameManifestSchema>;