pelican-engine 0.0.0-stage → 0.3.1
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/AGENTS.md +147 -0
- package/LICENSE +22 -0
- package/README.md +129 -2
- package/distribution.json +197 -0
- package/docs/AI-WORKFLOW.md +517 -0
- package/docs/ARCHITECTURE.md +268 -0
- package/docs/CAPABILITIES.md +264 -0
- package/docs/DECISIONS.md +107 -0
- package/docs/M3-USAGE.md +192 -0
- package/docs/PELICAN-MIGRATION.md +38 -0
- package/docs/RENDER-BACKENDS.md +50 -0
- package/docs/ROADMAP.md +239 -0
- package/docs/THIRD-PARTY-NOTICES.md +151 -0
- package/docs/api/REFERENCE.md +1435 -0
- package/docs/api/index.json +1139 -0
- package/docs/npm/DISTRIBUTION.md +130 -0
- package/docs/npm/GAME-AUTHOR.md +147 -0
- package/engine.manifest.json +15 -0
- package/lib/assets/audio.js +67 -0
- package/lib/assets/errors.js +36 -0
- package/lib/assets/glb.js +277 -0
- package/lib/assets/index.js +53 -0
- package/lib/assets/jpeg.js +199 -0
- package/lib/assets/model.js +558 -0
- package/lib/assets/optimize.js +95 -0
- package/lib/assets/texture.js +187 -0
- package/lib/compiler/distribution.js +17 -0
- package/lib/compiler/extract.js +425 -0
- package/lib/compiler/graph.js +366 -0
- package/lib/compiler/index.js +574 -0
- package/lib/compiler/normalize.js +1194 -0
- package/lib/compiler/source-map.js +25 -0
- package/lib/compiler/types.js +2 -0
- package/lib/compiler/version.js +93 -0
- package/lib/experiments/simulation/index.js +2 -0
- package/lib/experiments/simulation/math.js +41 -0
- package/lib/experiments/simulation/probe.js +215 -0
- package/lib/experiments/simulation/scenes.js +192 -0
- package/lib/experiments/simulation/solver.js +346 -0
- package/lib/experiments/simulation/types.js +1 -0
- package/lib/experiments/world/adapters.js +30 -0
- package/lib/experiments/world/geometry.js +180 -0
- package/lib/experiments/world/index.js +2 -0
- package/lib/experiments/world/probe.js +288 -0
- package/lib/experiments/world/types.js +1 -0
- package/lib/experiments/world/world.js +599 -0
- package/lib/navigation/index.js +157 -0
- package/lib/physics/index.js +909 -0
- package/lib/protocol/index.js +92 -0
- package/lib/renderer/acceleration.js +609 -0
- package/lib/renderer/browser.js +2015 -0
- package/lib/renderer/bsdf.js +260 -0
- package/lib/renderer/checkpoint.js +88 -0
- package/lib/renderer/compute-executor.js +1034 -0
- package/lib/renderer/compute-texture-assets.js +49 -0
- package/lib/renderer/compute-view-contract.js +1 -0
- package/lib/renderer/denoiser.js +361 -0
- package/lib/renderer/dielectric.js +360 -0
- package/lib/renderer/embedded-image-pixels.js +368 -0
- package/lib/renderer/emissive-lights.js +67 -0
- package/lib/renderer/environment-importance.js +55 -0
- package/lib/renderer/environment.js +346 -0
- package/lib/renderer/gameplay.js +464 -0
- package/lib/renderer/geometry.js +103 -0
- package/lib/renderer/graph-expressions.js +485 -0
- package/lib/renderer/graph-tsl.js +141 -0
- package/lib/renderer/graph.js +214 -0
- package/lib/renderer/managed-texture-sampling.js +29 -0
- package/lib/renderer/material-graphs.js +297 -0
- package/lib/renderer/medium.js +348 -0
- package/lib/renderer/models.js +1053 -0
- package/lib/renderer/motion-shader.js +40 -0
- package/lib/renderer/path-shader.js +196 -0
- package/lib/renderer/pathtrace.js +324 -0
- package/lib/renderer/postprocess-contract.js +1 -0
- package/lib/renderer/postprocess.js +494 -0
- package/lib/renderer/render-buffers.js +132 -0
- package/lib/renderer/render-textures.js +155 -0
- package/lib/renderer/startup.js +50 -0
- package/lib/renderer/surface-capture.js +199 -0
- package/lib/renderer/surface-gpu.js +270 -0
- package/lib/renderer/surface-motion.js +84 -0
- package/lib/renderer/surface-ray-shader.js +91 -0
- package/lib/renderer/surface-shader.js +88 -0
- package/lib/renderer/surface-tracer.js +1720 -0
- package/lib/renderer/surface.js +1425 -0
- package/lib/renderer/temporal-filter.js +512 -0
- package/lib/renderer/texture-pixels.js +207 -0
- package/lib/renderer/traversal-nodes.js +54 -0
- package/lib/renderer/ui.js +446 -0
- package/lib/renderer/vertex-bounds.js +127 -0
- package/lib/renderer/vertex-contract.js +1 -0
- package/lib/renderer/vertex-graphs.js +197 -0
- package/lib/renderer/volume-shader.js +50 -0
- package/lib/runtime/compute-contract.js +1 -0
- package/lib/runtime/compute-service.js +674 -0
- package/lib/runtime/contracts.js +1 -0
- package/lib/runtime/error.js +37 -0
- package/lib/runtime/events.js +189 -0
- package/lib/runtime/game-services.js +772 -0
- package/lib/runtime/host.js +839 -0
- package/lib/runtime/material-parameters.js +145 -0
- package/lib/runtime/plugins.js +1 -0
- package/lib/runtime/postprocess-contract.js +1 -0
- package/lib/runtime/postprocess.js +189 -0
- package/lib/runtime/prefabs.js +13 -0
- package/lib/runtime/random.js +88 -0
- package/lib/runtime/render-buffer-contract.js +1 -0
- package/lib/runtime/render-buffers.js +243 -0
- package/lib/runtime/render-capabilities.js +17 -0
- package/lib/runtime/render-settings.js +249 -0
- package/lib/runtime/render-texture-contract.js +1 -0
- package/lib/runtime/render-textures.js +271 -0
- package/lib/runtime/resources.js +648 -0
- package/lib/runtime/scheduler.js +765 -0
- package/lib/runtime/storage.js +169 -0
- package/lib/runtime/world.js +594 -0
- package/lib/schema/index.js +24 -0
- package/lib/sdk/graph-values.js +16 -0
- package/lib/sdk/index.d.ts +800 -0
- package/lib/sdk/index.js +82 -0
- package/lib/tools/api-index.js +113 -0
- package/lib/tools/browser-session.js +89 -0
- package/lib/tools/cli.js +513 -0
- package/lib/tools/compress-dist.js +59 -0
- package/lib/tools/control-client.js +153 -0
- package/lib/tools/control-server.js +633 -0
- package/lib/tools/diagnostic-code.js +3 -0
- package/lib/tools/doctor.js +496 -0
- package/lib/tools/npm/browser-cli.js +6 -0
- package/lib/tools/npm/entry.js +40 -0
- package/lib/tools/npm/layout.js +195 -0
- package/lib/tools/setup.js +153 -0
- package/licenses/@dimforge/rapier2d-compat/LICENSE +201 -0
- package/licenses/@dimforge/rapier3d-compat/LICENSE +201 -0
- package/licenses/@gltf-transform/core/LICENSE.md +21 -0
- package/licenses/@gltf-transform/extensions/LICENSE.md +21 -0
- package/licenses/@gltf-transform/functions/LICENSE.md +21 -0
- package/licenses/@img/colour/LICENSE.md +82 -0
- package/licenses/@img/sharp-win32-x64/LICENSE +191 -0
- package/licenses/@oxc-project/types/LICENSE +22 -0
- package/licenses/@rolldown/pluginutils/LICENSE +21 -0
- package/licenses/@tweenjs/tween.js/LICENSE +23 -0
- package/licenses/@types/ndarray/LICENSE +21 -0
- package/licenses/@types/node/LICENSE +21 -0
- package/licenses/@types/stats.js/LICENSE +21 -0
- package/licenses/@types/three/@dimforge/rapier3d-compat/LICENSE +201 -0
- package/licenses/@types/three/LICENSE +21 -0
- package/licenses/@types/webxr/LICENSE +21 -0
- package/licenses/@webgpu/types/LICENSE +26 -0
- package/licenses/THIRD-PARTY-NOTICES.md +139 -0
- package/licenses/cwise-compiler/LICENSE +22 -0
- package/licenses/detect-libc/LICENSE +201 -0
- package/licenses/esbuild/LICENSE.md +21 -0
- package/licenses/fast-png/LICENSE +21 -0
- package/licenses/fdir/LICENSE +7 -0
- package/licenses/fflate/LICENSE +21 -0
- package/licenses/iobuffer/LICENSE +22 -0
- package/licenses/iota-array/LICENSE +22 -0
- package/licenses/is-buffer/LICENSE +21 -0
- package/licenses/ktx-parse/LICENSE +21 -0
- package/licenses/lightningcss/LICENSE +373 -0
- package/licenses/lightningcss-win32-x64-msvc/LICENSE +373 -0
- package/licenses/meshoptimizer/LICENSE.md +21 -0
- package/licenses/nanoid/LICENSE +20 -0
- package/licenses/ndarray/LICENSE +22 -0
- package/licenses/ndarray-lanczos/LICENSE +21 -0
- package/licenses/ndarray-ops/LICENSE +22 -0
- package/licenses/ndarray-pixels/LICENSE +21 -0
- package/licenses/ngraph.path/LICENSE +21 -0
- package/licenses/picocolors/LICENSE +15 -0
- package/licenses/picomatch/LICENSE +21 -0
- package/licenses/playwright/LICENSE +202 -0
- package/licenses/playwright/NOTICE +5 -0
- package/licenses/playwright-core/LICENSE +202 -0
- package/licenses/playwright-core/NOTICE +5 -0
- package/licenses/postcss/LICENSE +20 -0
- package/licenses/prettier/LICENSE +7 -0
- package/licenses/property-graph/LICENSE +21 -0
- package/licenses/rolldown/LICENSE +25 -0
- package/licenses/semver/LICENSE +15 -0
- package/licenses/sharp/LICENSE +191 -0
- package/licenses/source-map-js/LICENSE +28 -0
- package/licenses/three/LICENSE +21 -0
- package/licenses/tinyglobby/LICENSE +21 -0
- package/licenses/tsx/LICENSE +21 -0
- package/licenses/typescript/LICENSE.txt +55 -0
- package/licenses/undici-types/LICENSE +21 -0
- package/licenses/uniq/LICENSE +22 -0
- package/licenses/vite/LICENSE.md +2084 -0
- package/package.json +49 -4
- package/web/index.html +34 -0
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
import { SDK_VERSION, } from "../sdk/index.js";
|
|
2
|
+
import { createWorld } from "./world.js";
|
|
3
|
+
import { createScheduler, } from "./scheduler.js";
|
|
4
|
+
import { cloneSerializable } from "./random.js";
|
|
5
|
+
import { resolveEntityDefinition } from "./prefabs.js";
|
|
6
|
+
import { diagnosticFrom } from "./error.js";
|
|
7
|
+
function canonical(value) {
|
|
8
|
+
const sorted = (item) => Array.isArray(item)
|
|
9
|
+
? item.map(sorted)
|
|
10
|
+
: item && typeof item === "object"
|
|
11
|
+
? Object.fromEntries(Object.entries(item)
|
|
12
|
+
.filter(([, v]) => v !== undefined)
|
|
13
|
+
.sort(([a], [b]) => a.localeCompare(b, "en"))
|
|
14
|
+
.map(([k, v]) => [k, sorted(v)]))
|
|
15
|
+
: item;
|
|
16
|
+
return JSON.stringify(sorted(value));
|
|
17
|
+
}
|
|
18
|
+
function metadata(system) {
|
|
19
|
+
return {
|
|
20
|
+
id: system.id,
|
|
21
|
+
phase: system.phase ?? "update",
|
|
22
|
+
before: [...(system.before ?? [])],
|
|
23
|
+
after: [...(system.after ?? [])],
|
|
24
|
+
...(system.reads === undefined ? {} : { reads: [...system.reads] }),
|
|
25
|
+
...(system.writes === undefined ? {} : { writes: [...system.writes] }),
|
|
26
|
+
...(system.structural === undefined
|
|
27
|
+
? {}
|
|
28
|
+
: { structural: system.structural }),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** Authoritative CPU world; renderers consume copies. A configuration change needs a new host. */
|
|
32
|
+
export class RuntimeHost {
|
|
33
|
+
world;
|
|
34
|
+
facade;
|
|
35
|
+
scene;
|
|
36
|
+
scheduler;
|
|
37
|
+
systemIdentity;
|
|
38
|
+
bindings = [];
|
|
39
|
+
game;
|
|
40
|
+
errors = [];
|
|
41
|
+
released = false;
|
|
42
|
+
fixedDt;
|
|
43
|
+
seed;
|
|
44
|
+
plugins = [];
|
|
45
|
+
services = {};
|
|
46
|
+
recordingPlugins;
|
|
47
|
+
asyncActive = false;
|
|
48
|
+
drivingAsync = false;
|
|
49
|
+
disposing = false;
|
|
50
|
+
constructor(ir, options = {}) {
|
|
51
|
+
if (ir.format !== "pelican.scene" || ir.version !== 1)
|
|
52
|
+
throw new Error("TC_RUNTIME_IR_VERSION: Unsupported scene representation.");
|
|
53
|
+
if (ir.engineVersion !== SDK_VERSION)
|
|
54
|
+
throw new Error("TC_RUNTIME_ENGINE_VERSION: Compiled content must match the active engine.");
|
|
55
|
+
this.fixedDt = options.fixedDt ?? 1 / 60;
|
|
56
|
+
this.seed = options.seed ?? 12345;
|
|
57
|
+
this.scene = structuredClone(ir);
|
|
58
|
+
const world = createWorld({
|
|
59
|
+
components: ir.components.map((component) => ({
|
|
60
|
+
id: component.id,
|
|
61
|
+
schema: component.schema,
|
|
62
|
+
})),
|
|
63
|
+
entities: ir.scene.entities.map((entity) => ({
|
|
64
|
+
id: entity.id,
|
|
65
|
+
prefab: entity.prefab,
|
|
66
|
+
parent: entity.parent,
|
|
67
|
+
transform: entity.transform,
|
|
68
|
+
components: entity.components,
|
|
69
|
+
})),
|
|
70
|
+
});
|
|
71
|
+
const mutations = new Set([
|
|
72
|
+
"registerComponent",
|
|
73
|
+
"spawn",
|
|
74
|
+
"destroy",
|
|
75
|
+
"setTransform",
|
|
76
|
+
"setComponent",
|
|
77
|
+
"addComponent",
|
|
78
|
+
"removeComponent",
|
|
79
|
+
"queue",
|
|
80
|
+
"flush",
|
|
81
|
+
"runSystem",
|
|
82
|
+
"restore",
|
|
83
|
+
"dispose",
|
|
84
|
+
]);
|
|
85
|
+
this.world = new Proxy(world, {
|
|
86
|
+
get: (target, key) => {
|
|
87
|
+
const value = target[key];
|
|
88
|
+
if (!mutations.has(key))
|
|
89
|
+
return value;
|
|
90
|
+
return (...args) => {
|
|
91
|
+
this.assertAsyncMutation();
|
|
92
|
+
return Reflect.apply(value, target, args);
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
let createdScheduler;
|
|
97
|
+
try {
|
|
98
|
+
createdScheduler = createScheduler({
|
|
99
|
+
world: this.world,
|
|
100
|
+
fixedDt: this.fixedDt,
|
|
101
|
+
seed: this.seed,
|
|
102
|
+
});
|
|
103
|
+
this.scheduler = createdScheduler;
|
|
104
|
+
for (const factory of options.plugins ?? []) {
|
|
105
|
+
const plugin = factory({
|
|
106
|
+
world: this.world,
|
|
107
|
+
scene: this.scene,
|
|
108
|
+
events: (type) => this.scheduler.events(type),
|
|
109
|
+
emit: (type, payload) => this.scheduler.emit(type, payload, { source: "system" }),
|
|
110
|
+
});
|
|
111
|
+
this.registerPlugin(plugin);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
const failures = [];
|
|
116
|
+
for (const plugin of [...this.plugins].reverse()) {
|
|
117
|
+
try {
|
|
118
|
+
plugin.dispose();
|
|
119
|
+
}
|
|
120
|
+
catch (disposeError) {
|
|
121
|
+
failures.push(String(disposeError));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
createdScheduler?.dispose();
|
|
126
|
+
}
|
|
127
|
+
catch (disposeError) {
|
|
128
|
+
failures.push(String(disposeError));
|
|
129
|
+
}
|
|
130
|
+
this.world.dispose();
|
|
131
|
+
if (failures.length)
|
|
132
|
+
throw new Error(`TC_PLUGIN_INIT: ${String(error)}; cleanup failures: ${failures.join("; ")}`);
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
const host = this;
|
|
136
|
+
const id = (component) => typeof component === "string" ? component : component.id;
|
|
137
|
+
this.facade = {
|
|
138
|
+
getTransform: (entity) => this.world.getTransform(entity),
|
|
139
|
+
setTransform: (entity, value) => this.world.setTransform(entity, value),
|
|
140
|
+
getComponent(entity, component) {
|
|
141
|
+
return host.world.getComponent(entity, id(component));
|
|
142
|
+
},
|
|
143
|
+
setComponent: (entity, component, value) => this.world.setComponent(entity, id(component), value),
|
|
144
|
+
query: (component) => this.world.query(id(component)),
|
|
145
|
+
runGameSystems: (systems, dt) => this.runSystems(systems, dt),
|
|
146
|
+
configureGameSystems: (systems) => this.configureSystems(systems),
|
|
147
|
+
get time() {
|
|
148
|
+
host.assertAlive();
|
|
149
|
+
return {
|
|
150
|
+
tick: host.scheduler.tick,
|
|
151
|
+
elapsed: host.scheduler.time,
|
|
152
|
+
fixedDt: host.fixedDt,
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
random: () => {
|
|
156
|
+
this.assertMutation();
|
|
157
|
+
return this.scheduler.random();
|
|
158
|
+
},
|
|
159
|
+
emit: (type, payload) => {
|
|
160
|
+
this.assertMutation();
|
|
161
|
+
this.scheduler.emit(type, payload, { source: "system" });
|
|
162
|
+
},
|
|
163
|
+
events: (type) => {
|
|
164
|
+
this.assertAlive();
|
|
165
|
+
return this.scheduler.events(type);
|
|
166
|
+
},
|
|
167
|
+
get input() {
|
|
168
|
+
return host.service("input");
|
|
169
|
+
},
|
|
170
|
+
get physics() {
|
|
171
|
+
return host.service("physics");
|
|
172
|
+
},
|
|
173
|
+
get animation() {
|
|
174
|
+
return host.service("animation");
|
|
175
|
+
},
|
|
176
|
+
get camera() {
|
|
177
|
+
return host.service("camera");
|
|
178
|
+
},
|
|
179
|
+
get ui() {
|
|
180
|
+
return host.service("ui");
|
|
181
|
+
},
|
|
182
|
+
get audio() {
|
|
183
|
+
return host.service("audio");
|
|
184
|
+
},
|
|
185
|
+
get storage() {
|
|
186
|
+
return host.service("storage");
|
|
187
|
+
},
|
|
188
|
+
get scenes() {
|
|
189
|
+
return host.service("scenes");
|
|
190
|
+
},
|
|
191
|
+
get render() {
|
|
192
|
+
return host.service("render");
|
|
193
|
+
},
|
|
194
|
+
get compute() {
|
|
195
|
+
return host.service("compute");
|
|
196
|
+
},
|
|
197
|
+
get navigation() {
|
|
198
|
+
return host.service("navigation");
|
|
199
|
+
},
|
|
200
|
+
entities: {
|
|
201
|
+
resolve: (entity) => this.world.resolve(entity),
|
|
202
|
+
isAlive: (entity) => this.world.isAlive(entity),
|
|
203
|
+
getTransform: (entity) => this.world.getTransform(entity),
|
|
204
|
+
setTransform: (entity, value) => this.world.setTransform(entity, value),
|
|
205
|
+
getComponent: (entity, component) => this.world.getComponent(entity, component),
|
|
206
|
+
setComponent: (entity, component, value) => this.world.setComponent(entity, component, value),
|
|
207
|
+
spawn: (entity) => {
|
|
208
|
+
const captured = cloneSerializable(entity);
|
|
209
|
+
if (captured.prefab !== undefined) {
|
|
210
|
+
const definition = this.scene.prefabs.find((entry) => entry.id === captured.prefab);
|
|
211
|
+
if (!definition)
|
|
212
|
+
throw new Error("TC_PREFAB_MISSING: " + captured.prefab);
|
|
213
|
+
this.world.queue({
|
|
214
|
+
type: "spawn",
|
|
215
|
+
entity: {
|
|
216
|
+
id: captured.id,
|
|
217
|
+
prefab: captured.prefab,
|
|
218
|
+
...(captured.parent === undefined
|
|
219
|
+
? {}
|
|
220
|
+
: { parent: captured.parent }),
|
|
221
|
+
transform: {
|
|
222
|
+
...structuredClone(definition.transform),
|
|
223
|
+
...captured.transform,
|
|
224
|
+
},
|
|
225
|
+
components: Object.fromEntries([
|
|
226
|
+
...new Set([
|
|
227
|
+
...Object.keys(definition.components),
|
|
228
|
+
...Object.keys(captured.components ?? {}),
|
|
229
|
+
]),
|
|
230
|
+
].map((id) => [
|
|
231
|
+
id,
|
|
232
|
+
{
|
|
233
|
+
...definition.components[id],
|
|
234
|
+
...captured.components?.[id],
|
|
235
|
+
},
|
|
236
|
+
])),
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
else
|
|
241
|
+
this.world.queue({ type: "spawn", entity: captured });
|
|
242
|
+
},
|
|
243
|
+
destroy: (entity) => this.world.queue({ type: "destroy", entity }),
|
|
244
|
+
addComponent: (entity, component, data) => this.world.queue({
|
|
245
|
+
type: "addComponent",
|
|
246
|
+
entity,
|
|
247
|
+
component,
|
|
248
|
+
...(data === undefined ? {} : { data }),
|
|
249
|
+
}),
|
|
250
|
+
removeComponent: (entity, component) => this.world.queue({ type: "removeComponent", entity, component }),
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
registerPlugin(plugin) {
|
|
255
|
+
if (!plugin || typeof plugin !== "object")
|
|
256
|
+
throw new Error("TC_PLUGIN_ID: Plugin must have a stable non-empty id.");
|
|
257
|
+
if (this.plugins.some((entry) => entry.id === plugin.id))
|
|
258
|
+
throw new Error(`TC_PLUGIN_DUPLICATE: Duplicate plugin id ${plugin.id}.`);
|
|
259
|
+
this.plugins.push(plugin);
|
|
260
|
+
if (typeof plugin.id !== "string" || !plugin.id.trim())
|
|
261
|
+
throw new Error("TC_PLUGIN_ID: Plugin must have a stable non-empty id.");
|
|
262
|
+
if (typeof plugin.snapshot !== "function" ||
|
|
263
|
+
typeof plugin.validateSnapshot !== "function" ||
|
|
264
|
+
typeof plugin.restore !== "function" ||
|
|
265
|
+
typeof plugin.dispose !== "function" ||
|
|
266
|
+
(plugin.settle !== undefined && typeof plugin.settle !== "function"))
|
|
267
|
+
throw new Error(`TC_PLUGIN_CONTRACT: Plugin ${plugin.id} is missing lifecycle methods.`);
|
|
268
|
+
for (const [key, service] of Object.entries(plugin.services ?? {})) {
|
|
269
|
+
if (service === undefined)
|
|
270
|
+
continue;
|
|
271
|
+
if (this.services[key] !== undefined)
|
|
272
|
+
throw new Error(`TC_SERVICE_DUPLICATE: Multiple plugins provide ${key}.`);
|
|
273
|
+
const nested = new WeakMap();
|
|
274
|
+
const guarded = (value) => {
|
|
275
|
+
const previous = nested.get(value);
|
|
276
|
+
if (previous)
|
|
277
|
+
return previous;
|
|
278
|
+
const proxy = new Proxy(value, {
|
|
279
|
+
get: (target, property) => {
|
|
280
|
+
const member = Reflect.get(target, property);
|
|
281
|
+
if (key === "render" && (property === "effects" || property === "buffers" || property === "textures") && member && typeof member === "object")
|
|
282
|
+
return guarded(member);
|
|
283
|
+
if (typeof member !== "function")
|
|
284
|
+
return member;
|
|
285
|
+
return (...args) => {
|
|
286
|
+
this.assertAsyncMutation();
|
|
287
|
+
return Reflect.apply(member, target, args);
|
|
288
|
+
};
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
nested.set(value, proxy);
|
|
292
|
+
return proxy;
|
|
293
|
+
};
|
|
294
|
+
this.services[key] = guarded(service);
|
|
295
|
+
}
|
|
296
|
+
for (const system of plugin.systems ?? [])
|
|
297
|
+
if (!system.id.startsWith("$engine."))
|
|
298
|
+
throw new Error(`TC_PLUGIN_SYSTEM_ID: Engine system ${system.id} must use the $engine. prefix.`);
|
|
299
|
+
}
|
|
300
|
+
service(key) {
|
|
301
|
+
this.assertMutation();
|
|
302
|
+
const service = this.services[key];
|
|
303
|
+
if (service === undefined)
|
|
304
|
+
throw new Error(`TC_SERVICE_UNAVAILABLE: No runtime plugin provides '${key}'.`);
|
|
305
|
+
return service;
|
|
306
|
+
}
|
|
307
|
+
pluginSnapshot() {
|
|
308
|
+
const result = {};
|
|
309
|
+
for (const plugin of this.plugins) {
|
|
310
|
+
const payload = cloneSerializable(plugin.snapshot());
|
|
311
|
+
const version = plugin.version;
|
|
312
|
+
if (version !== undefined && typeof version !== "string")
|
|
313
|
+
throw new Error(`TC_PLUGIN_VERSION: Plugin ${plugin.id} version must be a string.`);
|
|
314
|
+
result[plugin.id] = {
|
|
315
|
+
...(version === undefined ? {} : { version }),
|
|
316
|
+
payload,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
return result;
|
|
320
|
+
}
|
|
321
|
+
validatePlugins(value) {
|
|
322
|
+
const snapshots = cloneSerializable(value);
|
|
323
|
+
if (!snapshots || typeof snapshots !== "object" || Array.isArray(snapshots))
|
|
324
|
+
throw new Error("TC_PLUGIN_SNAPSHOT: Expected a plugin snapshot map.");
|
|
325
|
+
for (const plugin of this.plugins) {
|
|
326
|
+
if (!(plugin.id in snapshots) && plugin.legacySnapshot)
|
|
327
|
+
snapshots[plugin.id] = { ...(plugin.version === undefined ? {} : { version: plugin.version }), payload: plugin.legacySnapshot() };
|
|
328
|
+
}
|
|
329
|
+
const ids = this.plugins.map((plugin) => plugin.id).sort();
|
|
330
|
+
if (canonical(Object.keys(snapshots).sort()) !== canonical(ids))
|
|
331
|
+
throw new Error("TC_PLUGIN_SNAPSHOT_SET: Snapshot plugins do not match active plugins.");
|
|
332
|
+
for (const plugin of this.plugins) {
|
|
333
|
+
const entry = snapshots[plugin.id];
|
|
334
|
+
const version = plugin.version;
|
|
335
|
+
if (!entry ||
|
|
336
|
+
typeof entry !== "object" ||
|
|
337
|
+
!("payload" in entry) ||
|
|
338
|
+
entry.version !== version)
|
|
339
|
+
throw new Error(`TC_PLUGIN_SNAPSHOT_VERSION: Incompatible snapshot for plugin ${plugin.id}.`);
|
|
340
|
+
plugin.validateSnapshot(entry.payload);
|
|
341
|
+
}
|
|
342
|
+
for (const plugin of this.plugins)
|
|
343
|
+
plugin.validatePeers?.(snapshots[plugin.id].payload, snapshots);
|
|
344
|
+
return snapshots;
|
|
345
|
+
}
|
|
346
|
+
restorePlugins(snapshots) {
|
|
347
|
+
for (const plugin of this.plugins)
|
|
348
|
+
plugin.restore(cloneSerializable(snapshots[plugin.id].payload));
|
|
349
|
+
}
|
|
350
|
+
attach(game) {
|
|
351
|
+
this.assertBoundary();
|
|
352
|
+
if (!game ||
|
|
353
|
+
typeof game.update !== "function" ||
|
|
354
|
+
typeof game.dispose !== "function")
|
|
355
|
+
throw new Error("TC_RUNTIME_GAME: Expected a game instance.");
|
|
356
|
+
if (this.game && this.game !== game)
|
|
357
|
+
throw new Error("TC_RUNTIME_GAME_CHANGED: Create a new host to replace a game; clock/state are not implicitly reset.");
|
|
358
|
+
if (!this.systemIdentity)
|
|
359
|
+
throw new Error("TC_RUNTIME_NOT_CONFIGURED: Instantiate the game with host.facade before attach.");
|
|
360
|
+
this.game = game;
|
|
361
|
+
}
|
|
362
|
+
configureSystems(systems) {
|
|
363
|
+
this.assertBoundary();
|
|
364
|
+
if (this.systemIdentity) {
|
|
365
|
+
this.assertSystems(systems);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (this.scheduler.tick !== 0 || this.scheduler.inspect().recording)
|
|
369
|
+
throw new Error("TC_RUNTIME_SYSTEMS_CHANGED: Initial configuration requires a fresh host.");
|
|
370
|
+
const contracts = systems.map((system) => canonical(metadata(system)));
|
|
371
|
+
if (canonical(contracts) !==
|
|
372
|
+
canonical(this.scene.systems.map((system) => canonical(metadata(system)))))
|
|
373
|
+
throw new Error("TC_RUNTIME_SYSTEM_CONTRACT: Game system metadata must match compiled scene metadata.");
|
|
374
|
+
for (const system of systems)
|
|
375
|
+
if ([
|
|
376
|
+
"AsyncFunction",
|
|
377
|
+
"GeneratorFunction",
|
|
378
|
+
"AsyncGeneratorFunction",
|
|
379
|
+
].includes(system.run.constructor.name))
|
|
380
|
+
throw new Error("TC_RUNTIME_SYSTEM_ASYNC: Systems must be synchronous functions.");
|
|
381
|
+
for (const system of systems) {
|
|
382
|
+
if (system.id.startsWith("$engine."))
|
|
383
|
+
throw new Error("TC_RUNTIME_RESERVED_SYSTEM: Game system ids cannot use the $engine. prefix.");
|
|
384
|
+
if ([...(system.before ?? []), ...(system.after ?? [])].some((id) => id.startsWith("$engine.")))
|
|
385
|
+
throw new Error(`TC_RUNTIME_RESERVED_SYSTEM: Game system ${system.id} cannot reference engine systems.`);
|
|
386
|
+
}
|
|
387
|
+
const engineSystems = this.plugins.flatMap((plugin) => [
|
|
388
|
+
...(plugin.systems ?? []),
|
|
389
|
+
]);
|
|
390
|
+
const allIds = new Set();
|
|
391
|
+
for (const system of [...engineSystems, ...systems]) {
|
|
392
|
+
if (allIds.has(system.id))
|
|
393
|
+
throw new Error(`TC_RUNTIME_SYSTEM_COLLISION: Duplicate system id ${system.id}.`);
|
|
394
|
+
allIds.add(system.id);
|
|
395
|
+
}
|
|
396
|
+
const gameInputIds = systems
|
|
397
|
+
.filter((system) => (system.phase ?? "update") === "input")
|
|
398
|
+
.map((system) => system.id);
|
|
399
|
+
const gameSystems = systems.map((system) => {
|
|
400
|
+
const run = system.run;
|
|
401
|
+
return {
|
|
402
|
+
...metadata(system),
|
|
403
|
+
run: (delta) => run(this.facade, delta),
|
|
404
|
+
};
|
|
405
|
+
});
|
|
406
|
+
const candidate = createScheduler({
|
|
407
|
+
world: this.world,
|
|
408
|
+
fixedDt: this.fixedDt,
|
|
409
|
+
seed: this.seed,
|
|
410
|
+
systems: [
|
|
411
|
+
...engineSystems.map((system) => ({
|
|
412
|
+
...system,
|
|
413
|
+
...(system.phase === "input" && gameInputIds.length
|
|
414
|
+
? { before: [...(system.before ?? []), ...gameInputIds] }
|
|
415
|
+
: {}),
|
|
416
|
+
})),
|
|
417
|
+
...gameSystems,
|
|
418
|
+
],
|
|
419
|
+
});
|
|
420
|
+
this.scheduler.dispose();
|
|
421
|
+
this.scheduler = candidate;
|
|
422
|
+
this.bindings = systems.map((system, index) => ({
|
|
423
|
+
run: system.run,
|
|
424
|
+
contract: contracts[index],
|
|
425
|
+
}));
|
|
426
|
+
this.systemIdentity = systems;
|
|
427
|
+
}
|
|
428
|
+
assertSystems(systems) {
|
|
429
|
+
if (systems !== this.systemIdentity ||
|
|
430
|
+
systems.length !== this.bindings.length ||
|
|
431
|
+
systems.some((system, index) => system.run !== this.bindings[index].run ||
|
|
432
|
+
canonical(metadata(system)) !== this.bindings[index].contract))
|
|
433
|
+
throw new Error("TC_RUNTIME_SYSTEMS_CHANGED: System configuration is immutable; create a new host for reload.");
|
|
434
|
+
}
|
|
435
|
+
runSystems(systems, dt) {
|
|
436
|
+
this.assertMutation();
|
|
437
|
+
if (!Number.isFinite(dt) ||
|
|
438
|
+
dt < 0 ||
|
|
439
|
+
(dt !== 0 && Math.abs(dt - this.fixedDt) > 1e-12))
|
|
440
|
+
throw new Error("TC_RUNTIME_FIXED_DT: Host uses a configured fixed simulation interval.");
|
|
441
|
+
if (!this.systemIdentity)
|
|
442
|
+
this.configureSystems(systems);
|
|
443
|
+
else
|
|
444
|
+
this.assertSystems(systems);
|
|
445
|
+
if (dt === 0)
|
|
446
|
+
return;
|
|
447
|
+
const result = this.scheduler.step(1);
|
|
448
|
+
if (result.status === "faulted")
|
|
449
|
+
throw new Error(`TC_RUNTIME_SYSTEM: tick=${result.fault?.tick}, stage=${result.fault?.stage}, partialState=true; ${result.fault?.message}`);
|
|
450
|
+
if (result.executed !== 1 || result.status !== "completed")
|
|
451
|
+
throw new Error("TC_RUNTIME_TICK_NOT_COMPLETED: Scheduler did not execute one complete tick.");
|
|
452
|
+
}
|
|
453
|
+
validateStep(count) {
|
|
454
|
+
this.assertBoundary();
|
|
455
|
+
if (!Number.isInteger(count) || count < 1 || count > 10000)
|
|
456
|
+
throw new Error("TC_RUNTIME_STEPS: Expected count in [1,10000].");
|
|
457
|
+
if (!this.game)
|
|
458
|
+
throw new Error("TC_RUNTIME_NOT_ATTACHED: Attach a compiled game before stepping.");
|
|
459
|
+
}
|
|
460
|
+
updateTick() {
|
|
461
|
+
const previous = this.scheduler.tick;
|
|
462
|
+
this.game.update(this.fixedDt);
|
|
463
|
+
if (this.scheduler.tick !== previous + 1 || this.scheduler.inspect().fault)
|
|
464
|
+
throw new Error("TC_RUNTIME_TICK_NOT_COMPLETED: Game update did not execute exactly one complete scheduler tick; state may be partial.");
|
|
465
|
+
}
|
|
466
|
+
recordStepFailure(error) {
|
|
467
|
+
const fault = this.scheduler.inspect().fault;
|
|
468
|
+
const system = fault?.stage.startsWith("system:")
|
|
469
|
+
? fault.stage.slice(7)
|
|
470
|
+
: undefined;
|
|
471
|
+
const source = this.scene.systems.find((item) => item.id === system)?.source;
|
|
472
|
+
this.errors.push(diagnosticFrom(error, {
|
|
473
|
+
...(system ? { system } : {}),
|
|
474
|
+
...(source ? { source } : {}),
|
|
475
|
+
}));
|
|
476
|
+
}
|
|
477
|
+
step(count = 1) {
|
|
478
|
+
this.validateStep(count);
|
|
479
|
+
try {
|
|
480
|
+
for (let i = 0; i < count; i++)
|
|
481
|
+
this.updateTick();
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
this.recordStepFailure(error);
|
|
485
|
+
throw error;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
async stepAsync(count = 1) {
|
|
489
|
+
this.validateStep(count);
|
|
490
|
+
this.asyncActive = true;
|
|
491
|
+
try {
|
|
492
|
+
for (let i = 0; i < count; i++) {
|
|
493
|
+
this.assertAlive();
|
|
494
|
+
this.drivingAsync = true;
|
|
495
|
+
try {
|
|
496
|
+
this.updateTick();
|
|
497
|
+
}
|
|
498
|
+
finally {
|
|
499
|
+
this.drivingAsync = false;
|
|
500
|
+
}
|
|
501
|
+
await this.scheduler.settle(() => this.settlePlugins());
|
|
502
|
+
this.assertAlive();
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
catch (error) {
|
|
506
|
+
this.recordStepFailure(error);
|
|
507
|
+
throw error;
|
|
508
|
+
}
|
|
509
|
+
finally {
|
|
510
|
+
this.asyncActive = false;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
async settlePlugins() {
|
|
514
|
+
this.assertAlive();
|
|
515
|
+
for (const plugin of this.plugins) {
|
|
516
|
+
if (!plugin.settle)
|
|
517
|
+
continue;
|
|
518
|
+
try {
|
|
519
|
+
await plugin.settle();
|
|
520
|
+
}
|
|
521
|
+
catch (error) {
|
|
522
|
+
this.assertAlive();
|
|
523
|
+
throw new Error(`TC_RUNTIME_SETTLE: Plugin ${plugin.id}; partialState=true; ${String(error)}`);
|
|
524
|
+
}
|
|
525
|
+
this.assertAlive();
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
/** External commands enqueue next-tick events, never mutate game state directly. */
|
|
529
|
+
emitExternal(type, payload) {
|
|
530
|
+
this.assertConfigured();
|
|
531
|
+
if (typeof type !== "string" ||
|
|
532
|
+
!type.trim() ||
|
|
533
|
+
type.length > 128 ||
|
|
534
|
+
/[\u0000-\u001f\u007f]/.test(type))
|
|
535
|
+
throw new Error("TC_RUNTIME_INPUT: Event type must be 1..128 printable characters.");
|
|
536
|
+
const data = cloneSerializable(payload);
|
|
537
|
+
if (JSON.stringify(data).length > 65536 ||
|
|
538
|
+
this.scheduler.inspect().events.queued >= 4096)
|
|
539
|
+
throw new Error("TC_RUNTIME_INPUT_LIMIT: External event payload/queue limit exceeded.");
|
|
540
|
+
return this.scheduler.emit(type, data, { source: "external" });
|
|
541
|
+
}
|
|
542
|
+
input(key, pressed) {
|
|
543
|
+
this.assertConfigured();
|
|
544
|
+
if (typeof key !== "string" ||
|
|
545
|
+
!key.length ||
|
|
546
|
+
key.length > 64 ||
|
|
547
|
+
/[\u0000-\u001f\u007f]/.test(key) ||
|
|
548
|
+
typeof pressed !== "boolean")
|
|
549
|
+
throw new Error("TC_RUNTIME_INPUT: Expected a 1..64 character key and boolean pressed state.");
|
|
550
|
+
return this.emitExternal("input.key", { key, pressed });
|
|
551
|
+
}
|
|
552
|
+
inspect() {
|
|
553
|
+
const pluginStates = {};
|
|
554
|
+
for (const plugin of this.plugins) {
|
|
555
|
+
try {
|
|
556
|
+
pluginStates[plugin.id] = cloneSerializable(plugin.inspect?.() ?? null);
|
|
557
|
+
}
|
|
558
|
+
catch (error) {
|
|
559
|
+
pluginStates[plugin.id] = { error: String(error) };
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return {
|
|
563
|
+
engineVersion: SDK_VERSION,
|
|
564
|
+
world: this.world.inspect(),
|
|
565
|
+
records: this.released ? [] : this.world.records(),
|
|
566
|
+
scheduler: this.scheduler.inspect(),
|
|
567
|
+
plugins: {
|
|
568
|
+
ids: this.plugins.map((plugin) => plugin.id),
|
|
569
|
+
states: pluginStates,
|
|
570
|
+
services: Object.keys(this.services).sort(),
|
|
571
|
+
},
|
|
572
|
+
diagnostics: structuredClone(this.errors),
|
|
573
|
+
disposed: this.released,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
snapshot() {
|
|
577
|
+
this.assertBoundary();
|
|
578
|
+
const scheduler = this.scheduler.snapshot();
|
|
579
|
+
return {
|
|
580
|
+
format: "pelican.host",
|
|
581
|
+
version: 1,
|
|
582
|
+
engineVersion: SDK_VERSION,
|
|
583
|
+
world: structuredClone(scheduler.world),
|
|
584
|
+
scheduler,
|
|
585
|
+
...(this.plugins.length ? { plugins: this.pluginSnapshot() } : {}),
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
restore(value) {
|
|
589
|
+
this.assertBoundary();
|
|
590
|
+
const data = cloneSerializable(value);
|
|
591
|
+
if (!data ||
|
|
592
|
+
data.format !== "pelican.host" ||
|
|
593
|
+
data.version !== 1 ||
|
|
594
|
+
data.engineVersion !== SDK_VERSION ||
|
|
595
|
+
!data.scheduler)
|
|
596
|
+
throw new Error("TC_RUNTIME_SNAPSHOT_VERSION: Incompatible host snapshot.");
|
|
597
|
+
if (canonical(data.world) !== canonical(data.scheduler.world))
|
|
598
|
+
throw new Error("TC_RUNTIME_SNAPSHOT_WORLD: Host and scheduler world snapshots disagree.");
|
|
599
|
+
if (!Array.isArray(data.world?.entities))
|
|
600
|
+
throw new Error("TC_RUNTIME_SNAPSHOT_WORLD: Missing entity records.");
|
|
601
|
+
for (const record of data.world.entities)
|
|
602
|
+
resolveEntityDefinition(this.scene, record);
|
|
603
|
+
let pluginSnapshots;
|
|
604
|
+
if (this.plugins.length) {
|
|
605
|
+
if (data.plugins === undefined && this.plugins.some((plugin) => !plugin.legacySnapshot))
|
|
606
|
+
throw new Error("TC_PLUGIN_SNAPSHOT_SET: Plugin snapshots are required.");
|
|
607
|
+
pluginSnapshots = this.validatePlugins(data.plugins ?? {});
|
|
608
|
+
for (const plugin of this.plugins)
|
|
609
|
+
plugin.validateWorld?.(cloneSerializable(pluginSnapshots[plugin.id].payload), structuredClone(data.world));
|
|
610
|
+
}
|
|
611
|
+
else if (data.plugins !== undefined) {
|
|
612
|
+
if (!data.plugins ||
|
|
613
|
+
typeof data.plugins !== "object" ||
|
|
614
|
+
Array.isArray(data.plugins) ||
|
|
615
|
+
Object.keys(data.plugins).length > 0)
|
|
616
|
+
throw new Error("TC_PLUGIN_SNAPSHOT_SET: Snapshot contains plugins but this host has none.");
|
|
617
|
+
}
|
|
618
|
+
if (!pluginSnapshots && this.plugins.length)
|
|
619
|
+
throw new Error("TC_PLUGIN_SNAPSHOT_SET: Plugin snapshots are required.");
|
|
620
|
+
if (!this.plugins.length) {
|
|
621
|
+
this.scheduler.restore(data.scheduler);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
// A failed settle may leave in-flight sidecars, not a capturable checkpoint.
|
|
625
|
+
// Recovery can restore a known-good checkpoint, but cannot roll back to it.
|
|
626
|
+
const faulted = this.scheduler.inspect().fault !== null;
|
|
627
|
+
const previousScheduler = faulted ? undefined : this.scheduler.snapshot();
|
|
628
|
+
const previousPlugins = !faulted && this.plugins.length ? this.pluginSnapshot() : undefined;
|
|
629
|
+
try {
|
|
630
|
+
this.scheduler.restore(data.scheduler);
|
|
631
|
+
if (pluginSnapshots)
|
|
632
|
+
this.restorePlugins(pluginSnapshots);
|
|
633
|
+
}
|
|
634
|
+
catch (error) {
|
|
635
|
+
const rollbackErrors = [];
|
|
636
|
+
if (previousScheduler) {
|
|
637
|
+
try {
|
|
638
|
+
this.scheduler.restore(previousScheduler);
|
|
639
|
+
}
|
|
640
|
+
catch (rollbackError) {
|
|
641
|
+
rollbackErrors.push(String(rollbackError));
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
else
|
|
645
|
+
rollbackErrors.push("No successful checkpoint exists for the faulted state; restore a known-good checkpoint again.");
|
|
646
|
+
if (previousPlugins) {
|
|
647
|
+
for (const plugin of this.plugins) {
|
|
648
|
+
try {
|
|
649
|
+
plugin.restore(cloneSerializable(previousPlugins[plugin.id].payload));
|
|
650
|
+
}
|
|
651
|
+
catch (rollbackError) {
|
|
652
|
+
rollbackErrors.push(`${plugin.id}: ${String(rollbackError)}`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
const partial = rollbackErrors.length > 0;
|
|
657
|
+
const wrapped = new Error(`TC_PLUGIN_RESTORE: ${String(error)}${partial ? `; partialState=true; rollback failures: ${rollbackErrors.join("; ")}` : "; previous state restored."}`);
|
|
658
|
+
this.errors.push(diagnosticFrom(wrapped));
|
|
659
|
+
throw wrapped;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
startRecording() {
|
|
663
|
+
this.assertConfigured();
|
|
664
|
+
this.recordingPlugins = this.plugins.length
|
|
665
|
+
? this.pluginSnapshot()
|
|
666
|
+
: undefined;
|
|
667
|
+
this.scheduler.startRecording();
|
|
668
|
+
}
|
|
669
|
+
stopRecording() {
|
|
670
|
+
this.assertConfigured();
|
|
671
|
+
const trace = this.scheduler.stopRecording();
|
|
672
|
+
if (this.recordingPlugins) {
|
|
673
|
+
trace.plugins = {
|
|
674
|
+
initial: this.recordingPlugins,
|
|
675
|
+
final: this.pluginSnapshot(),
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
this.recordingPlugins = undefined;
|
|
679
|
+
return trace;
|
|
680
|
+
}
|
|
681
|
+
cancelRecording() {
|
|
682
|
+
this.assertConfigured();
|
|
683
|
+
this.recordingPlugins = undefined;
|
|
684
|
+
this.scheduler.cancelRecording();
|
|
685
|
+
}
|
|
686
|
+
preflightReplay(trace) {
|
|
687
|
+
const data = this.scheduler.preflightReplay(trace);
|
|
688
|
+
for (const snapshot of [data.initial, data.final])
|
|
689
|
+
for (const entity of snapshot.world.entities)
|
|
690
|
+
resolveEntityDefinition(this.scene, entity);
|
|
691
|
+
let initial;
|
|
692
|
+
let final;
|
|
693
|
+
if (this.plugins.length) {
|
|
694
|
+
if (!data.plugins)
|
|
695
|
+
throw new Error("TC_PLUGIN_TRACE_REQUIRED: Trace does not contain plugin state.");
|
|
696
|
+
initial = this.validatePlugins(data.plugins.initial);
|
|
697
|
+
final = this.validatePlugins(data.plugins.final);
|
|
698
|
+
for (const plugin of this.plugins) {
|
|
699
|
+
plugin.validateWorld?.(cloneSerializable(initial[plugin.id].payload), structuredClone(data.initial.world));
|
|
700
|
+
plugin.validateWorld?.(cloneSerializable(final[plugin.id].payload), structuredClone(data.final.world));
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
else if (data.plugins) {
|
|
704
|
+
for (const snapshots of [data.plugins.initial, data.plugins.final])
|
|
705
|
+
if (!snapshots ||
|
|
706
|
+
typeof snapshots !== "object" ||
|
|
707
|
+
Array.isArray(snapshots) ||
|
|
708
|
+
Object.keys(snapshots).length)
|
|
709
|
+
throw new Error("TC_PLUGIN_TRACE_SET: Trace requires plugins not present in this host.");
|
|
710
|
+
}
|
|
711
|
+
return { data, initial, final };
|
|
712
|
+
}
|
|
713
|
+
checkReplay(result, final) {
|
|
714
|
+
if (result.status === "passed" &&
|
|
715
|
+
final &&
|
|
716
|
+
canonical(this.pluginSnapshot()) !== canonical(final))
|
|
717
|
+
throw new Error("TC_REPLAY_DIVERGENCE: Final plugin state differs.");
|
|
718
|
+
if (result.status === "failed")
|
|
719
|
+
this.errors.push(diagnosticFrom(new Error(result.fault?.code === "TC_SCHEDULER_SETTLE"
|
|
720
|
+
? result.fault.message
|
|
721
|
+
: `TC_REPLAY_DIVERGENCE: ${result.fault?.message}`)));
|
|
722
|
+
return result;
|
|
723
|
+
}
|
|
724
|
+
replay(trace) {
|
|
725
|
+
this.assertConfigured();
|
|
726
|
+
try {
|
|
727
|
+
if (this.plugins.some((plugin) => plugin.settle !== undefined))
|
|
728
|
+
throw new Error("TC_RUNTIME_REPLAY_ASYNC_REQUIRED: Use replayAsync for plugins with settle boundaries.");
|
|
729
|
+
const { data, initial, final } = this.preflightReplay(trace);
|
|
730
|
+
if (initial)
|
|
731
|
+
this.restorePlugins(initial);
|
|
732
|
+
return this.checkReplay(this.scheduler.replay(data), final);
|
|
733
|
+
}
|
|
734
|
+
catch (error) {
|
|
735
|
+
this.errors.push(diagnosticFrom(error));
|
|
736
|
+
return { status: "failed", actionsExecuted: 0, fault: null };
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
async replayAsync(trace) {
|
|
740
|
+
this.assertConfigured();
|
|
741
|
+
try {
|
|
742
|
+
const { data, initial, final } = this.preflightReplay(trace);
|
|
743
|
+
if (initial)
|
|
744
|
+
this.restorePlugins(initial);
|
|
745
|
+
this.asyncActive = true;
|
|
746
|
+
this.drivingAsync = true;
|
|
747
|
+
let pending;
|
|
748
|
+
try {
|
|
749
|
+
pending = this.scheduler.replayAsync(data, () => {
|
|
750
|
+
this.drivingAsync = false;
|
|
751
|
+
return this.settlePlugins();
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
finally {
|
|
755
|
+
this.drivingAsync = false;
|
|
756
|
+
}
|
|
757
|
+
const result = await pending;
|
|
758
|
+
this.assertAlive();
|
|
759
|
+
return this.checkReplay(result, final);
|
|
760
|
+
}
|
|
761
|
+
catch (error) {
|
|
762
|
+
this.errors.push(diagnosticFrom(error));
|
|
763
|
+
return { status: "failed", actionsExecuted: 0, fault: null };
|
|
764
|
+
}
|
|
765
|
+
finally {
|
|
766
|
+
this.asyncActive = false;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
dispose() {
|
|
770
|
+
if (this.released || this.disposing)
|
|
771
|
+
return;
|
|
772
|
+
this.assertAlive();
|
|
773
|
+
if (this.scheduler.inspect().running)
|
|
774
|
+
throw new Error("TC_RUNTIME_SAFE_POINT: Operation requires a tick boundary.");
|
|
775
|
+
this.disposing = true;
|
|
776
|
+
const failures = [];
|
|
777
|
+
try {
|
|
778
|
+
this.game?.dispose();
|
|
779
|
+
}
|
|
780
|
+
catch (error) {
|
|
781
|
+
failures.push(error);
|
|
782
|
+
}
|
|
783
|
+
for (const plugin of [...this.plugins].reverse()) {
|
|
784
|
+
try {
|
|
785
|
+
plugin.dispose();
|
|
786
|
+
}
|
|
787
|
+
catch (error) {
|
|
788
|
+
failures.push(error);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
try {
|
|
792
|
+
this.scheduler.dispose();
|
|
793
|
+
}
|
|
794
|
+
catch (error) {
|
|
795
|
+
failures.push(error);
|
|
796
|
+
}
|
|
797
|
+
try {
|
|
798
|
+
this.world.dispose();
|
|
799
|
+
}
|
|
800
|
+
catch (error) {
|
|
801
|
+
failures.push(error);
|
|
802
|
+
}
|
|
803
|
+
this.released = true;
|
|
804
|
+
this.disposing = false;
|
|
805
|
+
if (failures.length) {
|
|
806
|
+
for (const error of failures)
|
|
807
|
+
this.errors.push(diagnosticFrom(error));
|
|
808
|
+
throw new AggregateError(failures, `TC_RUNTIME_DISPOSE: ${failures.map(String).join("; ")}`);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
assertAlive() {
|
|
812
|
+
if (this.released)
|
|
813
|
+
throw new Error("TC_RUNTIME_DISPOSED: Create a new runtime host.");
|
|
814
|
+
}
|
|
815
|
+
assertBoundary() {
|
|
816
|
+
this.assertAlive();
|
|
817
|
+
if (this.asyncActive)
|
|
818
|
+
throw new Error("TC_RUNTIME_ASYNC_ACTIVE: An async step/replay is pending.");
|
|
819
|
+
if (this.scheduler.inspect().running)
|
|
820
|
+
throw new Error("TC_RUNTIME_SAFE_POINT: Operation requires a tick boundary.");
|
|
821
|
+
}
|
|
822
|
+
assertMutation() {
|
|
823
|
+
this.assertAlive();
|
|
824
|
+
this.assertAsyncMutation();
|
|
825
|
+
}
|
|
826
|
+
assertAsyncMutation() {
|
|
827
|
+
if (!this.released &&
|
|
828
|
+
this.asyncActive &&
|
|
829
|
+
!this.drivingAsync &&
|
|
830
|
+
!this.disposing &&
|
|
831
|
+
!this.scheduler.inspect().running)
|
|
832
|
+
throw new Error("TC_RUNTIME_ASYNC_ACTIVE: An async step/replay is pending.");
|
|
833
|
+
}
|
|
834
|
+
assertConfigured() {
|
|
835
|
+
this.assertBoundary();
|
|
836
|
+
if (!this.systemIdentity)
|
|
837
|
+
throw new Error("TC_RUNTIME_NOT_CONFIGURED: Instantiate matching game systems first.");
|
|
838
|
+
}
|
|
839
|
+
}
|