@vgai/engine 0.2.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.
- package/LICENSE +202 -0
- package/README.md +35 -0
- package/package.json +55 -0
- package/src/adapter/authoring.ts +402 -0
- package/src/adapter/colyseus-networking-adapter.ts +72 -0
- package/src/adapter/first-party-systems.ts +103 -0
- package/src/adapter/game-adapter.ts +151 -0
- package/src/adapter/host-context.ts +77 -0
- package/src/adapter/index.ts +85 -0
- package/src/adapter/ingest/game-contract.ts +59 -0
- package/src/adapter/ingest/overlay-applier.ts +207 -0
- package/src/adapter/ingest/overlay-apply.ts +124 -0
- package/src/adapter/ingest/overlay-file.ts +126 -0
- package/src/adapter/ingest/overlay-report.ts +176 -0
- package/src/adapter/ingest/scene-capture.ts +307 -0
- package/src/adapter/ingest/upstream-pin.ts +52 -0
- package/src/adapter/loop-gate-report.ts +54 -0
- package/src/adapter/rapier-physics-adapter.ts +56 -0
- package/src/adapter/system-adapter.ts +154 -0
- package/src/adapter/transform.ts +18 -0
- package/src/adapter/vgai-scene-game-adapter.ts +886 -0
- package/src/adapter/world-kind.ts +34 -0
- package/src/ai/navigation.ts +164 -0
- package/src/animation/anim-graph-types.ts +56 -0
- package/src/animation/anim-graph.ts +406 -0
- package/src/animation/anim-system.ts +28 -0
- package/src/animation/blend-node.ts +119 -0
- package/src/animation/property-track.ts +178 -0
- package/src/animation/schema.ts +204 -0
- package/src/assets.ts +80 -0
- package/src/audio/ambient.ts +300 -0
- package/src/audio/impacts.ts +212 -0
- package/src/audio/index.ts +7 -0
- package/src/audio/movement.ts +140 -0
- package/src/audio/musical.ts +200 -0
- package/src/audio/ui-sounds.ts +171 -0
- package/src/audio/vehicle.ts +235 -0
- package/src/audio/weapons.ts +152 -0
- package/src/core/game-loop.ts +127 -0
- package/src/core/system-runner.ts +298 -0
- package/src/core/types.ts +58 -0
- package/src/dev/console-bridge.ts +83 -0
- package/src/dev/debug-draw.ts +80 -0
- package/src/dev/logger.ts +119 -0
- package/src/ecs/component-manager.ts +748 -0
- package/src/ecs/game-component.ts +147 -0
- package/src/ecs/hmr-swap-report.ts +65 -0
- package/src/input/input-manager.ts +439 -0
- package/src/input/input-types.ts +19 -0
- package/src/input/schema.ts +129 -0
- package/src/loader.ts +70 -0
- package/src/manifest/index.ts +24 -0
- package/src/manifest/load-file.ts +16 -0
- package/src/manifest/load.ts +378 -0
- package/src/manifest/schema.ts +375 -0
- package/src/physics/collision-system.ts +76 -0
- package/src/physics/physics-registry.ts +83 -0
- package/src/physics/transform-writer.ts +41 -0
- package/src/physics/trigger-dispatch.ts +97 -0
- package/src/react/game-state.tsx +172 -0
- package/src/render/auto-batcher.ts +169 -0
- package/src/render/render-batch-system.ts +268 -0
- package/src/render/render-features.ts +146 -0
- package/src/render/render-settings.ts +72 -0
- package/src/runtime/create-runtime.ts +1152 -0
- package/src/runtime/frame-selector-cache.ts +81 -0
- package/src/runtime/game.ts +1003 -0
- package/src/runtime/input-router.ts +213 -0
- package/src/runtime/mount-game.ts +269 -0
- package/src/runtime/mount-manifest.ts +361 -0
- package/src/runtime/scene-ui-bridge.ts +86 -0
- package/src/runtime/scene-ui-data.ts +119 -0
- package/src/runtime/state-bridge.ts +79 -0
- package/src/runtime/types.ts +196 -0
- package/src/scene/asset-loaders.ts +195 -0
- package/src/scene/asset-paths.ts +123 -0
- package/src/scene/asset-registry.ts +67 -0
- package/src/scene/collider-dimensions.ts +125 -0
- package/src/scene/component-registry.ts +40 -0
- package/src/scene/defaults.ts +164 -0
- package/src/scene/geometries/index.ts +7 -0
- package/src/scene/geometries/terrain.ts +42 -0
- package/src/scene/geometry-registry.ts +42 -0
- package/src/scene/instance-registry.ts +84 -0
- package/src/scene/instancers/grid.ts +38 -0
- package/src/scene/instancers/index.ts +7 -0
- package/src/scene/light-camera-factory.ts +97 -0
- package/src/scene/material-factory.ts +211 -0
- package/src/scene/material-registry.ts +73 -0
- package/src/scene/materials/index.ts +7 -0
- package/src/scene/materials/water.ts +56 -0
- package/src/scene/parse.ts +71 -0
- package/src/scene/particles-factory.ts +383 -0
- package/src/scene/scene-apply.ts +356 -0
- package/src/scene/scene-diff-schema.ts +115 -0
- package/src/scene/scene-diff-types.ts +29 -0
- package/src/scene/scene-loader.ts +1533 -0
- package/src/scene/scene-query.ts +63 -0
- package/src/scene/scene-types.ts +34 -0
- package/src/scene/scene-version.ts +40 -0
- package/src/scene/schema/animation.ts +95 -0
- package/src/scene/schema/audio.ts +25 -0
- package/src/scene/schema/camera.ts +21 -0
- package/src/scene/schema/collider.ts +69 -0
- package/src/scene/schema/entity-ref.ts +78 -0
- package/src/scene/schema/entity.ts +169 -0
- package/src/scene/schema/environment.ts +384 -0
- package/src/scene/schema/index.ts +95 -0
- package/src/scene/schema/instances.ts +35 -0
- package/src/scene/schema/joint.ts +26 -0
- package/src/scene/schema/light.ts +38 -0
- package/src/scene/schema/material.ts +113 -0
- package/src/scene/schema/mesh.ts +108 -0
- package/src/scene/schema/particles.ts +398 -0
- package/src/scene/schema/physics.ts +49 -0
- package/src/scene/schema/scene-file.ts +299 -0
- package/src/scene/schema/shadow.ts +24 -0
- package/src/scene/schema/spline.ts +21 -0
- package/src/scene/schema/tuples.ts +21 -0
- package/src/scene/schema/ui.ts +602 -0
- package/src/scene/user-data.ts +203 -0
- package/src/setup/setup-audio.ts +60 -0
- package/src/setup/setup-particles.ts +23 -0
- package/src/setup/setup-physics.ts +67 -0
- package/src/setup/setup-renderer.ts +529 -0
- package/src/types-n8ao.d.ts +37 -0
- package/src/types-realism-effects.d.ts +61 -0
- package/src/world2d/authoring-2d.ts +208 -0
- package/src/world2d/capture-to-scene2d.ts +52 -0
- package/src/world2d/collision-2d.ts +106 -0
- package/src/world2d/components-2d.ts +86 -0
- package/src/world2d/index.ts +66 -0
- package/src/world2d/ingest-iframe-2d.ts +255 -0
- package/src/world2d/ingest2d.ts +131 -0
- package/src/world2d/physics2d-registry.ts +49 -0
- package/src/world2d/pixi-game-adapter.ts +325 -0
- package/src/world2d/pixi-surface.ts +78 -0
- package/src/world2d/scene-capture-2d.ts +117 -0
- package/src/world2d/scene2d-loader.ts +308 -0
- package/src/world2d/schema/entity2d.ts +145 -0
- package/src/world2d/schema/physics2d.ts +53 -0
- package/src/world2d/schema/sprite.ts +71 -0
- package/src/world2d/schema/tilemap.ts +22 -0
- package/src/world2d/schema/tuples2d.ts +25 -0
- package/src/world2d/system-adapters-2d.ts +49 -0
- package/src/world2d/transform-writer-2d.ts +24 -0
- package/src/world2d/types.ts +55 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// T4.6 — the `.inputmap.json` schema.
|
|
2
|
+
//
|
|
3
|
+
// Was TS-types-only (`input-types.ts`) until T4.6 authored this Zod schema —
|
|
4
|
+
// see docs/BACKBONE-TASKS.md T4.6. `InputManager.loadMap` (input-manager.ts)
|
|
5
|
+
// is this schema's runtime reader: it parses via this schema, then copies
|
|
6
|
+
// each action's `bindings` array straight into `this.actions`.
|
|
7
|
+
//
|
|
8
|
+
// Every field has a `.describe()` (repo policy — powers
|
|
9
|
+
// `scripts/generate-schema.ts` and the T4.1/T4.6 schema-walk coverage test).
|
|
10
|
+
// No runtime dependency beyond `zod`, matching the `scene/schema/` and
|
|
11
|
+
// `manifest/schema.ts` precedent.
|
|
12
|
+
//
|
|
13
|
+
// T4.1 inheritance: `input-manager.ts`'s `isPressed`/`isJustPressed`/
|
|
14
|
+
// `isJustReleased` switch on `binding.type` and only ever handle `key`,
|
|
15
|
+
// `mouse_button`, `gamepad_button`, and `gamepad_axis` — `mouse_move` and
|
|
16
|
+
// `gamepad_axis_pair` are authored-but-unhandled binding kinds (mouse look is
|
|
17
|
+
// read via a free-standing `mouseDelta`/`lookStick` accumulator that no
|
|
18
|
+
// binding gates, and there is no code path that reads `xAxis`/`yAxis` as a
|
|
19
|
+
// pair). Authoring either kind is a confirmed dead stub — REJECTED at parse,
|
|
20
|
+
// mirroring the `scene/schema/ui.ts` dead-field mechanism (a `.superRefine`
|
|
21
|
+
// that throws naming the field).
|
|
22
|
+
|
|
23
|
+
import { z } from 'zod';
|
|
24
|
+
import type { InputAction, InputBinding, InputMapFile } from './input-types';
|
|
25
|
+
|
|
26
|
+
const KeyBindingSchema = z.object({
|
|
27
|
+
type: z.literal('key').describe('Binding kind: a keyboard key (KeyboardEvent.code)'),
|
|
28
|
+
code: z.string().describe('KeyboardEvent.code value, e.g. "KeyW", "Space", "ArrowUp"'),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const MouseButtonBindingSchema = z.object({
|
|
32
|
+
type: z.literal('mouse_button').describe('Binding kind: a mouse button'),
|
|
33
|
+
button: z.number().describe('MouseEvent.button index (0 = left, 1 = middle, 2 = right)'),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const MouseMoveBindingSchema = z.object({
|
|
37
|
+
type: z
|
|
38
|
+
.literal('mouse_move')
|
|
39
|
+
.describe(
|
|
40
|
+
'Binding kind: raw mouse movement. REJECTED at parse (T4.1/T4.6): declared but has no ' +
|
|
41
|
+
'runtime reader — authoring this binding throws. Do not author.',
|
|
42
|
+
),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const GamepadButtonBindingSchema = z.object({
|
|
46
|
+
type: z.literal('gamepad_button').describe('Binding kind: a gamepad face/shoulder button'),
|
|
47
|
+
button: z.number().describe('Standard Gamepad API button index'),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const GamepadAxisBindingSchema = z.object({
|
|
51
|
+
type: z.literal('gamepad_axis').describe('Binding kind: a single thresholded gamepad axis'),
|
|
52
|
+
axis: z.number().describe('Standard Gamepad API axis index'),
|
|
53
|
+
direction: z
|
|
54
|
+
.enum(['positive', 'negative'])
|
|
55
|
+
.describe('Which side of the deadzone counts as "pressed"'),
|
|
56
|
+
deadzone: z.number().optional().describe('Deadzone magnitude (default 0.15 if omitted)'),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const GamepadAxisPairBindingSchema = z.object({
|
|
60
|
+
type: z
|
|
61
|
+
.literal('gamepad_axis_pair')
|
|
62
|
+
.describe(
|
|
63
|
+
'Binding kind: a coupled (x, y) gamepad axis pair, e.g. a stick used as a 2D vector ' +
|
|
64
|
+
'(look/aim). REJECTED at parse (T4.1/T4.6): declared but has no runtime reader — only ' +
|
|
65
|
+
'`gamepad_axis` (a single thresholded axis) is handled by isPressed/isJustPressed/' +
|
|
66
|
+
'isJustReleased. Authoring this binding throws. Do not author.',
|
|
67
|
+
),
|
|
68
|
+
xAxis: z.number().describe('Standard Gamepad API axis index for the X component'),
|
|
69
|
+
yAxis: z.number().describe('Standard Gamepad API axis index for the Y component'),
|
|
70
|
+
deadzone: z.number().optional().describe('Deadzone magnitude (default 0.15 if omitted)'),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A single input binding. `mouse_move`/`gamepad_axis_pair` are dead-by-decision
|
|
75
|
+
* (see the per-variant `.describe()` above) — the `.superRefine` below is the
|
|
76
|
+
* actual enforcement, mirroring `scene/schema/ui.ts`'s dead-field mechanism.
|
|
77
|
+
*/
|
|
78
|
+
export const InputBindingSchema: z.ZodType<InputBinding> = z
|
|
79
|
+
.discriminatedUnion('type', [
|
|
80
|
+
KeyBindingSchema,
|
|
81
|
+
MouseButtonBindingSchema,
|
|
82
|
+
MouseMoveBindingSchema,
|
|
83
|
+
GamepadButtonBindingSchema,
|
|
84
|
+
GamepadAxisBindingSchema,
|
|
85
|
+
GamepadAxisPairBindingSchema,
|
|
86
|
+
])
|
|
87
|
+
.superRefine((binding, ctx) => {
|
|
88
|
+
if (binding.type === 'mouse_move') {
|
|
89
|
+
ctx.addIssue({
|
|
90
|
+
code: z.ZodIssueCode.custom,
|
|
91
|
+
message:
|
|
92
|
+
'`mouse_move` binding kind is authored but not implemented — InputManager.isPressed/' +
|
|
93
|
+
'isJustPressed/isJustReleased never handle it (mouse look is read via a free-standing ' +
|
|
94
|
+
'mouseDelta/lookStick accumulator, not gated by any action binding). See T4.1/T4.6.',
|
|
95
|
+
path: ['type'],
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (binding.type === 'gamepad_axis_pair') {
|
|
99
|
+
ctx.addIssue({
|
|
100
|
+
code: z.ZodIssueCode.custom,
|
|
101
|
+
message:
|
|
102
|
+
'`gamepad_axis_pair` binding kind is authored but not implemented — no reader consumes ' +
|
|
103
|
+
'`xAxis`/`yAxis` as a coupled pair (only `gamepad_axis`, a single thresholded axis, is ' +
|
|
104
|
+
'handled by isPressed/isJustPressed/isJustReleased). See T4.1/T4.6.',
|
|
105
|
+
path: ['type'],
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}) as z.ZodType<InputBinding>;
|
|
109
|
+
|
|
110
|
+
/** An action has a name (the record key) and one or more bindings. */
|
|
111
|
+
export const InputActionSchema: z.ZodType<InputAction> = z.object({
|
|
112
|
+
bindings: z
|
|
113
|
+
.array(InputBindingSchema)
|
|
114
|
+
.describe('Bindings that all trigger this action (any one firing is enough)'),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The `.inputmap.json` file format (`InputManager.loadMap`).
|
|
119
|
+
*
|
|
120
|
+
* `version` is RESERVED (no range-check reader exists yet for this format —
|
|
121
|
+
* same convention `2d.version`/`Scene2DSchema` used before T2.3 gave the 3D
|
|
122
|
+
* scene version axis a real reader; see `schema-consumption-map.ts`).
|
|
123
|
+
*/
|
|
124
|
+
export const InputMapFileSchema: z.ZodType<InputMapFile> = z.object({
|
|
125
|
+
version: z.number().describe('Input map file format version (RESERVED — no range check yet)'),
|
|
126
|
+
actions: z
|
|
127
|
+
.record(z.string(), InputActionSchema)
|
|
128
|
+
.describe('Named actions, each with a list of bindings (InputManager.loadMap)'),
|
|
129
|
+
}) as z.ZodType<InputMapFile>;
|
package/src/loader.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared asset loading infrastructure.
|
|
3
|
+
*
|
|
4
|
+
* Provides a single LoadingManager (for Three.js loaders) and resolveUrl()
|
|
5
|
+
* (for raw fetch calls) so that all engine asset requests go through one
|
|
6
|
+
* configurable URL prefix. Call setAssetPrefix() once at runtime startup.
|
|
7
|
+
*
|
|
8
|
+
* Three.js loaders constructed with `loadingManager` automatically rewrite
|
|
9
|
+
* URLs via setURLModifier(). For raw fetch() calls, wrap the URL with
|
|
10
|
+
* resolveUrl() to apply the same prefix.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as THREE from 'three';
|
|
14
|
+
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
|
|
15
|
+
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
16
|
+
|
|
17
|
+
let _prefix = '/';
|
|
18
|
+
|
|
19
|
+
function rewriteUrl(url: string): string {
|
|
20
|
+
if (
|
|
21
|
+
url.startsWith('/') ||
|
|
22
|
+
url.startsWith('http') ||
|
|
23
|
+
url.startsWith('data:') ||
|
|
24
|
+
url.startsWith('blob:')
|
|
25
|
+
) {
|
|
26
|
+
return url;
|
|
27
|
+
}
|
|
28
|
+
return _prefix + url;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Shared Three.js LoadingManager — all engine loaders should use this. */
|
|
32
|
+
export const loadingManager = new THREE.LoadingManager();
|
|
33
|
+
loadingManager.setURLModifier(rewriteUrl);
|
|
34
|
+
|
|
35
|
+
/** Pre-configured TextureLoader using the shared manager. */
|
|
36
|
+
export const textureLoader = new THREE.TextureLoader(loadingManager);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Shared DRACOLoader for decoding DRACO-compressed GLTF/GLB meshes.
|
|
40
|
+
*
|
|
41
|
+
* The decoder wasm/js is vendored under
|
|
42
|
+
* packages/editor/template/public/jsm/libs/draco/gltf/ and served at the
|
|
43
|
+
* absolute runtime path '/jsm/libs/draco/gltf/' (DRACOLoader appends
|
|
44
|
+
* draco_wasm_wrapper.js / draco_decoder.wasm to this path). This is an
|
|
45
|
+
* absolute path, so it deliberately does NOT go through the asset prefix —
|
|
46
|
+
* the decoder is engine infrastructure, not a scene asset.
|
|
47
|
+
*/
|
|
48
|
+
export const dracoLoader = new DRACOLoader(loadingManager);
|
|
49
|
+
dracoLoader.setDecoderPath('/jsm/libs/draco/gltf/');
|
|
50
|
+
|
|
51
|
+
/** Pre-configured GLTFLoader using the shared manager, with DRACO decoding wired in. */
|
|
52
|
+
export const gltfLoader = new GLTFLoader(loadingManager);
|
|
53
|
+
gltfLoader.setDRACOLoader(dracoLoader);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Set the URL prefix prepended to relative asset paths.
|
|
57
|
+
* Called once by createGameRuntime(). Defaults to '/'.
|
|
58
|
+
*/
|
|
59
|
+
export function setAssetPrefix(prefix: string): void {
|
|
60
|
+
_prefix = prefix.endsWith('/') ? prefix : prefix ? `${prefix}/` : '/';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve a relative asset URL using the current prefix.
|
|
65
|
+
* Use this for raw fetch() calls — Three.js loaders using
|
|
66
|
+
* `loadingManager` handle this automatically.
|
|
67
|
+
*/
|
|
68
|
+
export function resolveUrl(url: string): string {
|
|
69
|
+
return rewriteUrl(url);
|
|
70
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Game manifest (vgai.game.json) — schema + loader (T3.1 slice 1).
|
|
2
|
+
// See docs/GAME-MANIFEST-DESIGN.md.
|
|
3
|
+
|
|
4
|
+
export type { ResolvedAdapter, ResolvedGameManifest, ResolvedWorldEntry } from './load';
|
|
5
|
+
export { CAPABILITY_CEILINGS, DEFAULT_SERVER_MODULE, loadGameManifest } from './load';
|
|
6
|
+
export type {
|
|
7
|
+
GameManifest,
|
|
8
|
+
IngestStrategy,
|
|
9
|
+
Tier,
|
|
10
|
+
WorldAdapter,
|
|
11
|
+
WorldEntry,
|
|
12
|
+
} from './schema';
|
|
13
|
+
export {
|
|
14
|
+
GameManifestSchema,
|
|
15
|
+
IngestStrategySchema,
|
|
16
|
+
TierSchema,
|
|
17
|
+
WorldAdapterSchema,
|
|
18
|
+
WorldEntrySchema,
|
|
19
|
+
} from './schema';
|
|
20
|
+
|
|
21
|
+
// `loadGameManifestFile` (Node-only, reads `path` via `node:fs`) is
|
|
22
|
+
// deliberately NOT re-exported here — this barrel must stay import-safe for
|
|
23
|
+
// browser bundles. Import it directly from `@engine/manifest/load-file` in
|
|
24
|
+
// Node-only contexts (CLI, tests).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// T3.1 slice 1 — Node-only path wrapper around `loadGameManifest` (§5).
|
|
2
|
+
//
|
|
3
|
+
// Kept in its own module (rather than folded into `load.ts`) so that
|
|
4
|
+
// `loadGameManifest` itself stays free of `node:fs` — anything that only
|
|
5
|
+
// needs the pure parse/resolve function (e.g. a future browser/hosted-editor
|
|
6
|
+
// caller) can import `./load` without pulling a Node-only module into its
|
|
7
|
+
// bundle.
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { loadGameManifest, type ResolvedGameManifest } from './load';
|
|
11
|
+
|
|
12
|
+
/** Read + JSON.parse `path`, then resolve it via `loadGameManifest`. Node-only. */
|
|
13
|
+
export function loadGameManifestFile(path: string): ResolvedGameManifest {
|
|
14
|
+
const raw: unknown = JSON.parse(readFileSync(path, 'utf-8'));
|
|
15
|
+
return loadGameManifest(raw);
|
|
16
|
+
}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// T3.1 slice 1 — the game manifest loader/validator (docs/GAME-MANIFEST-DESIGN.md §5).
|
|
2
|
+
//
|
|
3
|
+
// Pure data-in/data-out: `loadGameManifest(raw)` parses `raw` against
|
|
4
|
+
// `GameManifestSchema`, runs the cross-field checks §3 promises beyond what
|
|
5
|
+
// Zod alone can express, resolves every optional/derivable field, and
|
|
6
|
+
// returns a `ResolvedGameManifest` with nothing left implicit. This is the
|
|
7
|
+
// schema's runtime reader (T4.1 policy) — every described field in
|
|
8
|
+
// `schema.ts` either drives a check here or is carried into the resolved
|
|
9
|
+
// output explicitly (see the per-field comments below and
|
|
10
|
+
// `packages/engine/test/schema-consumption-map.ts`'s `manifest.*` entries).
|
|
11
|
+
//
|
|
12
|
+
// No `fs` import here on purpose — `load-file.ts` is the thin Node-only
|
|
13
|
+
// wrapper that reads a path and calls this function, so browser bundles that
|
|
14
|
+
// only need `loadGameManifest` never pull in `node:fs`.
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
type GameManifest,
|
|
18
|
+
GameManifestSchema,
|
|
19
|
+
type IngestStrategy,
|
|
20
|
+
type Tier,
|
|
21
|
+
type WorldAdapter,
|
|
22
|
+
type WorldEntry,
|
|
23
|
+
} from './schema';
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Resolved shapes
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/** D6's resolved adapter identities. */
|
|
30
|
+
export type ResolvedAdapter =
|
|
31
|
+
| {
|
|
32
|
+
readonly type: 'default';
|
|
33
|
+
readonly identity: 'default-three' | 'default-pixi' | 'default-react';
|
|
34
|
+
}
|
|
35
|
+
| { readonly type: 'module'; readonly identity: 'module'; readonly module: string }
|
|
36
|
+
| {
|
|
37
|
+
readonly type: 'ingest';
|
|
38
|
+
// 'ingest-react' (Track N, N1 — docs/REACT-INGEST-LANDING-DESIGN.md
|
|
39
|
+
// D-N2) is assigned here purely structurally (kind === 'react'), same
|
|
40
|
+
// as 'ingest-three'/'ingest-pixi': this loader stays strategy-agnostic
|
|
41
|
+
// at resolve time, exactly like the other two ingest identities
|
|
42
|
+
// (`ceilingFor`/`resolveCapabilities` below already derive tiers per
|
|
43
|
+
// `strategy` regardless of `kind`). The v1 "deduped rung only" rule
|
|
44
|
+
// (D-N3) is enforced editor-side, at the resolver
|
|
45
|
+
// (`packages/editor/src/adapter-resolver.ts`'s `resolveIngestReactAdapter`)
|
|
46
|
+
// — not here — mirroring how THIS file never rejects
|
|
47
|
+
// `ingest-three`/`ingest-pixi`'s `iframe-reachable`/`opaque-embed`
|
|
48
|
+
// strategies either; `resolveIngestDescriptor`/`resolveIngest2DDescriptor`
|
|
49
|
+
// are where "not yet buildable" strategies throw.
|
|
50
|
+
readonly identity: 'ingest-three' | 'ingest-pixi' | 'ingest-react';
|
|
51
|
+
readonly strategy: IngestStrategy;
|
|
52
|
+
readonly entryHtml: string | undefined;
|
|
53
|
+
readonly assets: Record<string, string> | undefined;
|
|
54
|
+
readonly domStubs: string[] | undefined;
|
|
55
|
+
readonly captureTimeoutMs: number | undefined;
|
|
56
|
+
/** Track P (docs/PIXI-INGEST-LANDING-DESIGN.md §2) — iframe-reachable-multi mount fields. */
|
|
57
|
+
readonly bundleUrl: string | undefined;
|
|
58
|
+
readonly baseHref: string | undefined;
|
|
59
|
+
readonly assetBaseUrl: string | undefined;
|
|
60
|
+
readonly extraDeps: string[] | undefined;
|
|
61
|
+
readonly pixiModuleUrl: string | undefined;
|
|
62
|
+
readonly bodyHtml: string | undefined;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export interface ResolvedWorldEntry {
|
|
66
|
+
readonly id: string;
|
|
67
|
+
readonly kind: WorldEntry['kind'];
|
|
68
|
+
readonly description: string | undefined;
|
|
69
|
+
readonly adapter: ResolvedAdapter;
|
|
70
|
+
readonly scene: string | undefined;
|
|
71
|
+
readonly entry: string | undefined;
|
|
72
|
+
readonly zOrder: number;
|
|
73
|
+
readonly pausable: boolean;
|
|
74
|
+
readonly loop: 'gated' | 'self-driven';
|
|
75
|
+
/** Every optional resolved: both contexts always present (§4/§5). */
|
|
76
|
+
readonly capabilities: { readonly local: Tier; readonly hosted: Tier };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ResolvedGameManifest {
|
|
80
|
+
readonly manifestVersion: 1;
|
|
81
|
+
readonly name: string;
|
|
82
|
+
readonly version: string;
|
|
83
|
+
readonly engine: { readonly version: string };
|
|
84
|
+
/** Sorted by zOrder; ties broken by original array order (§3). */
|
|
85
|
+
readonly worlds: readonly ResolvedWorldEntry[];
|
|
86
|
+
readonly server: { readonly room: string; readonly module: string } | undefined;
|
|
87
|
+
readonly resolution: { readonly width: number; readonly height: number } | undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// §4 — the per-context ceiling table (named + exported: the tier-table doc
|
|
92
|
+
// test in slice 2 pins docs/CAPABILITY-TIERS.md's rows against this).
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Ceiling (maximum legal declared `Tier`) per adapter type and delivery
|
|
97
|
+
* context. `default` worlds are always first-party in both contexts (D6/§2 —
|
|
98
|
+
* a first-party world is fully vgai-native everywhere). `ingest` worlds are
|
|
99
|
+
* ceilinged by their own capture `strategy`: locally the local CLI's Vite
|
|
100
|
+
* pipeline gives bundler dedupe, so every strategy reaches its own natural
|
|
101
|
+
* rung; hosted has no bundler (esbuild-wasm + externals only), and a
|
|
102
|
+
* `deduped` world is DEFINED by being source built through our pipeline — no
|
|
103
|
+
* prebuilt bundle exists to fall back to an iframe rung, and nothing
|
|
104
|
+
* implements a hosted build for it, so hosted is `unsupported` (honesty over
|
|
105
|
+
* optimism; if the hosted esbuild-wasm path ever proves capable, raising
|
|
106
|
+
* this ceiling is a non-breaking widening). `shared`/`iframe-reachable`/
|
|
107
|
+
* `opaque-embed` don't depend on a bundler either way, so they carry through
|
|
108
|
+
* unchanged. `module` adapters have no derivable ceiling ("a custom
|
|
109
|
+
* adapter's reach is not derivable", §4) — modeled here as `first-party`
|
|
110
|
+
* (the top rank), i.e. no ceiling actually constrains a module adapter's
|
|
111
|
+
* declared tier; the loader instead requires the tier to be declared
|
|
112
|
+
* explicitly for module worlds (see `resolveCapabilities`), since there is no
|
|
113
|
+
* default to derive.
|
|
114
|
+
*/
|
|
115
|
+
export const CAPABILITY_CEILINGS = {
|
|
116
|
+
default: { local: 'first-party', hosted: 'first-party' },
|
|
117
|
+
ingest: {
|
|
118
|
+
shared: { local: 'shared', hosted: 'shared' },
|
|
119
|
+
deduped: { local: 'deduped', hosted: 'unsupported' },
|
|
120
|
+
'iframe-reachable': { local: 'iframe-reachable', hosted: 'iframe-reachable' },
|
|
121
|
+
'opaque-embed': { local: 'opaque-embed', hosted: 'opaque-embed' },
|
|
122
|
+
},
|
|
123
|
+
module: { local: 'first-party', hosted: 'first-party' },
|
|
124
|
+
} as const satisfies Record<
|
|
125
|
+
string,
|
|
126
|
+
{ local: Tier; hosted: Tier } | Record<IngestStrategy, { local: Tier; hosted: Tier }>
|
|
127
|
+
>;
|
|
128
|
+
|
|
129
|
+
/** Total order over `Tier` (§4: `first-party > shared ≈ deduped > iframe-reachable > opaque-embed > unsupported`). */
|
|
130
|
+
const TIER_RANK: Record<Tier, number> = {
|
|
131
|
+
'first-party': 5,
|
|
132
|
+
shared: 4,
|
|
133
|
+
deduped: 4,
|
|
134
|
+
'iframe-reachable': 3,
|
|
135
|
+
'opaque-embed': 2,
|
|
136
|
+
unsupported: 1,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** Default relative path for `server.module` when omitted (§3) — the scaffolded Colyseus bootstrap. */
|
|
140
|
+
export const DEFAULT_SERVER_MODULE = 'server/colyseus-setup.ts';
|
|
141
|
+
|
|
142
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$/;
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Adapter shape guards
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
function isModuleAdapter(
|
|
149
|
+
adapter: WorldAdapter,
|
|
150
|
+
): adapter is Extract<WorldAdapter, { module: string }> {
|
|
151
|
+
return typeof adapter === 'object' && 'module' in adapter;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isIngestAdapter(
|
|
155
|
+
adapter: WorldAdapter,
|
|
156
|
+
): adapter is Extract<WorldAdapter, { ingest: { strategy: IngestStrategy } }> {
|
|
157
|
+
return typeof adapter === 'object' && 'ingest' in adapter;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Cross-field checks (§3 — beyond what Zod alone enforces)
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
//
|
|
164
|
+
// D-V6 (docs/WAVE5-MULTIWORLD-INGEST-DESIGN.md): world-id uniqueness used to
|
|
165
|
+
// be a cross-field check HERE (`checkUniqueWorldIds`, removed) — it moved
|
|
166
|
+
// INTO `GameManifestSchema`'s `worlds` `superRefine` (schema.ts) instead, so
|
|
167
|
+
// the generated JSON Schema and any other schema consumer see the same
|
|
168
|
+
// constraint the loader always enforced. `parseManifest` below catches that
|
|
169
|
+
// one recognizable schema-level issue and rethrows it as a clean, single
|
|
170
|
+
// `Error` — the exact message shape `checkUniqueWorldIds` used to throw
|
|
171
|
+
// directly — rather than a raw multi-issue `ZodError` dump, so existing
|
|
172
|
+
// callers/tests that pattern-match the message text are unaffected by the
|
|
173
|
+
// enforcement moving one layer down.
|
|
174
|
+
|
|
175
|
+
function checkAdapterSceneEntryRules(world: WorldEntry): void {
|
|
176
|
+
const { adapter } = world;
|
|
177
|
+
if (adapter === 'default' && !world.scene && !world.entry) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`Game manifest: world "${world.id}" uses the 'default' adapter but declares neither ` +
|
|
180
|
+
'`scene` nor `entry` — a default-adapter world requires one of the two (§3).',
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
if (isIngestAdapter(adapter) && world.scene !== undefined) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`Game manifest: world "${world.id}" uses an { ingest } adapter but declares \`scene\` — ` +
|
|
186
|
+
'ingest adapters forbid `scene` (adapt-never-migrate: ingested games keep their own ' +
|
|
187
|
+
"formats; overlay persistence is T3.2's) (§3).",
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Historical note (Track N, N1): this file used to reject EVERY `kind:
|
|
193
|
+
// 'react'` + `{ ingest }` world outright here (`checkReactAdapterRestriction`,
|
|
194
|
+
// citing "no react ingest program is un-parked"). Track N un-parks exactly
|
|
195
|
+
// that program (docs/REACT-INGEST-LANDING-DESIGN.md) — react+ingest is now a
|
|
196
|
+
// structurally legal combination, resolved to the `'ingest-react'` identity
|
|
197
|
+
// by `resolveAdapter` below like any other ingest world. There is nothing
|
|
198
|
+
// left to reject at THIS layer: `checkAdapterSceneEntryRules` above already
|
|
199
|
+
// forbids `scene` on any `{ ingest }`-adapter world (kind-agnostic), and the
|
|
200
|
+
// v1 "deduped strategy only" restriction is an editor-resolver concern
|
|
201
|
+
// (D-N3), not a manifest-shape concern — see the `ResolvedAdapter` type's
|
|
202
|
+
// comment above.
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Resolution (§5)
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
function resolveAdapter(world: WorldEntry): ResolvedAdapter {
|
|
209
|
+
const { adapter, kind } = world;
|
|
210
|
+
|
|
211
|
+
if (adapter === 'default') {
|
|
212
|
+
const identity =
|
|
213
|
+
kind === 'threejs' ? 'default-three' : kind === 'pixijs' ? 'default-pixi' : 'default-react';
|
|
214
|
+
return { type: 'default', identity };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (isModuleAdapter(adapter)) {
|
|
218
|
+
return { type: 'module', identity: 'module', module: adapter.module };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// isIngestAdapter (Track N, N1: 'ingest-react' joins 'ingest-three'/
|
|
222
|
+
// 'ingest-pixi' — see the `ResolvedAdapter` type's comment above for why
|
|
223
|
+
// this stays strategy-agnostic here).
|
|
224
|
+
const identity =
|
|
225
|
+
kind === 'threejs' ? 'ingest-three' : kind === 'pixijs' ? 'ingest-pixi' : 'ingest-react';
|
|
226
|
+
const ingest = adapter.ingest;
|
|
227
|
+
return {
|
|
228
|
+
type: 'ingest',
|
|
229
|
+
identity,
|
|
230
|
+
strategy: ingest.strategy,
|
|
231
|
+
entryHtml: ingest.entryHtml,
|
|
232
|
+
assets: ingest.assets,
|
|
233
|
+
domStubs: ingest.domStubs,
|
|
234
|
+
captureTimeoutMs: ingest.captureTimeoutMs,
|
|
235
|
+
bundleUrl: ingest.bundleUrl,
|
|
236
|
+
baseHref: ingest.baseHref,
|
|
237
|
+
assetBaseUrl: ingest.assetBaseUrl,
|
|
238
|
+
extraDeps: ingest.extraDeps,
|
|
239
|
+
pixiModuleUrl: ingest.pixiModuleUrl,
|
|
240
|
+
bodyHtml: ingest.bodyHtml,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function ceilingFor(adapter: WorldAdapter, context: 'local' | 'hosted'): Tier {
|
|
245
|
+
if (adapter === 'default') return CAPABILITY_CEILINGS.default[context];
|
|
246
|
+
if (isModuleAdapter(adapter)) return CAPABILITY_CEILINGS.module[context];
|
|
247
|
+
return CAPABILITY_CEILINGS.ingest[adapter.ingest.strategy][context];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function resolveCapabilities(world: WorldEntry): { local: Tier; hosted: Tier } {
|
|
251
|
+
const contexts = ['local', 'hosted'] as const;
|
|
252
|
+
const result = {} as { local: Tier; hosted: Tier };
|
|
253
|
+
|
|
254
|
+
for (const context of contexts) {
|
|
255
|
+
const declared = world.capabilities?.[context];
|
|
256
|
+
if (declared !== undefined) {
|
|
257
|
+
const ceiling = ceilingFor(world.adapter, context);
|
|
258
|
+
if (TIER_RANK[declared] > TIER_RANK[ceiling]) {
|
|
259
|
+
throw new Error(
|
|
260
|
+
`Game manifest: world "${world.id}": declared capabilities.${context} tier "${declared}" ` +
|
|
261
|
+
`exceeds the "${ceiling}" ceiling for this adapter in the ${context} context (§4). ` +
|
|
262
|
+
'Under-claiming (including `unsupported`) is legal; over-claiming is not.',
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
result[context] = declared;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Omitted -> derive.
|
|
270
|
+
if (world.adapter === 'default') {
|
|
271
|
+
result[context] = 'first-party';
|
|
272
|
+
} else if (isModuleAdapter(world.adapter)) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
`Game manifest: world "${world.id}": { module } adapter requires an explicit ` +
|
|
275
|
+
`capabilities.${context} declaration — a custom adapter's reach is not derivable (§4).`,
|
|
276
|
+
);
|
|
277
|
+
} else {
|
|
278
|
+
result[context] = CAPABILITY_CEILINGS.ingest[world.adapter.ingest.strategy][context];
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return result;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function checkEngineVersionPin(version: string): void {
|
|
286
|
+
if (!SEMVER_RE.test(version)) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`Game manifest: engine.version "${version}" is not a valid exact semver string (e.g. ` +
|
|
289
|
+
'"0.1.0") — §1.5 requires an exact identity pin, not a range.',
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Resolve one already-Zod-validated world entry into its `ResolvedWorldEntry`
|
|
296
|
+
* (adapter identity, capabilities, and a pass-through of every other field).
|
|
297
|
+
* Every `WorldEntry` field participates here or in a check above:
|
|
298
|
+
* `id`/`kind` drive adapter resolution + uniqueness; `adapter` (incl. its
|
|
299
|
+
* `module`/`ingest.*` sub-fields) drives identity + capability-ceiling
|
|
300
|
+
* resolution; `scene`/`entry` drive the scene/entry cross-field rule and are
|
|
301
|
+
* carried through; `zOrder` drives the final sort; `pausable`/`loop` are
|
|
302
|
+
* carried through as-is (their consumers are the runtime/CLI, T3.2/T3.3);
|
|
303
|
+
* `capabilities.{local,hosted}` drive tier resolution; `description` (T3.3
|
|
304
|
+
* slice 3) is carried through as-is — its consumer today is
|
|
305
|
+
* `resolveIngestDescriptor` (packages/editor/src/adapter-resolver.ts), which
|
|
306
|
+
* threads an ingest-three world's description into `IngestGame.description`.
|
|
307
|
+
*/
|
|
308
|
+
function resolveWorld(world: WorldEntry): ResolvedWorldEntry {
|
|
309
|
+
checkAdapterSceneEntryRules(world);
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
id: world.id,
|
|
313
|
+
kind: world.kind,
|
|
314
|
+
description: world.description,
|
|
315
|
+
adapter: resolveAdapter(world),
|
|
316
|
+
scene: world.scene,
|
|
317
|
+
entry: world.entry,
|
|
318
|
+
zOrder: world.zOrder,
|
|
319
|
+
pausable: world.pausable,
|
|
320
|
+
loop: world.loop,
|
|
321
|
+
capabilities: resolveCapabilities(world),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// Entry point
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Parse + validate + resolve a raw manifest value into a `ResolvedGameManifest`.
|
|
331
|
+
* Pure data-in/data-out (no `fs`) — see `load-file.ts` for the Node path-based
|
|
332
|
+
* wrapper. Throws a descriptive `Error` (naming the offending world/field) on
|
|
333
|
+
* any Zod validation failure or cross-field rule violation.
|
|
334
|
+
*/
|
|
335
|
+
export function loadGameManifest(raw: unknown): ResolvedGameManifest {
|
|
336
|
+
const parsed = GameManifestSchema.safeParse(raw);
|
|
337
|
+
if (!parsed.success) {
|
|
338
|
+
// D-V6: the `worlds` `superRefine`'s duplicate-id issue is the one
|
|
339
|
+
// schema-level violation this loader still surfaces as a clean, single
|
|
340
|
+
// `Error` (matching `checkUniqueWorldIds`'s pre-D-V6 message shape) —
|
|
341
|
+
// every other schema violation keeps propagating as the raw `ZodError`
|
|
342
|
+
// it always has (unchanged behavior, e.g. the iframe-reachable
|
|
343
|
+
// field-exclusivity / manifestVersion checks).
|
|
344
|
+
const dupIssue = parsed.error.issues.find((issue) =>
|
|
345
|
+
issue.message.startsWith('Game manifest: duplicate world id'),
|
|
346
|
+
);
|
|
347
|
+
if (dupIssue) throw new Error(dupIssue.message);
|
|
348
|
+
throw parsed.error;
|
|
349
|
+
}
|
|
350
|
+
const manifest: GameManifest = parsed.data;
|
|
351
|
+
|
|
352
|
+
checkEngineVersionPin(manifest.engine.version);
|
|
353
|
+
|
|
354
|
+
const resolvedWithIndex = manifest.worlds.map((world, index) => ({
|
|
355
|
+
world: resolveWorld(world),
|
|
356
|
+
index,
|
|
357
|
+
}));
|
|
358
|
+
|
|
359
|
+
resolvedWithIndex.sort(
|
|
360
|
+
(a, b) => a.world.zOrder - b.world.zOrder || a.index - b.index, // ties -> array order (§3)
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
const worlds = resolvedWithIndex.map(({ world }) => world);
|
|
364
|
+
|
|
365
|
+
const server = manifest.server
|
|
366
|
+
? { room: manifest.server.room, module: manifest.server.module ?? DEFAULT_SERVER_MODULE }
|
|
367
|
+
: undefined;
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
manifestVersion: manifest.manifestVersion,
|
|
371
|
+
name: manifest.name,
|
|
372
|
+
version: manifest.version,
|
|
373
|
+
engine: { version: manifest.engine.version },
|
|
374
|
+
worlds,
|
|
375
|
+
server,
|
|
376
|
+
resolution: manifest.resolution,
|
|
377
|
+
};
|
|
378
|
+
}
|