@vgai/engine 0.5.42 → 0.5.45

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 (114) hide show
  1. package/dist/adapter/adapter-module.d.ts +28 -29
  2. package/dist/adapter/adapter-module.d.ts.map +1 -1
  3. package/dist/adapter/adapter-module.js +11 -34
  4. package/dist/adapter/authoring-seam-contract.d.ts +5 -0
  5. package/dist/adapter/authoring-seam-contract.d.ts.map +1 -1
  6. package/dist/adapter/authoring-seam-contract.js +1 -0
  7. package/dist/adapter/authoring.d.ts +16 -0
  8. package/dist/adapter/authoring.d.ts.map +1 -1
  9. package/dist/adapter/finders/finder-result.d.ts +2 -2
  10. package/dist/adapter/finders/finder-result.d.ts.map +1 -1
  11. package/dist/adapter/finders/index.d.ts +15 -2
  12. package/dist/adapter/finders/index.d.ts.map +1 -1
  13. package/dist/adapter/finders/index.js +38 -10
  14. package/dist/adapter/finders/prefabs-from-stories.d.ts.map +1 -1
  15. package/dist/adapter/finders/registry.d.ts +45 -0
  16. package/dist/adapter/finders/registry.d.ts.map +1 -0
  17. package/dist/adapter/finders/registry.js +56 -0
  18. package/dist/adapter/ingest/scene-capture.d.ts.map +1 -1
  19. package/dist/adapter/ingest/scene-capture.js +50 -20
  20. package/dist/adapter/system-adapter.d.ts +3 -1
  21. package/dist/adapter/system-adapter.d.ts.map +1 -1
  22. package/dist/canvas-react/pixi-react-root-factory.d.ts.map +1 -1
  23. package/dist/canvas-react/pixi-react-root-factory.js +21 -11
  24. package/dist/data/vite-plugin-data.d.ts +1 -1
  25. package/dist/data/vite-plugin-data.d.ts.map +1 -1
  26. package/dist/data/vite-plugin-data.js +2 -2
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +1 -1
  30. package/dist/input/host-pointer.d.ts +18 -0
  31. package/dist/input/host-pointer.d.ts.map +1 -1
  32. package/dist/input/host-pointer.js +54 -5
  33. package/dist/input/input-manager.d.ts +83 -1
  34. package/dist/input/input-manager.d.ts.map +1 -1
  35. package/dist/input/input-manager.js +357 -8
  36. package/dist/manifest/configuration-kinds.d.ts +97 -0
  37. package/dist/manifest/configuration-kinds.d.ts.map +1 -0
  38. package/dist/manifest/configuration-kinds.js +148 -0
  39. package/dist/manifest/index.d.ts +2 -1
  40. package/dist/manifest/index.d.ts.map +1 -1
  41. package/dist/manifest/index.js +4 -3
  42. package/dist/manifest/kind-modules.d.ts +18 -0
  43. package/dist/manifest/kind-modules.d.ts.map +1 -0
  44. package/dist/manifest/kind-modules.js +58 -0
  45. package/dist/manifest/load-file.d.ts +3 -3
  46. package/dist/manifest/load-file.d.ts.map +1 -1
  47. package/dist/manifest/load-file.js +4 -4
  48. package/dist/manifest/load.d.ts +17 -12
  49. package/dist/manifest/load.d.ts.map +1 -1
  50. package/dist/manifest/load.js +29 -19
  51. package/dist/manifest/runtime-environment.d.ts +11 -0
  52. package/dist/manifest/runtime-environment.d.ts.map +1 -0
  53. package/dist/manifest/runtime-environment.js +44 -0
  54. package/dist/manifest/schema.d.ts +10 -9
  55. package/dist/manifest/schema.d.ts.map +1 -1
  56. package/dist/manifest/schema.js +32 -27
  57. package/dist/react/use-data.d.ts +1 -1
  58. package/dist/react/use-data.js +1 -1
  59. package/dist/render/matcap-texture.d.ts +5 -0
  60. package/dist/render/matcap-texture.d.ts.map +1 -0
  61. package/dist/render/matcap-texture.js +82 -0
  62. package/dist/render/soft-particle-depth.d.ts +14 -8
  63. package/dist/render/soft-particle-depth.d.ts.map +1 -1
  64. package/dist/render/soft-particle-depth.js +37 -14
  65. package/dist/render/viewport-shading.d.ts +11 -1
  66. package/dist/render/viewport-shading.d.ts.map +1 -1
  67. package/dist/render/viewport-shading.js +25 -0
  68. package/dist/runtime/create-runtime.js +6 -5
  69. package/dist/runtime/mount-manifest.d.ts.map +1 -1
  70. package/dist/runtime/mount-manifest.js +11 -2
  71. package/dist/settings/schema.d.ts +99 -0
  72. package/dist/settings/schema.d.ts.map +1 -0
  73. package/dist/settings/schema.js +110 -0
  74. package/dist/world3d-react/r3f-root-factory.d.ts.map +1 -1
  75. package/dist/world3d-react/r3f-root-factory.js +34 -36
  76. package/dist/world3d-react/rapier-physics-bridge.d.ts.map +1 -1
  77. package/dist/world3d-react/rapier-physics-bridge.js +21 -6
  78. package/dist-config/config.js +1 -1
  79. package/dist-config/config.js.map +2 -2
  80. package/package.json +2 -1
  81. package/schemas/engine-capabilities.json +4 -4
  82. package/schemas/vgai-project.schema.json +41 -36
  83. package/schemas/vgai-settings.schema.json +112 -0
  84. package/src/adapter/adapter-module.ts +33 -51
  85. package/src/adapter/authoring-seam-contract.ts +1 -0
  86. package/src/adapter/authoring.ts +16 -0
  87. package/src/adapter/finders/finder-result.ts +2 -2
  88. package/src/adapter/finders/index.ts +68 -12
  89. package/src/adapter/finders/prefabs-from-stories.ts +2 -2
  90. package/src/adapter/finders/registry.ts +95 -0
  91. package/src/adapter/finders/scenes-from-entrypoint-selection.ts +4 -4
  92. package/src/adapter/ingest/scene-capture.ts +48 -19
  93. package/src/adapter/system-adapter.ts +3 -1
  94. package/src/canvas-react/pixi-react-root-factory.tsx +21 -11
  95. package/src/data/vite-plugin-data.ts +4 -2
  96. package/src/index.ts +1 -1
  97. package/src/input/host-pointer.ts +84 -5
  98. package/src/input/input-manager.ts +404 -7
  99. package/src/manifest/configuration-kinds.ts +241 -0
  100. package/src/manifest/index.ts +4 -3
  101. package/src/manifest/kind-modules.ts +58 -0
  102. package/src/manifest/load-file.ts +11 -5
  103. package/src/manifest/load.ts +53 -22
  104. package/src/manifest/runtime-environment.ts +48 -0
  105. package/src/manifest/schema.ts +39 -33
  106. package/src/react/use-data.ts +1 -1
  107. package/src/render/matcap-texture.ts +92 -0
  108. package/src/render/soft-particle-depth.ts +43 -14
  109. package/src/render/viewport-shading.ts +33 -1
  110. package/src/runtime/create-runtime.ts +6 -5
  111. package/src/runtime/mount-manifest.ts +13 -2
  112. package/src/settings/schema.ts +127 -0
  113. package/src/world3d-react/r3f-root-factory.tsx +45 -39
  114. package/src/world3d-react/rapier-physics-bridge.tsx +22 -8
@@ -0,0 +1,241 @@
1
+ /**
2
+ * THE RUN-CONFIGURATION KIND REGISTRY — the open set of kinds a manifest's
3
+ * `configurations[]` may declare (ARCHITECTURE-CORE §The project model). A run
4
+ * configuration is one of the project's ENTRYPOINTS: something the editor,
5
+ * the CLI or a harness can start by id. The engine registers two kinds
6
+ * here; a capability registers more through the same door; the manifest
7
+ * validates each declaration against its kind's own schema at load, and an
8
+ * unregistered kind refuses loudly naming the registered ones.
9
+ *
10
+ * The implicit configuration every project with roots has is `play` — mount
11
+ * every root in the host — which is never declared and may be named by a
12
+ * `compound`.
13
+ */
14
+
15
+ import { type ZodType, z } from 'zod';
16
+
17
+ /** The envelope every declaration carries; the rest is the kind's. */
18
+ export interface ConfigurationEnvelope {
19
+ readonly id: string;
20
+ readonly kind: string;
21
+ }
22
+
23
+ export type ConfigurationRole = 'run' | 'build';
24
+
25
+ export interface ConfigurationKindRegistration<
26
+ T extends ConfigurationEnvelope = ConfigurationEnvelope,
27
+ > {
28
+ readonly kind: T['kind'];
29
+ /** Where a configuration of this kind surfaces and which verb starts it:
30
+ * `run` (the transport, `vgai run`) or `build` (Export, `vgai build`). */
31
+ readonly role: ConfigurationRole;
32
+ /** The declaration's full shape, envelope included; `.strict()`. */
33
+ readonly schema: ZodType<T>;
34
+ /** One line for schema output and refusals. */
35
+ readonly describe: string;
36
+ /** A build-role kind backed by a project TOOL (the operation catalog):
37
+ * the host runs `tool` with `input(configuration)` and reports its
38
+ * outcome as the build's. This is how a capability's bake becomes a
39
+ * build without the engine knowing what a bake is. */
40
+ readonly build?: { readonly tool: string; readonly input: (configuration: T) => unknown };
41
+ }
42
+
43
+ /**
44
+ * What a CONTRIBUTION exports to register a kind — a plain object, so a
45
+ * project module (evaluated with the project's own copy of the engine)
46
+ * hands it to the HOST, which registers it in the host's registry. A module
47
+ * `src/contributions/<name>.kind.ts` exports it as `kind`.
48
+ */
49
+ export type ConfigurationKindContribution = ConfigurationKindRegistration;
50
+
51
+ const CONFIGURATION_ROLES: readonly ConfigurationRole[] = ['run', 'build'];
52
+
53
+ /** Register a contribution's kind after checking its shape by name. */
54
+ export function registerContributedConfigurationKind(value: unknown, source: string): () => void {
55
+ const record = (value ?? {}) as Partial<ConfigurationKindContribution>;
56
+ const problems: string[] = [];
57
+ if (typeof record.kind !== 'string' || record.kind.length === 0)
58
+ problems.push('`kind` must be a non-empty string');
59
+ if (!CONFIGURATION_ROLES.includes(record.role as ConfigurationRole))
60
+ problems.push("`role` must be 'run' or 'build'");
61
+ if (typeof record.describe !== 'string') problems.push('`describe` must be a string');
62
+ const schema = record.schema as { safeParse?: unknown } | undefined;
63
+ if (!schema || typeof schema.safeParse !== 'function')
64
+ problems.push('`schema` must be a Zod schema');
65
+ const build = record.build as { tool?: unknown; input?: unknown } | undefined;
66
+ if (
67
+ build !== undefined &&
68
+ (typeof build.tool !== 'string' || typeof build.input !== 'function')
69
+ ) {
70
+ problems.push('`build` must be `{ tool: string; input(configuration) }`');
71
+ }
72
+ if (problems.length > 0) {
73
+ throw new Error(`${source}: not a configuration kind — ${problems.join('; ')}`);
74
+ }
75
+ return registerConfigurationKind(record as ConfigurationKindContribution);
76
+ }
77
+
78
+ const registry = new Map<string, ConfigurationKindRegistration>();
79
+
80
+ /** Register a kind. A second registration replaces the first (a module
81
+ * re-evaluated on save); never throws. */
82
+ export function registerConfigurationKind<T extends ConfigurationEnvelope>(
83
+ registration: ConfigurationKindRegistration<T>,
84
+ ): () => void {
85
+ const entry = registration as unknown as ConfigurationKindRegistration;
86
+ registry.set(registration.kind, entry);
87
+ return () => {
88
+ if (registry.get(registration.kind) === entry) registry.delete(registration.kind);
89
+ };
90
+ }
91
+
92
+ export function registeredConfigurationKinds(): readonly string[] {
93
+ return [...registry.keys()];
94
+ }
95
+
96
+ export function configurationKind(kind: string): ConfigurationKindRegistration | undefined {
97
+ return registry.get(kind);
98
+ }
99
+
100
+ /** The id of the implicit configuration: mount every root in the host. */
101
+ export const PLAY_CONFIGURATION_ID = 'play';
102
+
103
+ /** A process the project runs beside the host: an entry module started
104
+ * with the project's own toolchain, listening on a port when it has one. */
105
+ export interface ProcessConfiguration extends ConfigurationEnvelope {
106
+ readonly kind: 'process';
107
+ /** Project-relative entry module (`server/main.ts`). */
108
+ readonly entry: string;
109
+ /** The command that starts it, run in a shell at `cwd`; `npx tsx <entry>`
110
+ * when omitted. Declare it when the entry needs its own flags. */
111
+ readonly command?: string;
112
+ /** The port it listens on, when readiness means a port. */
113
+ readonly port?: number;
114
+ /** Project-relative working directory; the project root when omitted. */
115
+ readonly cwd?: string;
116
+ readonly env?: Readonly<Record<string, string>>;
117
+ /** How a runner knows it is up: the port accepting connections (the
118
+ * default when `port` is set), or an HTTP path answering 2xx on it. */
119
+ readonly ready?: { readonly http?: string };
120
+ }
121
+
122
+ /** Several configurations started together, `play` included by name. */
123
+ export interface CompoundConfiguration extends ConfigurationEnvelope {
124
+ readonly kind: 'compound';
125
+ readonly run: readonly string[];
126
+ /** How many instances of the host mount to run against the rest (the
127
+ * editor's Instances picker is this parameter). */
128
+ readonly instances?: number;
129
+ }
130
+
131
+ /** A packaged build of the project's own web bundle: the project's build
132
+ * script, then `dist/` packaged as the web artifact. The first build-role
133
+ * kind; a capability registers more (a bake, a static site). */
134
+ export interface BundleConfiguration extends ConfigurationEnvelope {
135
+ readonly kind: 'bundle';
136
+ /** The package.json script that builds (`build`). */
137
+ readonly script?: string;
138
+ /** Extra arguments after `--` (`['--base', './']` for an itch-style relative bundle). */
139
+ readonly args?: readonly string[];
140
+ }
141
+
142
+ export type DeclaredConfiguration = ConfigurationEnvelope & Readonly<Record<string, unknown>>;
143
+
144
+ registerConfigurationKind<ProcessConfiguration>({
145
+ kind: 'process',
146
+ role: 'run',
147
+ describe: 'A process beside the host: an entry module, a port, readiness',
148
+ schema: z
149
+ .object({
150
+ id: z.string().min(1).describe('Configuration id'),
151
+ kind: z.literal('process'),
152
+ entry: z.string().min(1).describe('Project-relative entry module'),
153
+ command: z
154
+ .string()
155
+ .min(1)
156
+ .optional()
157
+ .describe('Shell command that starts it; `npx tsx <entry>` when omitted'),
158
+ port: z.number().int().min(1).max(65535).optional().describe('Port it listens on'),
159
+ cwd: z.string().min(1).optional().describe('Project-relative working directory'),
160
+ env: z.record(z.string(), z.string()).optional().describe('Environment for the process'),
161
+ ready: z
162
+ .object({ http: z.string().min(1).optional().describe('HTTP path answering 2xx when up') })
163
+ .strict()
164
+ .optional()
165
+ .describe('Readiness beyond the port accepting connections'),
166
+ })
167
+ .strict() as unknown as ZodType<ProcessConfiguration>,
168
+ });
169
+
170
+ registerConfigurationKind<CompoundConfiguration>({
171
+ kind: 'compound',
172
+ role: 'run',
173
+ describe: 'Several configurations started together; `play` names the host mount',
174
+ schema: z
175
+ .object({
176
+ id: z.string().min(1).describe('Configuration id'),
177
+ kind: z.literal('compound'),
178
+ run: z.array(z.string().min(1)).min(1).describe('Configuration ids, `play` included'),
179
+ instances: z
180
+ .number()
181
+ .int()
182
+ .min(1)
183
+ .optional()
184
+ .describe('Instances of the host mount to run against the rest'),
185
+ })
186
+ .strict() as unknown as ZodType<CompoundConfiguration>,
187
+ });
188
+
189
+ registerConfigurationKind<BundleConfiguration>({
190
+ kind: 'bundle',
191
+ role: 'build',
192
+ describe: "The project's web bundle: its build script, then dist/ packaged as the artifact",
193
+ schema: z
194
+ .object({
195
+ id: z.string().min(1).describe('Configuration id'),
196
+ kind: z.literal('bundle'),
197
+ script: z.string().min(1).optional().describe('package.json script that builds (`build`)'),
198
+ args: z.array(z.string()).optional().describe('Arguments passed after `--`'),
199
+ })
200
+ .strict() as unknown as ZodType<BundleConfiguration>,
201
+ });
202
+
203
+ /** Validate `configurations[]` against the registry: every kind registered, every
204
+ * declaration its kind's shape, ids unique, compound members declared. */
205
+ export function configurationIssues(configurations: readonly ConfigurationEnvelope[]): string[] {
206
+ const issues: string[] = [];
207
+ const ids = new Set<string>();
208
+ for (const declaration of configurations) {
209
+ if (ids.has(declaration.id)) issues.push(`configurations: duplicate id "${declaration.id}"`);
210
+ ids.add(declaration.id);
211
+ const registration = registry.get(declaration.kind);
212
+ if (!registration) {
213
+ issues.push(
214
+ `configurations "${declaration.id}": unknown kind "${declaration.kind}" — registered: ${registeredConfigurationKinds()
215
+ .map((k) => `"${k}"`)
216
+ .join(', ')}`,
217
+ );
218
+ continue;
219
+ }
220
+ const parsed = registration.schema.safeParse(declaration);
221
+ if (!parsed.success) {
222
+ for (const issue of parsed.error.issues) {
223
+ issues.push(
224
+ `configurations "${declaration.id}" (${declaration.kind}): ${issue.path.join('.') || '(declaration)'}: ${issue.message}`,
225
+ );
226
+ }
227
+ }
228
+ }
229
+ for (const declaration of configurations) {
230
+ if (declaration.kind !== 'compound') continue;
231
+ const members = (declaration as { run?: readonly string[] }).run ?? [];
232
+ for (const member of members) {
233
+ if (member !== PLAY_CONFIGURATION_ID && !ids.has(member)) {
234
+ issues.push(
235
+ `configurations "${declaration.id}": member "${member}" is not a declared configuration (or "${PLAY_CONFIGURATION_ID}")`,
236
+ );
237
+ }
238
+ }
239
+ }
240
+ return issues;
241
+ }
@@ -1,6 +1,7 @@
1
1
  // Game manifest (vgai.project.json) — schema + loader (T3.1 slice
2
2
  // 1)..
3
3
 
4
+ export * from './configuration-kinds';
4
5
  export {
5
6
  isManifestFilename,
6
7
  MANIFEST_FILENAME,
@@ -8,11 +9,11 @@ export {
8
9
  removedManifestFilenameMessage,
9
10
  } from './filename';
10
11
  export type { ResolvedAdapter, ResolvedAdapterRoot, ResolvedGameManifest } from './load';
11
- export { DEFAULT_SERVER_MODULE, loadGameManifest } from './load';
12
+ export { loadGameManifest } from './load';
12
13
  export type { AdapterRoot, GameManifest, RootAdapter } from './schema';
13
14
  export { AdapterRootSchema, GameManifestSchema, RootAdapterSchema } from './schema';
14
15
 
15
- // `loadGameManifestFile` (Node-only, reads `path` via `node:fs`) is
16
+ // File loading and kind-module discovery read `node:fs` and are
16
17
  // deliberately NOT re-exported here — this barrel must stay import-safe for
17
18
  // browser bundles. Import it directly from `@engine/manifest/load-file` in
18
- // Node-only contexts (CLI, tests).
19
+ // Node-only contexts; kind discovery lives in `@engine/manifest/kind-modules`.
@@ -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
+ * `src/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, 'src', '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
+ }
@@ -7,7 +7,7 @@
7
7
  // bundle.
8
8
 
9
9
  import { readFileSync } from 'node:fs';
10
- import { loadGameManifest, type ResolvedGameManifest } from './load';
10
+ import { type LoadGameManifestOptions, loadGameManifest, type ResolvedGameManifest } from './load';
11
11
  import { resolveManifestPath } from './locate';
12
12
 
13
13
  // The dual-name filesystem lookup lives in `./locate` (Zod-free on purpose);
@@ -16,12 +16,18 @@ import { resolveManifestPath } from './locate';
16
16
  export { hasManifest, manifestWritePath, resolveManifestPath } from './locate';
17
17
 
18
18
  /** Read + JSON.parse `path`, then resolve it via `loadGameManifest`. Node-only. */
19
- export function loadGameManifestFile(path: string): ResolvedGameManifest {
19
+ export function loadGameManifestFile(
20
+ path: string,
21
+ options: LoadGameManifestOptions = {},
22
+ ): ResolvedGameManifest {
20
23
  const raw: unknown = JSON.parse(readFileSync(path, 'utf-8'));
21
- return loadGameManifest(raw);
24
+ return loadGameManifest(raw, options);
22
25
  }
23
26
 
24
27
  /** {@link loadGameManifestFile} against `dir`'s `vgai.project.json`. */
25
- export function loadGameManifestDir(dir: string): ResolvedGameManifest {
26
- return loadGameManifestFile(resolveManifestPath(dir));
28
+ export function loadGameManifestDir(
29
+ dir: string,
30
+ options: LoadGameManifestOptions = {},
31
+ ): ResolvedGameManifest {
32
+ return loadGameManifestFile(resolveManifestPath(dir), options);
27
33
  }
@@ -10,9 +10,15 @@
10
10
  // `packages/engine/test/schema-consumption-map.ts`'s `manifest.*` entries).
11
11
  //
12
12
  // No `fs` import here on purpose — `load-file.ts` is the thin Node-only
13
+
13
14
  // wrapper that reads a path and calls this function, so browser bundles that
14
15
  // only need `loadGameManifest` never pull in `node:fs`.
15
16
 
17
+ import {
18
+ configurationIssues,
19
+ configurationKind,
20
+ type DeclaredConfiguration,
21
+ } from './configuration-kinds';
16
22
  import {
17
23
  type AdapterRoot,
18
24
  type GameManifest,
@@ -90,16 +96,15 @@ export interface ResolvedGameManifest {
90
96
  readonly engine: { readonly version: string };
91
97
  /** Sorted by zOrder; ties broken by original array order (§3). */
92
98
  readonly roots: readonly ResolvedAdapterRoot[];
93
- readonly server:
94
- | {
95
- readonly room: string;
96
- readonly module: string;
97
- /** The declared endpoint, or `undefined` = the scaffold default (see
98
- * the schema's own description; the editor resolves it against the
99
- * page host, which the loader has no business knowing). */
100
- readonly endpoint: string | undefined;
101
- }
102
- | undefined;
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
+ /** Deploy-baked relay credentials, when the served manifest carries them. */
107
+ readonly relay: GameManifest['relay'];
103
108
  readonly resolution: { readonly width: number; readonly height: number } | undefined;
104
109
  /** Construction-time renderer properties no world can declare for itself — see the schema. */
105
110
  readonly rendering: { readonly antialias: boolean } | undefined;
@@ -148,9 +153,6 @@ export interface ResolvedGameManifest {
148
153
  readonly learn: LearnMetadata | undefined;
149
154
  }
150
155
 
151
- /** Default relative path for `server.module` when omitted (§3) — the scaffolded Colyseus bootstrap. */
152
- export const DEFAULT_SERVER_MODULE = 'server/colyseus-setup.ts';
153
-
154
156
  const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$/;
155
157
 
156
158
  // ---------------------------------------------------------------------------
@@ -288,7 +290,16 @@ function resolveRoot(root: AdapterRoot): ResolvedAdapterRoot {
288
290
  * wrapper. Throws a descriptive `Error` (naming the offending root/field) on
289
291
  * any Zod validation failure or cross-field rule violation.
290
292
  */
291
- export function loadGameManifest(raw: unknown): ResolvedGameManifest {
293
+ export interface LoadGameManifestOptions {
294
+ /** `require` (default): a declaration of an unregistered kind refuses.
295
+ * `defer`: it is reported in `deferredConfigurationKinds` instead. */
296
+ readonly configurationKinds?: 'require' | 'defer';
297
+ }
298
+
299
+ export function loadGameManifest(
300
+ raw: unknown,
301
+ options: LoadGameManifestOptions = {},
302
+ ): ResolvedGameManifest {
292
303
  // Removed-format guard FIRST, on the raw value: a root declaring a removed
293
304
  // content field would otherwise fail Zod's `roots` discriminated union with
294
305
  // a confusing shape error instead of the named migration message.
@@ -325,13 +336,31 @@ export function loadGameManifest(raw: unknown): ResolvedGameManifest {
325
336
 
326
337
  const roots = resolvedWithIndex.map(({ root }) => root);
327
338
 
328
- const server = manifest.server
329
- ? {
330
- room: manifest.server.room,
331
- module: manifest.server.module ?? DEFAULT_SERVER_MODULE,
332
- endpoint: manifest.server.endpoint,
333
- }
334
- : undefined;
339
+ const configurations = (manifest.configurations ?? []) as DeclaredConfiguration[];
340
+ // Kinds are OPEN (ARCHITECTURE-CORE §The project model): each declaration
341
+ // is checked against its registered kind's own schema HERE, where the
342
+ // host's registry is, not in the Zod schema. A host that cannot evaluate a
343
+ // project's kind contributions defers unknown kinds by name instead of
344
+ // refusing them.
345
+ const deferredConfigurationKinds: string[] = [];
346
+ const kindIssues = configurationIssues(configurations).filter((issue) => {
347
+ if (options.configurationKinds !== 'defer') return true;
348
+ const unknown = /^configurations "([^"]+)": unknown kind/.exec(issue);
349
+ if (!unknown) return true;
350
+ deferredConfigurationKinds.push(unknown[1] as string);
351
+ return false;
352
+ });
353
+ if (kindIssues.length > 0) {
354
+ throw new Error(`Game manifest: ${kindIssues.join('\n ')}`);
355
+ }
356
+ for (const configuration of configurations) {
357
+ if (
358
+ !configurationKind(configuration.kind) &&
359
+ !deferredConfigurationKinds.includes(configuration.id)
360
+ ) {
361
+ deferredConfigurationKinds.push(configuration.id);
362
+ }
363
+ }
335
364
 
336
365
  return {
337
366
  manifestVersion: manifest.manifestVersion,
@@ -340,7 +369,9 @@ export function loadGameManifest(raw: unknown): ResolvedGameManifest {
340
369
  version: manifest.version,
341
370
  engine: { version: manifest.engine.version },
342
371
  roots,
343
- server,
372
+ configurations,
373
+ deferredConfigurationKinds,
374
+ relay: manifest.relay,
344
375
  resolution: manifest.resolution,
345
376
  rendering: manifest.rendering,
346
377
  authoring: manifest.authoring,
@@ -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 \`vgai\` on a Node that satisfies it, or change engines.node`
47
+ );
48
+ }
@@ -398,6 +398,14 @@ export type LearnMetadata = z.infer<typeof LearnMetadataSchema>;
398
398
  // Game manifest (vgai.project.json)
399
399
  // ---------------------------------------------------------------------------
400
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
+
401
409
  export const GameManifestSchema = z
402
410
  .object({
403
411
  $schema: z
@@ -426,7 +434,6 @@ export const GameManifestSchema = z
426
434
  .describe('Engine version pin'),
427
435
  roots: z
428
436
  .array(AdapterRootSchema)
429
- .min(1)
430
437
  .superRefine((roots, ctx) => {
431
438
  // D-V6 (debt): root ids are described as unique above but that was
432
439
  // describe-only at the SCHEMA level — only load.ts's loader-level
@@ -482,7 +489,7 @@ export const GameManifestSchema = z
482
489
  }
483
490
  })
484
491
  .describe(
485
- "The Game's explicit, non-empty adapter-root composition. There is no implicit root. " +
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. " +
486
493
  'Root ids are unique, and a project declares at most ONE world root per medium — ' +
487
494
  'one `three`, one `dom`, one canvas-surface root (first-party canvas, canvas ingest ' +
488
495
  'and canvas module are the same medium).',
@@ -518,52 +525,51 @@ export const GameManifestSchema = z
518
525
  .strict()
519
526
  .optional()
520
527
  .describe('Editor-only authoring metadata; never runtime ownership'),
521
- server: z
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, `vgai run <id>` and a harness start the same declaration.",
535
+ ),
536
+ relay: z
522
537
  .object({
523
- room: z.string().describe('Colyseus room name'),
524
- endpoint: z
538
+ signalingUrl: z
525
539
  .string()
526
540
  .optional()
527
541
  .describe(
528
- 'WebSocket endpoint the editor reports for this room, e.g. "wss://rooms.example.com". ' +
529
- 'Omit to declare the scaffold default, `ws://<the page host>:2567` — the port ' +
530
- "`server/colyseus-setup.ts` listens on. Stated here because the endpoint is the game's " +
531
- 'own deployment fact, not something the editor may guess',
542
+ 'Managed relay signaling URL to prefer over the client default; omitted when the deploy did not pin one',
532
543
  ),
533
- module: z
544
+ grant: z
534
545
  .string()
535
546
  .optional()
536
547
  .describe(
537
- 'Module path for the Colyseus room bootstrap; defaults to the scaffolded server bootstrap',
548
+ 'Room-scoped managed-relay GRANT minted at `vgai deploy`; players exchange it for a fresh token at `sessionUrl`',
538
549
  ),
539
- relay: z
540
- .object({
541
- signalingUrl: z
542
- .string()
543
- .optional()
544
- .describe(
545
- 'Managed relay signaling URL to prefer over the client default; omitted when the deploy did not pin one',
546
- ),
547
- accessToken: z
548
- .string()
549
- .describe(
550
- 'Room-scoped managed-relay access token minted at `vgai deploy` and read by the standalone client (main.ts)',
551
- ),
552
- expiresAt: z
553
- .number()
554
- .describe(
555
- 'Token expiry as epoch milliseconds; the client falls back to a tokenless join once elapsed',
556
- ),
557
- })
558
- .strict()
550
+ sessionUrl: z
551
+ .string()
559
552
  .optional()
560
553
  .describe(
561
- 'Deploy-baked managed-relay credentials. NEVER hand-authored in source written only into the staged manifest by `vgai deploy` and fetched at runtime by the standalone client',
554
+ 'Public session endpoint where a deployed client exchanges the grant for a short-lived relay token',
555
+ ),
556
+ accessToken: z
557
+ .string()
558
+ .optional()
559
+ .describe('A baked relay token (older deploys); used directly while unexpired'),
560
+ expiresAt: z
561
+ .number()
562
+ .optional()
563
+ .describe(
564
+ 'Token expiry as epoch milliseconds; the client falls back to a tokenless join once elapsed',
562
565
  ),
563
566
  })
564
567
  .strict()
565
568
  .optional()
566
- .describe('Multiplayer server config (Colyseus), if this game registers a room'),
569
+ .describe(
570
+ 'Deploy-baked managed-relay credentials — a DEPLOYMENT fact. NEVER hand-authored in source: written only into ' +
571
+ 'the staged manifest by `vgai deploy` and read by the deployed client at connect time.',
572
+ ),
567
573
  resolution: z
568
574
  .object({
569
575
  width: z.number().describe('Canvas width in pixels'),
@@ -57,7 +57,7 @@ export function useData<T>(handle: DataHandle<T>): T {
57
57
  * runtime state belongs in your game's own store, never written back into
58
58
  * data files. Outside a running editor there is no `/__editor` server, so
59
59
  * this rejects with a teaching error (a shipped game could never reach the
60
- * route anyway — builds strip `src/tools/` entirely, §4).
60
+ * route anyway — builds strip `src/contributions/` and `src/tools/` entirely, §4).
61
61
  *
62
62
  * The body is PARSED VALUES, never serialized text: the dev server's fold
63
63
  * (`packages/editor/server/data-file-serialize.ts`) owns key order and the