incanto 0.31.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/bin/incanto-play.mjs +7 -2
  2. package/dist/2d.d.ts +59 -2
  3. package/dist/2d.js +3 -3
  4. package/dist/3d.d.ts +28 -3
  5. package/dist/3d.js +4 -4
  6. package/dist/{behavior-D_jMpFh8.d.ts → behavior-CKwTCjfR.d.ts} +140 -1
  7. package/dist/{create-game-z5XaB1p5.js → create-game-D2QU5x7S.js} +16 -8
  8. package/dist/{create-game-DqOjMBUS.js → create-game-sFuTLqjD.js} +22 -10
  9. package/dist/debug.d.ts +1 -1
  10. package/dist/{duplicate-KRPtUtzl.js → duplicate-BgnG1Lqz.js} +1 -1
  11. package/dist/editor.js +35 -0
  12. package/dist/{environment-presets--DigHNg4.js → environment-presets-CQtEGogB.js} +2 -2
  13. package/dist/{gameplay-BftxM_It.js → gameplay-BpQCbABv.js} +90 -2
  14. package/dist/gameplay.d.ts +1 -1
  15. package/dist/gameplay.js +1 -1
  16. package/dist/index.d.ts +135 -23
  17. package/dist/index.js +6 -59
  18. package/dist/{loader-BlaRQGaA.js → loader-Buk8Bu1h.js} +53 -0
  19. package/dist/{loader-BYBrqTxP.d.ts → loader-COn5fS0o.d.ts} +1 -1
  20. package/dist/net.d.ts +1 -1
  21. package/dist/net.js +3 -3
  22. package/dist/{pathfinding-BwhqPD3i.d.ts → pathfinding-DUw9mir9.d.ts} +1 -1
  23. package/dist/{physics-2d-D9wquBvK.js → physics-2d-DjXR5DMu.js} +12 -2
  24. package/dist/{physics-3d-CnPygVGo.js → physics-3d-DF8npb1O.js} +55 -9
  25. package/dist/react.d.ts +1 -1
  26. package/dist/react.js +1 -1
  27. package/dist/{register-Dzkd6-os.js → register-CscIzJEO.js} +551 -13
  28. package/dist/{register-CUY284Is.js → register-CtI-itec.js} +2 -2
  29. package/dist/{register-tkR_8tWg.js → register-FIJtNbub.js} +145 -8
  30. package/dist/test-BRxLd2jH.js +642 -0
  31. package/dist/test.d.ts +55 -3
  32. package/dist/test.js +2 -392
  33. package/dist/{touch-031PxtCR.js → touch-BoNg_MnF.js} +2 -1
  34. package/dist/vite.js +1 -1
  35. package/editor/assets/{agent8-o27_Y1xN.js → agent8-BdDP3xKW.js} +1 -1
  36. package/editor/assets/{debug-DfcWX3uW.js → debug-DbjTyTlC.js} +1 -1
  37. package/editor/assets/{index-C9fb5QcT.js → index-BWCudoz1.js} +90 -90
  38. package/editor/index.html +1 -1
  39. package/package.json +1 -1
  40. package/schemas/scene.schema.json +520 -0
  41. package/skills/README.md +14 -2
  42. package/skills/incanto-audio.md +43 -0
  43. package/skills/incanto-building-2d-games.md +52 -0
  44. package/skills/incanto-building-3d-games.md +16 -0
  45. package/skills/incanto-hud.md +41 -0
  46. package/skills/incanto-node-reference.md +89 -0
  47. package/skills/incanto-physics-and-input.md +50 -0
  48. package/skills/incanto-verifying-your-game.md +86 -30
  49. package/templates-app/beacon-isle-3d/docs/project-3d-rules.md +40 -19
  50. package/templates-app/beacon-isle-3d/package.json +1 -1
  51. package/templates-app/tps-3d/docs/project-3d-rules.md +41 -19
  52. package/templates-app/tps-3d/package.json +1 -1
  53. package/templates-app/village-quest-3d/docs/project-3d-rules.md +40 -19
  54. package/templates-app/village-quest-3d/package.json +1 -1
@@ -0,0 +1,642 @@
1
+ import { n as loadScene, y as registerBehavior } from "./loader-Buk8Bu1h.js";
2
+ import { h as Engine } from "./register-CscIzJEO.js";
3
+ import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
+ import { n as jsonEquals, t as jsonClone } from "./json-BLk7H2Qa.js";
5
+ import { i as getNodeSchema, s as mergeStaticProps } from "./registry-CJdGpT2V.js";
6
+ import { n as registerGameplayBehaviors } from "./gameplay-BpQCbABv.js";
7
+ import { t as registerNodes2D } from "./register-FIJtNbub.js";
8
+ import { n as registerNodes3D, t as resolveEnvironmentHdri } from "./environment-presets-CQtEGogB.js";
9
+ import { t as registerNodesNet } from "./register-CtI-itec.js";
10
+ import { Box3, Euler, Matrix4, PerspectiveCamera, Quaternion, Vector3 } from "three";
11
+ //#region src/test/framing.ts
12
+ /**
13
+ * What the camera is actually looking at — without a GPU.
14
+ *
15
+ * Nearly every decision in a 3D game is visual: is the camera framing the
16
+ * island, is anything lit, is the gem buried inside the platform. An agent
17
+ * building a game could answer none of them. `captureScene()` lists nodes and
18
+ * their props, which tells you a gem is at `[4, 1, -2]` and nothing whatsoever
19
+ * about whether you can SEE it — and the engine ships no pixel output at all,
20
+ * so "look at it" was a human's job.
21
+ *
22
+ * This is the geometry half of the answer, and it needs no renderer, no canvas
23
+ * and no browser: the camera's own matrices, the world transforms composed from
24
+ * the tree, and the colliders already declared in the JSON.
25
+ *
26
+ * What it measures: the node's ORIGIN, widened to its declared `size` (a
27
+ * MeshInstance3D's box, a Terrain3D's ground) or its collider when it has one.
28
+ * A 200 m island whose origin is off to the left still counts as on screen,
29
+ * because its box is. For a node that declares neither, read `offscreen` as
30
+ * "its origin is off-screen" — a loaded GLB's real silhouette needs the model,
31
+ * and the model needs a GPU.
32
+ */
33
+ const DEG = Math.PI / 180;
34
+ const CAMERA_TYPES = new Set(["Camera3D"]);
35
+ const LIGHT_TYPES = new Set([
36
+ "DirectionalLight3D",
37
+ "OmniLight3D",
38
+ "SpotLight3D"
39
+ ]);
40
+ /**
41
+ * World transform for every node, composed from the tree.
42
+ *
43
+ * NOT read off `_object3D`: that is the renderer's copy, and headless there may
44
+ * be no renderer to have written it. The props are the truth here.
45
+ */
46
+ function place(scene) {
47
+ const out = [];
48
+ const walk = (node, parent) => {
49
+ const type = node.constructor.typeName ?? "Node";
50
+ const n = node;
51
+ const local = new Matrix4();
52
+ if (Array.isArray(n.position)) local.compose(new Vector3(n.position[0] ?? 0, n.position[1] ?? 0, n.position[2] ?? 0), new Quaternion().setFromEuler(new Euler((n.rotation?.[0] ?? 0) * DEG, (n.rotation?.[1] ?? 0) * DEG, (n.rotation?.[2] ?? 0) * DEG, "XYZ")), new Vector3(n.scale?.[0] ?? 1, n.scale?.[1] ?? 1, n.scale?.[2] ?? 1));
53
+ const matrix = new Matrix4().multiplyMatrices(parent, local);
54
+ out.push({
55
+ node,
56
+ type,
57
+ matrix,
58
+ position: new Vector3().setFromMatrixPosition(matrix)
59
+ });
60
+ for (const child of node.children) walk(child, matrix);
61
+ };
62
+ walk(scene.root, new Matrix4());
63
+ return out;
64
+ }
65
+ /** A node draws something if its class builds its own three object. */
66
+ function draws(node) {
67
+ const proto = Object.getPrototypeOf(node);
68
+ let base = proto;
69
+ while (base && base.constructor?.typeName !== "Node3D") {
70
+ base = Object.getPrototypeOf(base);
71
+ if (!base) return false;
72
+ }
73
+ return proto._createObject3D !== base._createObject3D;
74
+ }
75
+ /**
76
+ * How big the node is on screen, from whatever it declares.
77
+ *
78
+ * `size` first (a MeshInstance3D's box, a Terrain3D's ground), then a collider.
79
+ * This is the FRAMING question — "does this thing fill the view" — and a mesh's
80
+ * own size answers it better than the body wrapped around it.
81
+ */
82
+ function extentOf(node) {
83
+ const size = node.size;
84
+ if (Array.isArray(size) && size.length >= 2 && typeof size[0] === "number") {
85
+ const x = size[0] / 2;
86
+ const y = typeof size[1] === "number" && size.length >= 3 ? size[1] / 2 : x;
87
+ const z = (size[size.length - 1] ?? size[0]) / 2;
88
+ return new Vector3(Math.abs(x), Math.abs(y), Math.abs(z));
89
+ }
90
+ return halfExtents(node);
91
+ }
92
+ /**
93
+ * A COLLIDER's half-extents, when the node declares one.
94
+ *
95
+ * Colliders live on bodies, not meshes — which is exactly right for the
96
+ * overlap report: "the gem is inside the platform" is a question about the
97
+ * physical shapes, and two meshes touching (a floor meeting a wall) is not a
98
+ * finding.
99
+ */
100
+ function halfExtents(node) {
101
+ const collider = node.collider;
102
+ if (!collider || typeof collider !== "object") return null;
103
+ const shape = collider.shape;
104
+ if (shape === "box") {
105
+ const size = collider.size ?? [
106
+ 1,
107
+ 1,
108
+ 1
109
+ ];
110
+ return new Vector3((size[0] ?? 1) / 2, (size[1] ?? 1) / 2, (size[2] ?? 1) / 2);
111
+ }
112
+ if (shape === "sphere") {
113
+ const r = collider.radius ?? .5;
114
+ return new Vector3(r, r, r);
115
+ }
116
+ if (shape === "capsule" || shape === "cylinder") {
117
+ const r = collider.radius ?? .5;
118
+ return new Vector3(r, (collider.height ?? 1) / 2 + r, r);
119
+ }
120
+ return null;
121
+ }
122
+ function describeFraming(scene, opts = {}) {
123
+ const placed = place(scene);
124
+ const cameras = placed.filter((p) => CAMERA_TYPES.has(p.type));
125
+ const chosen = cameras.find((p) => p.node.current === true) ?? cameras[0] ?? null;
126
+ const lights = [];
127
+ for (const p of placed) if (LIGHT_TYPES.has(p.type)) lights.push(`${p.node.getPath()} (${p.type})`);
128
+ const env = scene.environment;
129
+ if (env) {
130
+ if (env.preset !== void 0) lights.push(`environment.preset = ${JSON.stringify(env.preset)}`);
131
+ if (env.sky !== void 0) lights.push("environment.sky");
132
+ if (env.hdri !== void 0) lights.push("environment.hdri");
133
+ const ambient = env.ambient;
134
+ if (typeof ambient?.intensity === "number" && ambient.intensity > 0) lights.push(`environment.ambient.intensity = ${ambient.intensity}`);
135
+ }
136
+ const wanted = opts.types ? new Set(opts.types) : null;
137
+ const subjects = placed.filter((p) => !CAMERA_TYPES.has(p.type) && !LIGHT_TYPES.has(p.type) && (wanted ? wanted.has(p.type) : draws(p.node)));
138
+ const entries = [];
139
+ let camera = null;
140
+ if (chosen) {
141
+ const node = chosen.node;
142
+ const cam = new PerspectiveCamera(node.fov ?? 60, opts.aspect ?? 16 / 9, node.near ?? .1, node.far ?? 1e3);
143
+ cam.matrixWorld.copy(chosen.matrix);
144
+ cam.matrixWorldInverse.copy(chosen.matrix).invert();
145
+ cam.updateProjectionMatrix();
146
+ const forward = new Vector3(0, 0, -1).applyQuaternion(new Quaternion().setFromRotationMatrix(chosen.matrix));
147
+ camera = {
148
+ path: chosen.node.getPath(),
149
+ at: chosen.position.toArray(),
150
+ forward: [
151
+ round(forward.x),
152
+ round(forward.y),
153
+ round(forward.z)
154
+ ],
155
+ fovDeg: node.fov ?? 60
156
+ };
157
+ for (const p of subjects) {
158
+ const view = p.position.clone().applyMatrix4(cam.matrixWorldInverse);
159
+ const distance = p.position.distanceTo(chosen.position);
160
+ if (view.z > -(node.near ?? .1)) {
161
+ entries.push({
162
+ path: p.node.getPath(),
163
+ type: p.type,
164
+ at: p.position.toArray(),
165
+ where: "behind",
166
+ distance: round(distance)
167
+ });
168
+ continue;
169
+ }
170
+ const ndc = p.position.clone().project(cam);
171
+ let onScreen = Math.abs(ndc.x) <= 1 && Math.abs(ndc.y) <= 1;
172
+ if (!onScreen) {
173
+ const half = extentOf(p.node);
174
+ if (half) onScreen = cornersOnScreen(new Box3().setFromCenterAndSize(p.position, half.clone().multiplyScalar(2)), cam);
175
+ }
176
+ entries.push({
177
+ path: p.node.getPath(),
178
+ type: p.type,
179
+ at: p.position.toArray(),
180
+ where: onScreen ? "onScreen" : "offscreen",
181
+ screen: [round(ndc.x), round(ndc.y)],
182
+ distance: round(distance)
183
+ });
184
+ }
185
+ }
186
+ return {
187
+ camera,
188
+ entries,
189
+ lights,
190
+ overlaps: findOverlaps(placed)
191
+ };
192
+ }
193
+ function cornersOnScreen(box, cam) {
194
+ const min = box.min;
195
+ const max = box.max;
196
+ for (const x of [min.x, max.x]) for (const y of [min.y, max.y]) for (const z of [min.z, max.z]) {
197
+ const ndc = new Vector3(x, y, z).project(cam);
198
+ if (Math.abs(ndc.x) <= 1 && Math.abs(ndc.y) <= 1 && ndc.z <= 1) return true;
199
+ }
200
+ return false;
201
+ }
202
+ /**
203
+ * Declared colliders that intersect.
204
+ *
205
+ * The colliders, not the meshes: they are in the JSON, so this needs no GPU and
206
+ * no loaded model — and a prop buried inside a platform is the thing you were
207
+ * trying to see anyway.
208
+ */
209
+ function findOverlaps(placed) {
210
+ const boxes = [];
211
+ for (const p of placed) {
212
+ const half = halfExtents(p.node);
213
+ if (!half) continue;
214
+ boxes.push({
215
+ path: p.node.getPath(),
216
+ box: new Box3().setFromCenterAndSize(p.position, half.clone().multiplyScalar(2))
217
+ });
218
+ }
219
+ const out = [];
220
+ for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) {
221
+ const a = boxes[i];
222
+ const b = boxes[j];
223
+ if (!a || !b) continue;
224
+ if (a.path.startsWith(`${b.path}/`) || b.path.startsWith(`${a.path}/`)) continue;
225
+ if (a.box.intersectsBox(b.box)) out.push({
226
+ a: a.path,
227
+ b: b.path
228
+ });
229
+ }
230
+ return out;
231
+ }
232
+ function round(n) {
233
+ return Math.round(n * 1e3) / 1e3;
234
+ }
235
+ /** The report as something to read in a terminal. */
236
+ function framingText(report) {
237
+ const lines = [];
238
+ if (!report.camera) lines.push("camera: NONE — this scene frames nothing (no Camera3D)");
239
+ else {
240
+ const c = report.camera;
241
+ lines.push(`camera ${c.path} at [${c.at.map(round).join(", ")}] looking [${c.forward.join(", ")}] fov ${c.fovDeg}`);
242
+ }
243
+ lines.push(report.lights.length ? `lit by: ${report.lights.join(", ")}` : "lit by: NOTHING — this scene renders black");
244
+ const on = report.entries.filter((e) => e.where === "onScreen");
245
+ const off = report.entries.filter((e) => e.where === "offscreen");
246
+ const behind = report.entries.filter((e) => e.where === "behind");
247
+ lines.push(`${on.length} on screen, ${off.length} off screen, ${behind.length} behind the camera`);
248
+ for (const e of report.entries) {
249
+ const at = `[${e.at.map(round).join(", ")}]`;
250
+ const screen = e.screen ? ` screen [${e.screen.join(", ")}]` : "";
251
+ lines.push(` ${e.where.padEnd(9)} ${e.path} (${e.type}) ${at}${screen} ${e.distance}m`);
252
+ }
253
+ for (const o of report.overlaps) lines.push(` overlap ${o.a} ∩ ${o.b}`);
254
+ return lines.join("\n");
255
+ }
256
+ //#endregion
257
+ //#region src/test/index.ts
258
+ /**
259
+ * incanto/test — the browserless verification harness.
260
+ *
261
+ * The agent loop is "author → VERIFY → fix", and production environments
262
+ * (e.g. the agent8 VM) have no browser. This module makes the scene state
263
+ * itself the screenshot:
264
+ *
265
+ * - `validateScene(json)` — every hard load error, without a browser
266
+ * - `runScript(json, ...)` — scripted play by ACTION intent at a fixed
267
+ * timestep, with inline assertions and periodic snapshots
268
+ * - `captureScene(scene)` / `describeCapture(capture)` — a structural,
269
+ * grep-able "text screenshot" of the live tree
270
+ *
271
+ * Deterministic by construction: fixed timestep, seeded `engine.rng`,
272
+ * no requestAnimationFrame.
273
+ */
274
+ /** Node types whose whole job is to sit on the ground. Characters, water,
275
+ * clouds, birds and lights are all legitimately off it. */
276
+ const GROUNDED_TYPES = ["MeshInstance3D", "InstancedMesh3D"];
277
+ /** Under one of these, the BODY owns where the thing stands — a character's
278
+ * skin hangs at whatever offset its rig wants, and is not a floating prop. */
279
+ const BODY_OWNED = new Set([
280
+ "CharacterBody3D",
281
+ "RigidBody3D",
282
+ "StaticBody3D",
283
+ "AreaBody3D"
284
+ ]);
285
+ /**
286
+ * Every prop that is floating or buried.
287
+ *
288
+ * THE most repeated mistake in scene authoring, by humans and agents alike, is
289
+ * a hand-computed Y: correct when it is written and wrong the moment anything
290
+ * reshapes the ground under it — a river carving its bed, a new seed, a
291
+ * generator that mirrors a carve slightly differently. This is the check that
292
+ * catches it, and `snapToGround` is the fix that stops it happening.
293
+ *
294
+ * Anything explicitly marked `snapToGround: false` is skipped: that is the
295
+ * author saying "yes, this hangs in the air", which a bridge deck, a lantern
296
+ * and a cloud all legitimately do.
297
+ *
298
+ * ```ts
299
+ * const off = findFloatingProps(gameJson);
300
+ * ok(`nothing floats (${off.length})`, off.length === 0);
301
+ * ```
302
+ */
303
+ function findFloatingProps(json, opts = {}) {
304
+ registerAllNodes();
305
+ const tolerance = opts.tolerance ?? .1;
306
+ const types = new Set(opts.types ?? GROUNDED_TYPES);
307
+ let scene;
308
+ try {
309
+ scene = loadScene(json);
310
+ } catch {
311
+ return [];
312
+ }
313
+ let ground = null;
314
+ const findGround = (node) => {
315
+ if (!ground && node.constructor.typeName === "Terrain3D" && typeof node.heightAt === "function") ground = node;
316
+ for (const child of node.children) findGround(child);
317
+ };
318
+ findGround(scene.root);
319
+ if (!ground) return [];
320
+ const out = [];
321
+ const walk = (node, path, ox, oy, oz, yaw, bodyOwned) => {
322
+ const n = node;
323
+ let wx = ox;
324
+ let wy = oy;
325
+ let wz = oz;
326
+ let ownYaw = yaw;
327
+ if (Array.isArray(n.position)) {
328
+ const c = Math.cos(yaw);
329
+ const s = Math.sin(yaw);
330
+ const lx = n.position[0] ?? 0;
331
+ const lz = n.position[2] ?? 0;
332
+ wx = ox + lx * c + lz * s;
333
+ wz = oz - lx * s + lz * c;
334
+ wy = oy + (n.position[1] ?? 0);
335
+ ownYaw = yaw + (n.rotation?.[1] ?? 0) * Math.PI / 180;
336
+ }
337
+ const type = node.constructor.typeName ?? "";
338
+ const soil = ground;
339
+ if (types.has(type) && n.visible !== false && n.snapToGround !== false && !bodyOwned) {
340
+ const rows = node.transforms;
341
+ if (Array.isArray(rows) && rows.length > 0) {
342
+ let worst = null;
343
+ for (const row of rows) {
344
+ const scale = row[4] ?? 1;
345
+ const half = (n.size?.[1] ?? 0) * scale / 2;
346
+ const gap = wy + (row[1] ?? 0) - half - soil.heightAt(wx + (row[0] ?? 0), wz + (row[2] ?? 0));
347
+ if (Math.abs(gap) > tolerance && (!worst || Math.abs(gap) > Math.abs(worst.gap))) worst = {
348
+ path,
349
+ gap: Math.round(gap * 100) / 100
350
+ };
351
+ }
352
+ if (worst) out.push(worst);
353
+ } else {
354
+ const half = (n.size?.[1] ?? 0) / 2;
355
+ const gap = wy - half - soil.heightAt(wx, wz);
356
+ if (Math.abs(gap) > tolerance) out.push({
357
+ path,
358
+ gap: Math.round(gap * 100) / 100
359
+ });
360
+ }
361
+ }
362
+ const childBodyOwned = bodyOwned || BODY_OWNED.has(type);
363
+ for (const child of node.children) walk(child, `${path}/${child.name}`, wx, wy, wz, ownYaw, childBodyOwned);
364
+ };
365
+ walk(scene.root, `/${scene.root.name}`, 0, 0, 0, 0, false);
366
+ return out;
367
+ }
368
+ function registerAllNodes() {
369
+ registerNodes2D();
370
+ registerNodes3D();
371
+ registerNodesNet();
372
+ registerGameplayBehaviors();
373
+ }
374
+ /** Snapshot the live tree: every node, every schema prop's current value. */
375
+ function captureScene(scene) {
376
+ const nodes = [];
377
+ const walk = (node) => {
378
+ const ctor = node.constructor;
379
+ const schema = mergeStaticProps(ctor);
380
+ const props = {};
381
+ for (const key of Object.keys(schema)) props[key] = jsonClone(node[key]);
382
+ const capture = {
383
+ path: node.getPath(),
384
+ type: ctor.typeName,
385
+ props
386
+ };
387
+ if (node.uid) capture.uid = node.uid;
388
+ if (node.groups.size > 0) capture.groups = [...node.groups];
389
+ if (Object.keys(node.tags).length > 0) capture.tags = jsonClone(node.tags);
390
+ if (typeof node.script?.name === "string") capture.script = node.script.name;
391
+ nodes.push(capture);
392
+ for (const child of node.children) walk(child);
393
+ };
394
+ walk(scene.root);
395
+ const out = {
396
+ name: scene.name,
397
+ nodes
398
+ };
399
+ if (scene.dimension) out.dimension = scene.dimension;
400
+ return out;
401
+ }
402
+ /**
403
+ * Render a capture as one grep-able line per node, showing only NON-DEFAULT
404
+ * props — the same delta discipline as scene JSON, so the interesting state
405
+ * stands out.
406
+ */
407
+ function describeCapture(capture) {
408
+ const lines = [`scene ${capture.name}${capture.dimension ? ` (${capture.dimension})` : ""}`];
409
+ for (const node of capture.nodes) {
410
+ const schema = defaultsFor(node.type);
411
+ const deltas = Object.entries(node.props).filter(([key, value]) => !schema || !jsonEquals(value, schema[key]?.default ?? null)).map(([key, value]) => `${key}=${JSON.stringify(value)}`);
412
+ const extras = [
413
+ node.script ? `script=${node.script}` : "",
414
+ node.groups ? `groups=${node.groups.join(",")}` : "",
415
+ ...deltas
416
+ ].filter(Boolean);
417
+ lines.push(`${node.path} ${node.type}${extras.length ? ` ${extras.join(" ")}` : ""}`);
418
+ }
419
+ return lines.join("\n");
420
+ }
421
+ function defaultsFor(typeName) {
422
+ try {
423
+ return getNodeSchema(typeName);
424
+ } catch {
425
+ return null;
426
+ }
427
+ }
428
+ /**
429
+ * Run every hard load-time check headlessly. Unregistered behaviors are
430
+ * stubbed by default (structure-only validation, no TypeScript needed) —
431
+ * pass the real classes via `behaviors` to validate script props too.
432
+ */
433
+ /**
434
+ * `environment.preset` is resolved deep inside Renderer3D, which no headless
435
+ * tool reaches — so a typo there passed `incanto-check` and hard-failed at boot.
436
+ * It cannot live in the core loader (core must never import 3d), but it belongs
437
+ * exactly here: the test entry already registers the 3D nodes.
438
+ */
439
+ function checkEnvironment(json) {
440
+ const env = json?.environment;
441
+ if (!env || typeof env !== "object") return;
442
+ if (env.preset === void 0) return;
443
+ if (typeof env.preset !== "string") throw new IncantoError("BAD_FORMAT", `Scene "environment.preset" must be a string, got ${JSON.stringify(env.preset)}.`);
444
+ try {
445
+ resolveEnvironmentHdri(env);
446
+ } catch (error) {
447
+ throw new IncantoError("BAD_FORMAT", error instanceof Error ? error.message : String(error));
448
+ }
449
+ }
450
+ function validateScene(json, opts = {}) {
451
+ registerAllNodes();
452
+ for (const [name, ctor] of Object.entries(opts.behaviors ?? {})) registerBehavior(name, ctor, { replace: true });
453
+ try {
454
+ checkEnvironment(json);
455
+ loadScene(json, {
456
+ resolveScene: opts.resolveScene,
457
+ stubMissingBehaviors: !opts.strictBehaviors
458
+ }).root.free();
459
+ return { ok: true };
460
+ } catch (e) {
461
+ if (e instanceof IncantoError) return {
462
+ ok: false,
463
+ error: e
464
+ };
465
+ throw e;
466
+ }
467
+ }
468
+ /**
469
+ * Load a scene and play it headlessly at a fixed timestep, driving input by
470
+ * ACTION intent. This is the e2e loop without a browser: script → simulate →
471
+ * snapshot → assert.
472
+ */
473
+ async function runScript(json, opts) {
474
+ registerAllNodes();
475
+ for (const [name, ctor] of Object.entries(opts.behaviors ?? {})) registerBehavior(name, ctor, { replace: true });
476
+ const engine = new Engine({
477
+ seed: opts.seed,
478
+ fixedHz: opts.fixedHz,
479
+ scheduler: () => () => {}
480
+ });
481
+ const scene = loadScene(structuredClone(json), { resolveScene: opts.resolveScene });
482
+ engine.setScene(scene);
483
+ const physics = opts.physics ?? "auto";
484
+ if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
485
+ const { enablePhysics2D } = await import("./physics-2d-DjXR5DMu.js").then((n) => n.r);
486
+ await enablePhysics2D(engine);
487
+ } else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
488
+ const { enablePhysics3D } = await import("./physics-3d-DF8npb1O.js").then((n) => n.r);
489
+ await enablePhysics3D(engine);
490
+ }
491
+ const failures = [];
492
+ const snapshots = [];
493
+ const ctx = {
494
+ engine,
495
+ scene,
496
+ timeMs: 0,
497
+ getNode: (path) => scene.root.getNode(path),
498
+ capture: () => captureScene(scene)
499
+ };
500
+ const applyStep = (step) => {
501
+ if (step.press) engine.input.pressAction(step.press);
502
+ if (step.release) engine.input.releaseAction(step.release);
503
+ if (step.vector) engine.input.setActionVector(...step.vector);
504
+ if (step.key) engine.input.handleKey(step.key[0], step.key[1]);
505
+ step.do?.(ctx);
506
+ if (step.assert) try {
507
+ if (step.assert(ctx) === false) failures.push({
508
+ atMs: step.atMs,
509
+ label: step.label,
510
+ message: "assert returned false"
511
+ });
512
+ } catch (e) {
513
+ failures.push({
514
+ atMs: step.atMs,
515
+ label: step.label,
516
+ message: e instanceof Error ? e.message : String(e)
517
+ });
518
+ }
519
+ };
520
+ const stepMs = 1e3 / (opts.fixedHz ?? 60);
521
+ const totalTicks = Math.round(opts.durationMs / stepMs);
522
+ const steps = [...opts.steps ?? []].sort((a, b) => a.atMs - b.atMs);
523
+ let stepIndex = 0;
524
+ let nextSnapshot = opts.snapshotEveryMs ?? Number.POSITIVE_INFINITY;
525
+ for (let i = 1; i <= totalTicks; i++) {
526
+ const t = i * stepMs;
527
+ ctx.timeMs = t;
528
+ while (stepIndex < steps.length && steps[stepIndex].atMs <= t) {
529
+ applyStep(steps[stepIndex]);
530
+ stepIndex += 1;
531
+ }
532
+ engine.step();
533
+ if (t >= nextSnapshot - 1e-6) {
534
+ snapshots.push({
535
+ atMs: Math.round(t),
536
+ capture: captureScene(scene)
537
+ });
538
+ nextSnapshot += opts.snapshotEveryMs;
539
+ }
540
+ }
541
+ for (; stepIndex < steps.length; stepIndex++) {
542
+ const missed = steps[stepIndex];
543
+ failures.push({
544
+ atMs: missed.atMs,
545
+ label: missed.label,
546
+ message: `step never executed — atMs ${missed.atMs} is beyond the ${opts.durationMs}ms run`
547
+ });
548
+ }
549
+ const finalCapture = captureScene(scene);
550
+ const logs = [...engine.log.entries()];
551
+ const swallowed = engine.stats().errors;
552
+ if (swallowed > 0) failures.push({
553
+ atMs: opts.durationMs,
554
+ label: "engine",
555
+ message: `${swallowed} error(s) were swallowed to keep the loop alive — the logs above name the node, the behavior and the phase`
556
+ });
557
+ engine.dispose();
558
+ const ok = failures.length === 0;
559
+ return {
560
+ ok,
561
+ failures,
562
+ snapshots,
563
+ logs,
564
+ finalCapture,
565
+ describe: () => {
566
+ const engineWarnings = logs.filter((l) => l.level === "warn" || l.level === "error");
567
+ const head = `run ${ok ? "OK" : "FAILED"} — ${opts.durationMs}ms simulated, ${failures.length} failure(s), ${logs.length} log(s)`;
568
+ const fails = failures.map((f) => ` ✗ at ${f.atMs}ms${f.label ? ` [${f.label}]` : ""}: ${f.message}`);
569
+ const noisy = engineWarnings.map((l) => ` ! ${l.level}: ${l.parts.map((p) => typeof p === "string" ? p : String(p)).join(" ")}`);
570
+ return [
571
+ head,
572
+ ...fails,
573
+ ...noisy,
574
+ describeCapture(finalCapture)
575
+ ].join("\n");
576
+ }
577
+ };
578
+ }
579
+ /**
580
+ * The headless play loop behind `incanto-play`: load → feed inputs by intent
581
+ * (`session.engine.input.pressAction(...)`) → `step(ms)` → `capture()` the
582
+ * state AS A SCENE FILE. What a screenshot is to humans, the capture is to
583
+ * agents: complete, structured, and in the exact format they already read.
584
+ */
585
+ async function createPlaySession(json, opts = {}) {
586
+ registerAllNodes();
587
+ for (const [name, ctor] of Object.entries(opts.behaviors ?? {})) registerBehavior(name, ctor, { replace: true });
588
+ const engine = new Engine({
589
+ seed: opts.seed,
590
+ fixedHz: opts.fixedHz,
591
+ scheduler: () => () => {}
592
+ });
593
+ const scene = loadScene(structuredClone(json), {
594
+ resolveScene: opts.resolveScene,
595
+ stubMissingBehaviors: opts.stubMissingBehaviors,
596
+ engine
597
+ });
598
+ engine.setScene(scene);
599
+ const physics = opts.physics ?? "auto";
600
+ if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
601
+ const { enablePhysics2D } = await import("./physics-2d-DjXR5DMu.js").then((n) => n.r);
602
+ await enablePhysics2D(engine);
603
+ } else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
604
+ const { enablePhysics3D } = await import("./physics-3d-DF8npb1O.js").then((n) => n.r);
605
+ await enablePhysics3D(engine);
606
+ }
607
+ const stepMs = 1e3 / (opts.fixedHz ?? 60);
608
+ let timeMs = 0;
609
+ let logCursor = 0;
610
+ return {
611
+ engine,
612
+ scene,
613
+ get timeMs() {
614
+ return timeMs;
615
+ },
616
+ step(ms) {
617
+ const ticks = Math.max(1, Math.round(ms / stepMs));
618
+ for (let i = 0; i < ticks; i++) engine.step();
619
+ timeMs += ticks * stepMs;
620
+ },
621
+ capture() {
622
+ return scene.toJSON();
623
+ },
624
+ describe() {
625
+ return describeCapture(captureScene(scene));
626
+ },
627
+ framing() {
628
+ return framingText(describeFraming(scene));
629
+ },
630
+ drainLogs() {
631
+ const all = engine.log.entries();
632
+ const fresh = all.filter((e) => e.seq > logCursor);
633
+ if (all.length > 0) logCursor = all[all.length - 1].seq;
634
+ return [...fresh];
635
+ },
636
+ dispose() {
637
+ engine.dispose();
638
+ }
639
+ };
640
+ }
641
+ //#endregion
642
+ export { registerAllNodes as a, describeFraming as c, findFloatingProps as i, framingText as l, createPlaySession as n, runScript as o, describeCapture as r, validateScene as s, captureScene as t };