incanto 0.36.2 → 0.38.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 (57) hide show
  1. package/bin/incanto-feel.mjs +93 -0
  2. package/bin/incanto-playtest.mjs +136 -0
  3. package/dist/2d.d.ts +2 -2
  4. package/dist/2d.js +3 -3
  5. package/dist/3d.d.ts +44 -3
  6. package/dist/3d.js +4 -32
  7. package/dist/{behavior-DibCwrW7.d.ts → behavior-B5PJ32eu.d.ts} +106 -6
  8. package/dist/{create-game-D-StzrTj.js → create-game-C4t5gVhc.js} +5 -5
  9. package/dist/{create-game-DnOu2aAa.js → create-game-DqY2lMBc.js} +783 -689
  10. package/dist/debug.d.ts +1 -1
  11. package/dist/{duplicate-Cvb1BSca.js → duplicate-CRtihGmC.js} +1 -1
  12. package/dist/editor.js +21 -0
  13. package/dist/{environment-presets-fK9oyrrR.js → environment-presets-C0Axpfth.js} +75 -9
  14. package/dist/{gameplay-C0MUMSak.js → gameplay-CsJDUQh_.js} +2 -2
  15. package/dist/gameplay.d.ts +1 -1
  16. package/dist/gameplay.js +1 -1
  17. package/dist/index.d.ts +4 -50
  18. package/dist/index.js +5 -75
  19. package/dist/{loader-BqigZsfF.d.ts → loader-DSMdqasu.d.ts} +1 -1
  20. package/dist/{loader-DAZlgqeC.js → loader-r49nDwB4.js} +15 -0
  21. package/dist/net.d.ts +1 -1
  22. package/dist/net.js +3 -3
  23. package/dist/{pathfinding-B3QtdhvZ.d.ts → pathfinding-BrE5Uec6.d.ts} +1 -1
  24. package/dist/{physics-2d-B4v39hca.js → physics-2d-OfKb0PV_.js} +2 -2
  25. package/dist/{physics-3d-DFJVMYET.js → physics-3d-C8242nXr.js} +3 -3
  26. package/dist/react.d.ts +1 -1
  27. package/dist/react.js +1 -1
  28. package/dist/{register-DXwtIVqP.js → register-B4RRQbyq.js} +2 -2
  29. package/dist/{register-BHAwM4bK.js → register-Dm7nrd-E.js} +322 -34
  30. package/dist/{register-COGCNitr.js → register-DnB9m7Rl.js} +2 -2
  31. package/dist/replay-Cxrr-ApH.d.ts +86 -0
  32. package/dist/{audit-D7F3n3Nt.js → replay-DH0UOuZ-.js} +72 -2
  33. package/dist/test-CoDPWx-m.js +1404 -0
  34. package/dist/test.d.ts +113 -4
  35. package/dist/test.js +3 -3
  36. package/dist/vite.js +1 -1
  37. package/editor/assets/{agent8-DMvBVhe4.js → agent8-BtIlblPm.js} +1 -1
  38. package/editor/assets/{debug-BgAq55oC.js → debug-BvTmHXI7.js} +1 -1
  39. package/editor/assets/{index-Cy_QsKbh.js → index-BS01WWgM.js} +91 -91
  40. package/editor/index.html +1 -1
  41. package/package.json +3 -1
  42. package/schemas/scene.schema.json +318 -0
  43. package/skills/incanto-3d-character.md +35 -0
  44. package/skills/incanto-building-2d-games.md +6 -0
  45. package/skills/incanto-building-3d-games.md +6 -0
  46. package/skills/incanto-game-feel.md +76 -0
  47. package/skills/incanto-node-reference.md +51 -0
  48. package/skills/incanto-performance.md +212 -0
  49. package/skills/incanto-playtesting.md +143 -0
  50. package/skills/incanto-verifying-your-game.md +41 -0
  51. package/templates-app/beacon-isle-3d/package.json +1 -1
  52. package/templates-app/tps-3d/package.json +1 -1
  53. package/templates-app/village-quest-3d/package.json +1 -1
  54. package/templates-app/village-quest-3d/src/village.scene.json +3 -1
  55. package/templates-app/village-quest-3d/verify.ts +55 -5
  56. package/dist/errors-DGRtWlSx.d.ts +0 -39
  57. package/dist/test-X_xE6Yay.js +0 -642
@@ -0,0 +1,1404 @@
1
+ import { n as loadScene, w as registerBehavior } from "./loader-r49nDwB4.js";
2
+ import { h as Engine } from "./register-Dm7nrd-E.js";
3
+ import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
+ import { n as startRecording } from "./replay-DH0UOuZ-.js";
5
+ import { n as jsonEquals, t as jsonClone } from "./json-BLk7H2Qa.js";
6
+ import { i as getNodeSchema, s as mergeStaticProps } from "./registry-IyWCGe4q.js";
7
+ import { n as registerGameplayBehaviors } from "./gameplay-CsJDUQh_.js";
8
+ import { t as registerNodes2D } from "./register-DnB9m7Rl.js";
9
+ import { n as registerNodes3D, t as resolveEnvironmentHdri } from "./environment-presets-C0Axpfth.js";
10
+ import { t as registerNodesNet } from "./register-B4RRQbyq.js";
11
+ import { Box3, Euler, Matrix4, PerspectiveCamera, Quaternion, Vector3 } from "three";
12
+ //#region src/test/framing.ts
13
+ /**
14
+ * What the camera is actually looking at — without a GPU.
15
+ *
16
+ * Nearly every decision in a 3D game is visual: is the camera framing the
17
+ * island, is anything lit, is the gem buried inside the platform. An agent
18
+ * building a game could answer none of them. `captureScene()` lists nodes and
19
+ * their props, which tells you a gem is at `[4, 1, -2]` and nothing whatsoever
20
+ * about whether you can SEE it — and the engine ships no pixel output at all,
21
+ * so "look at it" was a human's job.
22
+ *
23
+ * This is the geometry half of the answer, and it needs no renderer, no canvas
24
+ * and no browser: the camera's own matrices, the world transforms composed from
25
+ * the tree, and the colliders already declared in the JSON.
26
+ *
27
+ * What it measures: the node's ORIGIN, widened to its declared `size` (a
28
+ * MeshInstance3D's box, a Terrain3D's ground) or its collider when it has one.
29
+ * A 200 m island whose origin is off to the left still counts as on screen,
30
+ * because its box is. For a node that declares neither, read `offscreen` as
31
+ * "its origin is off-screen" — a loaded GLB's real silhouette needs the model,
32
+ * and the model needs a GPU.
33
+ */
34
+ const DEG = Math.PI / 180;
35
+ const CAMERA_TYPES = new Set(["Camera3D"]);
36
+ const LIGHT_TYPES = new Set([
37
+ "DirectionalLight3D",
38
+ "OmniLight3D",
39
+ "SpotLight3D"
40
+ ]);
41
+ /**
42
+ * World transform for every node, composed from the tree.
43
+ *
44
+ * NOT read off `_object3D`: that is the renderer's copy, and headless there may
45
+ * be no renderer to have written it. The props are the truth here.
46
+ */
47
+ function place(scene) {
48
+ const out = [];
49
+ const walk = (node, parent) => {
50
+ const type = node.constructor.typeName ?? "Node";
51
+ const n = node;
52
+ const local = new Matrix4();
53
+ 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));
54
+ const matrix = new Matrix4().multiplyMatrices(parent, local);
55
+ out.push({
56
+ node,
57
+ type,
58
+ matrix,
59
+ position: new Vector3().setFromMatrixPosition(matrix)
60
+ });
61
+ for (const child of node.children) walk(child, matrix);
62
+ };
63
+ walk(scene.root, new Matrix4());
64
+ return out;
65
+ }
66
+ /** A node draws something if its class builds its own three object. */
67
+ function draws(node) {
68
+ const proto = Object.getPrototypeOf(node);
69
+ let base = proto;
70
+ while (base && base.constructor?.typeName !== "Node3D") {
71
+ base = Object.getPrototypeOf(base);
72
+ if (!base) return false;
73
+ }
74
+ return proto._createObject3D !== base._createObject3D;
75
+ }
76
+ /**
77
+ * How big the node is on screen, from whatever it declares.
78
+ *
79
+ * `size` first (a MeshInstance3D's box, a Terrain3D's ground), then a collider.
80
+ * This is the FRAMING question — "does this thing fill the view" — and a mesh's
81
+ * own size answers it better than the body wrapped around it.
82
+ */
83
+ function extentOf(node) {
84
+ const size = node.size;
85
+ if (Array.isArray(size) && size.length >= 2 && typeof size[0] === "number") {
86
+ const x = size[0] / 2;
87
+ const y = typeof size[1] === "number" && size.length >= 3 ? size[1] / 2 : x;
88
+ const z = (size[size.length - 1] ?? size[0]) / 2;
89
+ return new Vector3(Math.abs(x), Math.abs(y), Math.abs(z));
90
+ }
91
+ return halfExtents(node);
92
+ }
93
+ /**
94
+ * A COLLIDER's half-extents, when the node declares one.
95
+ *
96
+ * Colliders live on bodies, not meshes — which is exactly right for the
97
+ * overlap report: "the gem is inside the platform" is a question about the
98
+ * physical shapes, and two meshes touching (a floor meeting a wall) is not a
99
+ * finding.
100
+ */
101
+ function halfExtents(node) {
102
+ const collider = node.collider;
103
+ if (!collider || typeof collider !== "object") return null;
104
+ const shape = collider.shape;
105
+ if (shape === "box") {
106
+ const size = collider.size ?? [
107
+ 1,
108
+ 1,
109
+ 1
110
+ ];
111
+ return new Vector3((size[0] ?? 1) / 2, (size[1] ?? 1) / 2, (size[2] ?? 1) / 2);
112
+ }
113
+ if (shape === "sphere") {
114
+ const r = collider.radius ?? .5;
115
+ return new Vector3(r, r, r);
116
+ }
117
+ if (shape === "capsule" || shape === "cylinder") {
118
+ const r = collider.radius ?? .5;
119
+ return new Vector3(r, (collider.height ?? 1) / 2 + r, r);
120
+ }
121
+ return null;
122
+ }
123
+ function describeFraming(scene, opts = {}) {
124
+ const placed = place(scene);
125
+ const cameras = placed.filter((p) => CAMERA_TYPES.has(p.type));
126
+ const chosen = cameras.find((p) => p.node.current === true) ?? cameras[0] ?? null;
127
+ const lights = [];
128
+ for (const p of placed) if (LIGHT_TYPES.has(p.type)) lights.push(`${p.node.getPath()} (${p.type})`);
129
+ const env = scene.environment;
130
+ if (env) {
131
+ if (env.preset !== void 0) lights.push(`environment.preset = ${JSON.stringify(env.preset)}`);
132
+ if (env.sky !== void 0) lights.push("environment.sky");
133
+ if (env.hdri !== void 0) lights.push("environment.hdri");
134
+ const ambient = env.ambient;
135
+ if (typeof ambient?.intensity === "number" && ambient.intensity > 0) lights.push(`environment.ambient.intensity = ${ambient.intensity}`);
136
+ }
137
+ const wanted = opts.types ? new Set(opts.types) : null;
138
+ const subjects = placed.filter((p) => !CAMERA_TYPES.has(p.type) && !LIGHT_TYPES.has(p.type) && (wanted ? wanted.has(p.type) : draws(p.node)));
139
+ const entries = [];
140
+ let camera = null;
141
+ if (chosen) {
142
+ const node = chosen.node;
143
+ const cam = new PerspectiveCamera(node.fov ?? 60, opts.aspect ?? 16 / 9, node.near ?? .1, node.far ?? 1e3);
144
+ cam.matrixWorld.copy(chosen.matrix);
145
+ cam.matrixWorldInverse.copy(chosen.matrix).invert();
146
+ cam.updateProjectionMatrix();
147
+ const forward = new Vector3(0, 0, -1).applyQuaternion(new Quaternion().setFromRotationMatrix(chosen.matrix));
148
+ camera = {
149
+ path: chosen.node.getPath(),
150
+ at: chosen.position.toArray(),
151
+ forward: [
152
+ round(forward.x),
153
+ round(forward.y),
154
+ round(forward.z)
155
+ ],
156
+ fovDeg: node.fov ?? 60
157
+ };
158
+ for (const p of subjects) {
159
+ const view = p.position.clone().applyMatrix4(cam.matrixWorldInverse);
160
+ const distance = p.position.distanceTo(chosen.position);
161
+ if (view.z > -(node.near ?? .1)) {
162
+ entries.push({
163
+ path: p.node.getPath(),
164
+ type: p.type,
165
+ at: p.position.toArray(),
166
+ where: "behind",
167
+ distance: round(distance)
168
+ });
169
+ continue;
170
+ }
171
+ const ndc = p.position.clone().project(cam);
172
+ let onScreen = Math.abs(ndc.x) <= 1 && Math.abs(ndc.y) <= 1;
173
+ if (!onScreen) {
174
+ const half = extentOf(p.node);
175
+ if (half) onScreen = cornersOnScreen(new Box3().setFromCenterAndSize(p.position, half.clone().multiplyScalar(2)), cam);
176
+ }
177
+ entries.push({
178
+ path: p.node.getPath(),
179
+ type: p.type,
180
+ at: p.position.toArray(),
181
+ where: onScreen ? "onScreen" : "offscreen",
182
+ screen: [round(ndc.x), round(ndc.y)],
183
+ distance: round(distance)
184
+ });
185
+ }
186
+ }
187
+ return {
188
+ camera,
189
+ entries,
190
+ lights,
191
+ overlaps: findOverlaps(placed)
192
+ };
193
+ }
194
+ function cornersOnScreen(box, cam) {
195
+ const min = box.min;
196
+ const max = box.max;
197
+ for (const x of [min.x, max.x]) for (const y of [min.y, max.y]) for (const z of [min.z, max.z]) {
198
+ const ndc = new Vector3(x, y, z).project(cam);
199
+ if (Math.abs(ndc.x) <= 1 && Math.abs(ndc.y) <= 1 && ndc.z <= 1) return true;
200
+ }
201
+ return false;
202
+ }
203
+ /**
204
+ * Declared colliders that intersect.
205
+ *
206
+ * The colliders, not the meshes: they are in the JSON, so this needs no GPU and
207
+ * no loaded model — and a prop buried inside a platform is the thing you were
208
+ * trying to see anyway.
209
+ */
210
+ function findOverlaps(placed) {
211
+ const boxes = [];
212
+ for (const p of placed) {
213
+ const half = halfExtents(p.node);
214
+ if (!half) continue;
215
+ boxes.push({
216
+ path: p.node.getPath(),
217
+ box: new Box3().setFromCenterAndSize(p.position, half.clone().multiplyScalar(2))
218
+ });
219
+ }
220
+ const out = [];
221
+ for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) {
222
+ const a = boxes[i];
223
+ const b = boxes[j];
224
+ if (!a || !b) continue;
225
+ if (a.path.startsWith(`${b.path}/`) || b.path.startsWith(`${a.path}/`)) continue;
226
+ if (a.box.intersectsBox(b.box)) out.push({
227
+ a: a.path,
228
+ b: b.path
229
+ });
230
+ }
231
+ return out;
232
+ }
233
+ function round(n) {
234
+ return Math.round(n * 1e3) / 1e3;
235
+ }
236
+ /** The report as something to read in a terminal. */
237
+ function framingText(report) {
238
+ const lines = [];
239
+ if (!report.camera) lines.push("camera: NONE — this scene frames nothing (no Camera3D)");
240
+ else {
241
+ const c = report.camera;
242
+ lines.push(`camera ${c.path} at [${c.at.map(round).join(", ")}] looking [${c.forward.join(", ")}] fov ${c.fovDeg}`);
243
+ }
244
+ lines.push(report.lights.length ? `lit by: ${report.lights.join(", ")}` : "lit by: NOTHING — this scene renders black");
245
+ const on = report.entries.filter((e) => e.where === "onScreen");
246
+ const off = report.entries.filter((e) => e.where === "offscreen");
247
+ const behind = report.entries.filter((e) => e.where === "behind");
248
+ lines.push(`${on.length} on screen, ${off.length} off screen, ${behind.length} behind the camera`);
249
+ for (const e of report.entries) {
250
+ const at = `[${e.at.map(round).join(", ")}]`;
251
+ const screen = e.screen ? ` screen [${e.screen.join(", ")}]` : "";
252
+ lines.push(` ${e.where.padEnd(9)} ${e.path} (${e.type}) ${at}${screen} ${e.distance}m`);
253
+ }
254
+ for (const o of report.overlaps) lines.push(` overlap ${o.a} ∩ ${o.b}`);
255
+ return lines.join("\n");
256
+ }
257
+ //#endregion
258
+ //#region src/test/playtest.ts
259
+ const CONTROLLERS = new Set(["CharacterController2D", "CharacterController3D"]);
260
+ /**
261
+ * Who the driver is playing as.
262
+ *
263
+ * In order: the body carrying a character controller, then group `player`, then
264
+ * a node literally called Player. A scene that satisfies none of them is not
265
+ * one this can drive, and says so rather than guessing.
266
+ */
267
+ function findPlayer(root) {
268
+ let found = null;
269
+ const walk = (node) => {
270
+ if (found) return;
271
+ for (const child of node.children) if (CONTROLLERS.has(child.constructor.typeName ?? "")) {
272
+ found = node;
273
+ return;
274
+ }
275
+ for (const child of node.children) walk(child);
276
+ };
277
+ walk(root);
278
+ if (found) return found;
279
+ let byGroup = null;
280
+ const groups = (node) => {
281
+ if (byGroup) return;
282
+ if (node.isInGroup("player")) byGroup = node;
283
+ for (const child of node.children) groups(child);
284
+ };
285
+ groups(root);
286
+ if (byGroup) return byGroup;
287
+ return root.getNodeOrNull("%Player");
288
+ }
289
+ function positionOf$1(node) {
290
+ const p = node.position;
291
+ if (!Array.isArray(p)) return [
292
+ 0,
293
+ 0,
294
+ 0
295
+ ];
296
+ return [
297
+ p[0] ?? 0,
298
+ p[1] ?? 0,
299
+ p[2] ?? 0
300
+ ];
301
+ }
302
+ /** World position by walking the parent chain — no renderer, no matrices. */
303
+ function worldOf(node) {
304
+ let x = 0;
305
+ let y = 0;
306
+ let z = 0;
307
+ for (let n = node; n; n = n.parent) {
308
+ const [px, py, pz] = positionOf$1(n);
309
+ x += px;
310
+ y += py;
311
+ z += pz;
312
+ }
313
+ return [
314
+ x,
315
+ y,
316
+ z
317
+ ];
318
+ }
319
+ /** Trigger volumes are destinations even without a script — that IS their job. */
320
+ const TRIGGERS = new Set(["Area2D", "Area3D"]);
321
+ /**
322
+ * Is this node somewhere the player is meant to GO?
323
+ *
324
+ * Author intent, not geometry. Keying off the tree alone reported 150 unreached
325
+ * nodes on the village scene and 138 of them were roof beams and fence posts —
326
+ * a report nobody would read twice. A node is a destination when the author
327
+ * marked it as one: it runs a behavior, it carries a group, or it is a trigger
328
+ * volume. Decoration is silent.
329
+ */
330
+ function isTarget(node) {
331
+ const type = node.constructor.typeName ?? "";
332
+ if (type.startsWith("Ui")) return false;
333
+ if (TRIGGERS.has(type)) return true;
334
+ if (node.groups.size > 0) return true;
335
+ return node.script?.name !== void 0;
336
+ }
337
+ /**
338
+ * What the SCENE says winning and losing are.
339
+ *
340
+ * Nothing here is a heuristic about game design — `GameFlow` already models
341
+ * `'won'`/`'gameover'`, `ScoreKeeper` already emits `won`/`lost`, and `Health`
342
+ * already emits `died`. The scene declared all of it; this only listens.
343
+ */
344
+ function makeOracle(engine, player) {
345
+ let outcome = null;
346
+ let damage = 0;
347
+ const offs = [];
348
+ /** First verdict wins — a run ends at its first conclusion, not its last. */
349
+ const settle = (verdict) => {
350
+ if (outcome === null) outcome = verdict;
351
+ };
352
+ const walk = (node) => {
353
+ if (node.behavior) for (const signal of node.declaredSignalNames()) {
354
+ if (signal === "won") offs.push(node.on(signal, () => settle("won")));
355
+ if (signal === "lost") offs.push(node.on(signal, () => settle("lost")));
356
+ if (signal === "died" && node === player) offs.push(node.on(signal, () => settle("lost")));
357
+ if (signal === "damaged" && node === player) offs.push(node.on(signal, () => {
358
+ damage += 1;
359
+ }));
360
+ if (signal === "flowChanged") offs.push(node.on(signal, (state) => {
361
+ if (state === "won") settle("won");
362
+ if (state === "gameover") settle("lost");
363
+ }));
364
+ }
365
+ for (const child of node.children) walk(child);
366
+ };
367
+ const root = engine.scene?.root;
368
+ if (root) walk(root);
369
+ return {
370
+ outcome: () => outcome,
371
+ damage: () => damage,
372
+ dispose: () => {
373
+ for (const off of offs) off();
374
+ }
375
+ };
376
+ }
377
+ /**
378
+ * A monkey with a memory.
379
+ *
380
+ * Not an attempt to play well — an attempt to play DIFFERENTLY, which is what
381
+ * makes automated testers find what humans do not. It holds a direction for a
382
+ * while (a per-frame coin flip goes nowhere), presses buttons at intervals, and
383
+ * biases its next heading toward the part of the world it has visited least.
384
+ * That last part is the whole difference between "wanders near spawn forever"
385
+ * and "eventually finds the door".
386
+ */
387
+ var Driver = class {
388
+ engine;
389
+ rand;
390
+ heading = 0;
391
+ headingLeft = 0;
392
+ visits = /* @__PURE__ */ new Map();
393
+ buttons;
394
+ moveAction;
395
+ held = /* @__PURE__ */ new Map();
396
+ constructor(engine, rand) {
397
+ this.engine = engine;
398
+ this.rand = rand;
399
+ const declared = engine.input.declaredActions();
400
+ this.moveAction = declared.find((a) => a.type === "vector2")?.name ?? null;
401
+ this.buttons = declared.filter((a) => a.type === "button").map((a) => a.name);
402
+ }
403
+ /** Grid cell key — coarse on purpose, this is novelty not navigation. */
404
+ cell(p) {
405
+ return `${Math.round(p[0] / 4)},${Math.round(p[2] / 4)}`;
406
+ }
407
+ step(dt, at) {
408
+ this.visits.set(this.cell(at), (this.visits.get(this.cell(at)) ?? 0) + 1);
409
+ this.headingLeft -= dt;
410
+ if (this.headingLeft <= 0) {
411
+ this.heading = this.pickHeading(at);
412
+ this.headingLeft = .4 + this.rand() * 1.2;
413
+ }
414
+ if (this.moveAction) this.engine.input.setActionVector(this.moveAction, Math.cos(this.heading), Math.sin(this.heading));
415
+ for (const button of this.buttons) {
416
+ const left = (this.held.get(button) ?? 0) - dt;
417
+ if (left > 0) {
418
+ this.held.set(button, left);
419
+ continue;
420
+ }
421
+ if (left > -.05 && left <= 0) {
422
+ this.engine.input.releaseAction(button);
423
+ this.held.set(button, -1);
424
+ continue;
425
+ }
426
+ const chance = button === "jump" ? .06 : .02;
427
+ if (this.rand() < chance) {
428
+ this.engine.input.pressAction(button);
429
+ this.held.set(button, .08 + this.rand() * .12);
430
+ }
431
+ }
432
+ }
433
+ /** Prefer the least-visited neighbouring cell; ties break randomly. */
434
+ pickHeading(at) {
435
+ let best = Number.POSITIVE_INFINITY;
436
+ let bestAngle = this.rand() * Math.PI * 2;
437
+ for (let i = 0; i < 8; i++) {
438
+ const angle = i / 8 * Math.PI * 2 + this.rand() * .3;
439
+ const probe = [
440
+ at[0] + Math.cos(angle) * 4,
441
+ at[1],
442
+ at[2] + Math.sin(angle) * 4
443
+ ];
444
+ const seen = (this.visits.get(this.cell(probe)) ?? 0) + this.rand();
445
+ if (seen < best) {
446
+ best = seen;
447
+ bestAngle = angle;
448
+ }
449
+ }
450
+ return bestAngle;
451
+ }
452
+ release() {
453
+ for (const button of this.buttons) this.engine.input.releaseAction(button);
454
+ if (this.moveAction) this.engine.input.setActionVector(this.moveAction, 0, 0);
455
+ }
456
+ };
457
+ async function runOnce(json, seed, opts) {
458
+ const session = await createPlaySession(json, {
459
+ seed,
460
+ ...opts.behaviors ? { behaviors: opts.behaviors } : {},
461
+ stubMissingBehaviors: opts.stubMissingBehaviors ?? true,
462
+ ...opts.resolveScene ? { resolveScene: opts.resolveScene } : {}
463
+ });
464
+ const { engine, scene } = session;
465
+ const player = findPlayer(scene.root);
466
+ const oracle = makeOracle(engine, player);
467
+ const targets = [];
468
+ const signals = [];
469
+ const fired = /* @__PURE__ */ new Set();
470
+ const offs = [];
471
+ const wired = /* @__PURE__ */ new Set();
472
+ for (const conn of scene.source.connections ?? []) {
473
+ if (typeof conn.from !== "string" || typeof conn.signal !== "string") continue;
474
+ const node = scene.root.getNodeOrNull(conn.from);
475
+ if (node) wired.add(`${node.getPath()}.${conn.signal}`);
476
+ }
477
+ const walkAll = (node) => {
478
+ const isDestination = node !== player && isTarget(node);
479
+ if (isDestination) targets.push(node.getPath());
480
+ for (const signal of node.declaredSignalNames()) {
481
+ const key = `${node.getPath()}.${signal}`;
482
+ if (wired.has(key) || isDestination) signals.push(key);
483
+ offs.push(node.on(signal, () => void fired.add(key)));
484
+ }
485
+ for (const child of node.children) walkAll(child);
486
+ };
487
+ walkAll(scene.root);
488
+ const targetNodes = [];
489
+ const collect = (node) => {
490
+ if (node !== player && isTarget(node)) targetNodes.push([node.getPath(), node]);
491
+ for (const child of node.children) collect(child);
492
+ };
493
+ collect(scene.root);
494
+ const start = player ? worldOf(player) : [
495
+ 0,
496
+ 0,
497
+ 0
498
+ ];
499
+ const floor = opts.fallBelow ?? start[1] - 50;
500
+ const radius = opts.reachRadius ?? 2;
501
+ const recorder = startRecording(engine);
502
+ const rand = () => engine.rng.next();
503
+ const driver = new Driver(engine, rand);
504
+ const reached = /* @__PURE__ */ new Set();
505
+ const stepMs = 1e3 / 60;
506
+ const deadline = (opts.seconds ?? 60) * 1e3;
507
+ let outcome = "stuck";
508
+ let at = start;
509
+ while (session.timeMs < deadline) {
510
+ at = player ? worldOf(player) : start;
511
+ driver.step(stepMs / 1e3, at);
512
+ session.step(stepMs);
513
+ if (player) for (const [path, node] of targetNodes) {
514
+ if (reached.has(path)) continue;
515
+ const p = worldOf(node);
516
+ if (Math.hypot(at[0] - p[0], at[1] - p[1], at[2] - p[2]) <= radius) reached.add(path);
517
+ }
518
+ const verdict = oracle.outcome();
519
+ if (verdict) {
520
+ outcome = verdict;
521
+ break;
522
+ }
523
+ if (engine.stats().errors > 0) {
524
+ outcome = "error";
525
+ break;
526
+ }
527
+ if (player && at[1] < floor) {
528
+ outcome = "fell";
529
+ break;
530
+ }
531
+ }
532
+ driver.release();
533
+ const bad = outcome !== "won";
534
+ const run = {
535
+ seed,
536
+ outcome,
537
+ timeMs: session.timeMs,
538
+ reached: [...reached],
539
+ fired: [...fired],
540
+ damageTaken: oracle.damage(),
541
+ errors: engine.stats().errors,
542
+ replay: bad ? recorder.stop() : null,
543
+ endedAt: at
544
+ };
545
+ if (!bad) recorder.stop();
546
+ for (const off of offs) off();
547
+ oracle.dispose();
548
+ session.dispose();
549
+ return {
550
+ run,
551
+ targets,
552
+ signals
553
+ };
554
+ }
555
+ /** Play the scene `runs` times and report what happened. */
556
+ async function playtest(json, opts = {}) {
557
+ const count = Math.max(1, opts.runs ?? 20);
558
+ const base = opts.seed ?? 1;
559
+ const runs = [];
560
+ let targets = [];
561
+ let signals = [];
562
+ for (let i = 0; i < count; i++) {
563
+ const one = await runOnce(json, base + i, opts);
564
+ runs.push(one.run);
565
+ targets = one.targets;
566
+ signals = one.signals;
567
+ }
568
+ const probe = await createPlaySession(json, {
569
+ seed: base,
570
+ stubMissingBehaviors: opts.stubMissingBehaviors ?? true,
571
+ ...opts.resolveScene ? { resolveScene: opts.resolveScene } : {}
572
+ });
573
+ const actions = probe.engine.input.declaredActions().map((a) => a.name);
574
+ probe.dispose();
575
+ return {
576
+ runs,
577
+ targets,
578
+ signals: [...new Set(signals)],
579
+ actions,
580
+ inertActions: []
581
+ };
582
+ }
583
+ function pct(n, total) {
584
+ return `${n}/${total}`;
585
+ }
586
+ function median(values) {
587
+ if (values.length === 0) return 0;
588
+ const s = [...values].sort((a, b) => a - b);
589
+ return s[Math.floor(s.length / 2)];
590
+ }
591
+ /**
592
+ * The report, as prose an agent reads and a person skims.
593
+ *
594
+ * Deliberately says what is WRONG and stays quiet about what is fine — a wall
595
+ * of green is a wall nobody reads.
596
+ */
597
+ function playtestText(report) {
598
+ const { runs, targets, signals } = report;
599
+ const total = runs.length;
600
+ const lines = [];
601
+ const secs = Math.round((runs[0]?.timeMs ?? 0) / 1e3);
602
+ lines.push(`${total} runs × up to ${secs}s`);
603
+ lines.push("");
604
+ const won = runs.filter((r) => r.outcome === "won");
605
+ if (won.length > 0) lines.push(` ✓ reached "won" in ${pct(won.length, total)} median ${Math.round(median(won.map((r) => r.timeMs)) / 1e3)}s`);
606
+ else lines.push(` ✗ never reached "won" in ${total} runs — nothing in this scene declares a win, or it cannot be reached`);
607
+ for (const bad of [
608
+ "error",
609
+ "fell",
610
+ "lost",
611
+ "stuck"
612
+ ]) {
613
+ const hits = runs.filter((r) => r.outcome === bad);
614
+ if (hits.length === 0) continue;
615
+ const where = bad === "fell" ? ` (last at y=${hits[0].endedAt[1].toFixed(1)})` : "";
616
+ lines.push(` ✗ ${bad} in ${pct(hits.length, total)}${where}`);
617
+ }
618
+ const everReached = new Set(runs.flatMap((r) => r.reached));
619
+ const missed = targets.filter((path) => !everReached.has(path));
620
+ if (missed.length > 0) {
621
+ lines.push("");
622
+ lines.push(` never reached (${missed.length} of ${targets.length}):`);
623
+ for (const path of missed.slice(0, 12)) lines.push(` ${path}`);
624
+ if (missed.length > 12) lines.push(` …and ${missed.length - 12} more`);
625
+ }
626
+ const everFired = new Set(runs.flatMap((r) => r.fired));
627
+ const silent = signals.filter((s) => !everFired.has(s));
628
+ if (silent.length > 0) {
629
+ lines.push("");
630
+ lines.push(` never fired (${silent.length} of ${signals.length}):`);
631
+ for (const s of silent.slice(0, 12)) lines.push(` ${s}`);
632
+ if (silent.length > 12) lines.push(` …and ${silent.length - 12} more`);
633
+ }
634
+ const damage = runs.reduce((n, r) => n + r.damageTaken, 0);
635
+ lines.push("");
636
+ lines.push(damage === 0 ? ` danger: the player took damage 0 times in ${total} runs — nothing here can hurt you` : ` danger: ${damage} hits across ${total} runs (${(damage / total).toFixed(1)} per run)`);
637
+ return lines.join("\n");
638
+ }
639
+ /** The failing runs worth saving, newest problem first. */
640
+ function failingReplays(report) {
641
+ const order = [
642
+ "error",
643
+ "fell",
644
+ "stuck",
645
+ "lost"
646
+ ];
647
+ return report.runs.filter((r) => r.replay !== null).sort((a, b) => order.indexOf(a.outcome) - order.indexOf(b.outcome)).map((r) => ({
648
+ seed: r.seed,
649
+ outcome: r.outcome,
650
+ replay: r.replay
651
+ }));
652
+ }
653
+ //#endregion
654
+ //#region src/test/feel.ts
655
+ const STEP_MS = 1e3 / 60;
656
+ /** Drop height for the buffer probe — long enough to outlast a coyote window. */
657
+ const DROP = 12;
658
+ function positionOf(node) {
659
+ const p = node.position;
660
+ return [
661
+ p?.[0] ?? 0,
662
+ p?.[1] ?? 0,
663
+ p?.[2] ?? 0
664
+ ];
665
+ }
666
+ function velocityOf(node) {
667
+ const v = node.linearVelocity;
668
+ return [
669
+ v?.[0] ?? 0,
670
+ v?.[1] ?? 0,
671
+ v?.[2] ?? 0
672
+ ];
673
+ }
674
+ async function makeProbe(json, opts) {
675
+ const session = await createPlaySession(json, {
676
+ seed: opts.seed ?? 1,
677
+ ...opts.behaviors ? { behaviors: opts.behaviors } : {},
678
+ stubMissingBehaviors: opts.stubMissingBehaviors ?? true,
679
+ ...opts.resolveScene ? { resolveScene: opts.resolveScene } : {}
680
+ });
681
+ const player = findPlayer(session.scene.root);
682
+ const declared = session.engine.input.declaredActions();
683
+ const move = opts.moveAction ?? declared.find((a) => a.type === "vector2")?.name ?? null;
684
+ const jump = opts.jumpAction ?? (declared.some((a) => a.name === "jump" && a.type === "button") ? "jump" : null);
685
+ if (!player) {
686
+ session.dispose();
687
+ return {
688
+ probe: null,
689
+ move,
690
+ jump
691
+ };
692
+ }
693
+ return {
694
+ probe: {
695
+ session,
696
+ engine: session.engine,
697
+ player,
698
+ step: (frames = 1) => session.step(frames * STEP_MS),
699
+ pos: () => positionOf(player),
700
+ vel: () => velocityOf(player),
701
+ speed: () => {
702
+ const v = velocityOf(player);
703
+ return Math.hypot(v[0], v[2]);
704
+ }
705
+ },
706
+ move,
707
+ jump
708
+ };
709
+ }
710
+ /** Let the character settle onto whatever it is standing on. */
711
+ function settle(probe) {
712
+ probe.step(60);
713
+ }
714
+ /** Hold one direction and learn what this character's plateau actually is. */
715
+ function measureAccel(probe, move) {
716
+ settle(probe);
717
+ const before = probe.pos();
718
+ probe.engine.input.setActionVector(move, 1, 0);
719
+ let latency = null;
720
+ let top = 0;
721
+ for (let f = 1; f <= 300; f++) {
722
+ probe.step();
723
+ if (latency === null) {
724
+ const p = probe.pos();
725
+ if (Math.hypot(p[0] - before[0], p[2] - before[2]) > .01) latency = f;
726
+ }
727
+ top = Math.max(top, probe.speed());
728
+ }
729
+ probe.engine.input.setActionVector(move, 0, 0);
730
+ return {
731
+ top,
732
+ latencyFrames: latency
733
+ };
734
+ }
735
+ /**
736
+ * Time from standstill to 95% of top speed.
737
+ *
738
+ * Two passes on purpose: the first learns what top speed IS (it depends on the
739
+ * scene's own props and surface), the second times the climb to 95% of it. One
740
+ * pass would have to guess the target.
741
+ */
742
+ function timeToFullSpeed(probe, move, top) {
743
+ settle(probe);
744
+ const target = top * .95;
745
+ probe.engine.input.setActionVector(move, 1, 0);
746
+ for (let f = 1; f <= 600; f++) {
747
+ probe.step();
748
+ if (probe.speed() >= target) {
749
+ probe.engine.input.setActionVector(move, 0, 0);
750
+ return Math.round(f * STEP_MS);
751
+ }
752
+ }
753
+ probe.engine.input.setActionVector(move, 0, 0);
754
+ return null;
755
+ }
756
+ function timeToStop(probe, move, top) {
757
+ settle(probe);
758
+ probe.engine.input.setActionVector(move, 1, 0);
759
+ for (let f = 0; f < 600 && probe.speed() < top * .9; f++) probe.step();
760
+ probe.engine.input.setActionVector(move, 0, 0);
761
+ for (let f = 1; f <= 600; f++) {
762
+ probe.step();
763
+ if (probe.speed() < top * .05) return Math.round(f * STEP_MS);
764
+ }
765
+ return null;
766
+ }
767
+ function turnaround(probe, move, top) {
768
+ settle(probe);
769
+ probe.engine.input.setActionVector(move, 1, 0);
770
+ for (let f = 0; f < 600 && probe.speed() < top * .9; f++) probe.step();
771
+ probe.engine.input.setActionVector(move, -1, 0);
772
+ for (let f = 1; f <= 600; f++) {
773
+ probe.step();
774
+ if (probe.vel()[0] < -top * .9) return Math.round(f * STEP_MS);
775
+ }
776
+ probe.engine.input.setActionVector(move, 0, 0);
777
+ return null;
778
+ }
779
+ function jumpShape(probe, jump) {
780
+ settle(probe);
781
+ const floor = probe.pos()[1];
782
+ probe.engine.input.pressAction(jump);
783
+ probe.step();
784
+ probe.engine.input.releaseAction(jump);
785
+ let apex = 0;
786
+ let toApex = 0;
787
+ let airtime = null;
788
+ for (let f = 2; f <= 600; f++) {
789
+ probe.step();
790
+ const h = probe.pos()[1] - floor;
791
+ if (h > apex) {
792
+ apex = h;
793
+ toApex = f;
794
+ }
795
+ if (apex > .02 && h <= .02 && airtime === null) {
796
+ airtime = Math.round(f * STEP_MS);
797
+ break;
798
+ }
799
+ }
800
+ if (apex <= .02) return {
801
+ apex: null,
802
+ toApexMs: null,
803
+ airtimeMs: null
804
+ };
805
+ return {
806
+ apex: Number(apex.toFixed(3)),
807
+ toApexMs: Math.round(toApex * STEP_MS),
808
+ airtimeMs: airtime
809
+ };
810
+ }
811
+ /**
812
+ * How long after leaving the ground a jump still fires — measured, not read.
813
+ *
814
+ * Binary search over the delay: lift the character clear of its support (the
815
+ * same thing walking off a ledge does), wait, press, and see whether it
816
+ * launched. A `coyoteSeconds` the controller never consults reports 0 here,
817
+ * which is the whole point — that was a real bug in this engine's 3D controller
818
+ * until 0.37 and the schema looked correct the entire time.
819
+ */
820
+ async function measureCoyote(json, opts, jump) {
821
+ const fired = async (delayFrames) => {
822
+ const { probe } = await makeProbe(json, opts);
823
+ if (!probe) return false;
824
+ settle(probe);
825
+ const p = probe.pos();
826
+ probe.player.position = [
827
+ p[0],
828
+ p[1] + 40,
829
+ p[2]
830
+ ];
831
+ probe.step(delayFrames);
832
+ const before = probe.vel()[1];
833
+ probe.engine.input.pressAction(jump);
834
+ probe.step();
835
+ const after = probe.vel()[1];
836
+ probe.session.dispose();
837
+ return after - before > 1;
838
+ };
839
+ if (!await fired(1)) return 0;
840
+ let lo = 1;
841
+ let hi = 60;
842
+ if (await fired(hi)) return null;
843
+ while (hi - lo > 1) {
844
+ const mid = Math.floor((lo + hi) / 2);
845
+ if (await fired(mid)) lo = mid;
846
+ else hi = mid;
847
+ }
848
+ return Math.round(lo * STEP_MS);
849
+ }
850
+ /**
851
+ * How long BEFORE landing a press still survives.
852
+ *
853
+ * Same shape: drop the character, press at T frames before it would touch down,
854
+ * and check whether it left the ground again afterwards.
855
+ */
856
+ async function measureBuffer(json, opts, jump) {
857
+ const survived = async (earlyFrames) => {
858
+ const { probe } = await makeProbe(json, opts);
859
+ if (!probe) return false;
860
+ settle(probe);
861
+ const floor = probe.pos()[1];
862
+ probe.player.position = [
863
+ probe.pos()[0],
864
+ floor + DROP,
865
+ probe.pos()[2]
866
+ ];
867
+ let frames = 0;
868
+ while (probe.pos()[1] > floor + .05 && frames < 200) {
869
+ probe.step();
870
+ frames += 1;
871
+ }
872
+ const fallFrames = frames;
873
+ probe.session.dispose();
874
+ const second = await makeProbe(json, opts);
875
+ if (!second.probe) return false;
876
+ const p2 = second.probe;
877
+ settle(p2);
878
+ p2.player.position = [
879
+ p2.pos()[0],
880
+ floor + DROP,
881
+ p2.pos()[2]
882
+ ];
883
+ p2.step(Math.max(0, fallFrames - earlyFrames));
884
+ p2.engine.input.pressAction(jump);
885
+ p2.step();
886
+ p2.engine.input.releaseAction(jump);
887
+ let rose = false;
888
+ for (let f = 0; f < 40; f++) {
889
+ p2.step();
890
+ if (p2.vel()[1] > 1) rose = true;
891
+ }
892
+ p2.session.dispose();
893
+ return rose;
894
+ };
895
+ if (!await survived(1)) return 0;
896
+ let lo = 1;
897
+ let hi = 30;
898
+ if (await survived(hi)) return null;
899
+ while (hi - lo > 1) {
900
+ const mid = Math.floor((lo + hi) / 2);
901
+ if (await survived(mid)) lo = mid;
902
+ else hi = mid;
903
+ }
904
+ return Math.round(lo * STEP_MS);
905
+ }
906
+ async function feelReport(json, opts = {}) {
907
+ const first = await makeProbe(json, opts);
908
+ const move = first.move;
909
+ const jump = first.jump;
910
+ if (!first.probe) return {
911
+ player: null,
912
+ moveAction: move,
913
+ jumpAction: jump,
914
+ topSpeed: 0,
915
+ timeToFullSpeedMs: null,
916
+ timeToStopMs: null,
917
+ turnaroundMs: null,
918
+ jumpApex: null,
919
+ timeToApexMs: null,
920
+ airtimeMs: null,
921
+ coyoteMs: null,
922
+ jumpBufferMs: null,
923
+ inputToMotionFrames: null
924
+ };
925
+ const playerPath = first.probe.player.getPath();
926
+ let topSpeed = 0;
927
+ let latency = null;
928
+ if (move) {
929
+ const ramp = measureAccel(first.probe, move);
930
+ topSpeed = Number(ramp.top.toFixed(2));
931
+ latency = ramp.latencyFrames;
932
+ }
933
+ first.probe.session.dispose();
934
+ const fresh = async () => (await makeProbe(json, opts)).probe;
935
+ let toFull = null;
936
+ let toStop = null;
937
+ let turn = null;
938
+ if (move && topSpeed > .05) {
939
+ const a = await fresh();
940
+ if (a) {
941
+ toFull = timeToFullSpeed(a, move, topSpeed);
942
+ a.session.dispose();
943
+ }
944
+ const b = await fresh();
945
+ if (b) {
946
+ toStop = timeToStop(b, move, topSpeed);
947
+ b.session.dispose();
948
+ }
949
+ const c = await fresh();
950
+ if (c) {
951
+ turn = turnaround(c, move, topSpeed);
952
+ c.session.dispose();
953
+ }
954
+ }
955
+ let shape = {
956
+ apex: null,
957
+ toApexMs: null,
958
+ airtimeMs: null
959
+ };
960
+ let coyote = null;
961
+ let buffer = null;
962
+ if (jump) {
963
+ const d = await fresh();
964
+ if (d) {
965
+ shape = jumpShape(d, jump);
966
+ d.session.dispose();
967
+ }
968
+ coyote = await measureCoyote(json, opts, jump);
969
+ buffer = await measureBuffer(json, opts, jump);
970
+ }
971
+ return {
972
+ player: playerPath,
973
+ moveAction: move,
974
+ jumpAction: jump,
975
+ topSpeed,
976
+ timeToFullSpeedMs: toFull,
977
+ timeToStopMs: toStop,
978
+ turnaroundMs: turn,
979
+ jumpApex: shape.apex,
980
+ timeToApexMs: shape.toApexMs,
981
+ airtimeMs: shape.airtimeMs,
982
+ coyoteMs: coyote,
983
+ jumpBufferMs: buffer,
984
+ inputToMotionFrames: latency
985
+ };
986
+ }
987
+ const ms = (v) => v === null ? "—" : `${v} ms`;
988
+ /** Numbers, not grades. What "good" means here is a genre decision. */
989
+ function feelText(report) {
990
+ if (!report.player) return "feel: no player found — this needs a body with a CharacterController, a node in group \"player\", or one named Player.";
991
+ const lines = [];
992
+ lines.push(`feel of ${report.player}`);
993
+ lines.push("");
994
+ lines.push(` top speed ${report.topSpeed} u/s`);
995
+ lines.push(` to full speed ${ms(report.timeToFullSpeedMs)}`);
996
+ lines.push(` to a stop ${ms(report.timeToStopMs)}`);
997
+ lines.push(` turnaround ${ms(report.turnaroundMs)}`);
998
+ lines.push(` input → motion ${report.inputToMotionFrames === null ? "—" : `${report.inputToMotionFrames} frame(s)`}`);
999
+ if (report.jumpAction) {
1000
+ lines.push("");
1001
+ lines.push(` jump apex ${report.jumpApex === null ? "— (nothing left the ground)" : `${report.jumpApex} u`}`);
1002
+ lines.push(` time to apex ${ms(report.timeToApexMs)}`);
1003
+ lines.push(` airtime ${ms(report.airtimeMs)}`);
1004
+ lines.push(` coyote window ${report.coyoteMs === null ? "always (no window)" : ms(report.coyoteMs)}`);
1005
+ lines.push(` jump buffer ${report.jumpBufferMs === null ? "always (no window)" : ms(report.jumpBufferMs)}`);
1006
+ if (report.coyoteMs === 0) {
1007
+ lines.push("");
1008
+ lines.push(" note: coyote window is 0 — a jump that only fires while the ground check");
1009
+ lines.push(" is true reads to players as unresponsive controls. Try coyoteSeconds: 0.12.");
1010
+ }
1011
+ if (report.jumpBufferMs === 0) {
1012
+ lines.push(" note: jump buffer is 0 — a press a few frames before landing is thrown away.");
1013
+ lines.push(" Try jumpBufferSeconds: 0.15.");
1014
+ }
1015
+ }
1016
+ return lines.join("\n");
1017
+ }
1018
+ //#endregion
1019
+ //#region src/test/index.ts
1020
+ /**
1021
+ * incanto/test — the browserless verification harness.
1022
+ *
1023
+ * The agent loop is "author → VERIFY → fix", and production environments
1024
+ * (e.g. the agent8 VM) have no browser. This module makes the scene state
1025
+ * itself the screenshot:
1026
+ *
1027
+ * - `validateScene(json)` — every hard load error, without a browser
1028
+ * - `runScript(json, ...)` — scripted play by ACTION intent at a fixed
1029
+ * timestep, with inline assertions and periodic snapshots
1030
+ * - `captureScene(scene)` / `describeCapture(capture)` — a structural,
1031
+ * grep-able "text screenshot" of the live tree
1032
+ *
1033
+ * Deterministic by construction: fixed timestep, seeded `engine.rng`,
1034
+ * no requestAnimationFrame.
1035
+ */
1036
+ /** Node types whose whole job is to sit on the ground. Characters, water,
1037
+ * clouds, birds and lights are all legitimately off it. */
1038
+ const GROUNDED_TYPES = ["MeshInstance3D", "InstancedMesh3D"];
1039
+ /** Under one of these, the BODY owns where the thing stands — a character's
1040
+ * skin hangs at whatever offset its rig wants, and is not a floating prop. */
1041
+ const BODY_OWNED = new Set([
1042
+ "CharacterBody3D",
1043
+ "RigidBody3D",
1044
+ "StaticBody3D",
1045
+ "AreaBody3D"
1046
+ ]);
1047
+ /**
1048
+ * Every prop that is floating or buried.
1049
+ *
1050
+ * THE most repeated mistake in scene authoring, by humans and agents alike, is
1051
+ * a hand-computed Y: correct when it is written and wrong the moment anything
1052
+ * reshapes the ground under it — a river carving its bed, a new seed, a
1053
+ * generator that mirrors a carve slightly differently. This is the check that
1054
+ * catches it, and `snapToGround` is the fix that stops it happening.
1055
+ *
1056
+ * Anything explicitly marked `snapToGround: false` is skipped: that is the
1057
+ * author saying "yes, this hangs in the air", which a bridge deck, a lantern
1058
+ * and a cloud all legitimately do.
1059
+ *
1060
+ * ```ts
1061
+ * const off = findFloatingProps(gameJson);
1062
+ * ok(`nothing floats (${off.length})`, off.length === 0);
1063
+ * ```
1064
+ */
1065
+ function findFloatingProps(json, opts = {}) {
1066
+ registerAllNodes();
1067
+ const tolerance = opts.tolerance ?? .1;
1068
+ const types = new Set(opts.types ?? GROUNDED_TYPES);
1069
+ let scene;
1070
+ try {
1071
+ scene = loadScene(json);
1072
+ } catch {
1073
+ return [];
1074
+ }
1075
+ let ground = null;
1076
+ const findGround = (node) => {
1077
+ if (!ground && node.constructor.typeName === "Terrain3D" && typeof node.heightAt === "function") ground = node;
1078
+ for (const child of node.children) findGround(child);
1079
+ };
1080
+ findGround(scene.root);
1081
+ if (!ground) return [];
1082
+ const out = [];
1083
+ const walk = (node, path, ox, oy, oz, yaw, bodyOwned) => {
1084
+ const n = node;
1085
+ let wx = ox;
1086
+ let wy = oy;
1087
+ let wz = oz;
1088
+ let ownYaw = yaw;
1089
+ if (Array.isArray(n.position)) {
1090
+ const c = Math.cos(yaw);
1091
+ const s = Math.sin(yaw);
1092
+ const lx = n.position[0] ?? 0;
1093
+ const lz = n.position[2] ?? 0;
1094
+ wx = ox + lx * c + lz * s;
1095
+ wz = oz - lx * s + lz * c;
1096
+ wy = oy + (n.position[1] ?? 0);
1097
+ ownYaw = yaw + (n.rotation?.[1] ?? 0) * Math.PI / 180;
1098
+ }
1099
+ const type = node.constructor.typeName ?? "";
1100
+ const soil = ground;
1101
+ if (types.has(type) && n.visible !== false && n.snapToGround !== false && !bodyOwned) {
1102
+ const rows = node.transforms;
1103
+ if (Array.isArray(rows) && rows.length > 0) {
1104
+ let worst = null;
1105
+ for (const row of rows) {
1106
+ const scale = row[4] ?? 1;
1107
+ const half = (n.size?.[1] ?? 0) * scale / 2;
1108
+ const gap = wy + (row[1] ?? 0) - half - soil.heightAt(wx + (row[0] ?? 0), wz + (row[2] ?? 0));
1109
+ if (Math.abs(gap) > tolerance && (!worst || Math.abs(gap) > Math.abs(worst.gap))) worst = {
1110
+ path,
1111
+ gap: Math.round(gap * 100) / 100
1112
+ };
1113
+ }
1114
+ if (worst) out.push(worst);
1115
+ } else {
1116
+ const half = (n.size?.[1] ?? 0) / 2;
1117
+ const gap = wy - half - soil.heightAt(wx, wz);
1118
+ if (Math.abs(gap) > tolerance) out.push({
1119
+ path,
1120
+ gap: Math.round(gap * 100) / 100
1121
+ });
1122
+ }
1123
+ }
1124
+ const childBodyOwned = bodyOwned || BODY_OWNED.has(type);
1125
+ for (const child of node.children) walk(child, `${path}/${child.name}`, wx, wy, wz, ownYaw, childBodyOwned);
1126
+ };
1127
+ walk(scene.root, `/${scene.root.name}`, 0, 0, 0, 0, false);
1128
+ return out;
1129
+ }
1130
+ function registerAllNodes() {
1131
+ registerNodes2D();
1132
+ registerNodes3D();
1133
+ registerNodesNet();
1134
+ registerGameplayBehaviors();
1135
+ }
1136
+ /** Snapshot the live tree: every node, every schema prop's current value. */
1137
+ function captureScene(scene) {
1138
+ const nodes = [];
1139
+ const walk = (node) => {
1140
+ const ctor = node.constructor;
1141
+ const schema = mergeStaticProps(ctor);
1142
+ const props = {};
1143
+ for (const key of Object.keys(schema)) props[key] = jsonClone(node[key]);
1144
+ const capture = {
1145
+ path: node.getPath(),
1146
+ type: ctor.typeName,
1147
+ props
1148
+ };
1149
+ if (node.uid) capture.uid = node.uid;
1150
+ if (node.groups.size > 0) capture.groups = [...node.groups];
1151
+ if (Object.keys(node.tags).length > 0) capture.tags = jsonClone(node.tags);
1152
+ if (typeof node.script?.name === "string") capture.script = node.script.name;
1153
+ nodes.push(capture);
1154
+ for (const child of node.children) walk(child);
1155
+ };
1156
+ walk(scene.root);
1157
+ const out = {
1158
+ name: scene.name,
1159
+ nodes
1160
+ };
1161
+ if (scene.dimension) out.dimension = scene.dimension;
1162
+ return out;
1163
+ }
1164
+ /**
1165
+ * Render a capture as one grep-able line per node, showing only NON-DEFAULT
1166
+ * props — the same delta discipline as scene JSON, so the interesting state
1167
+ * stands out.
1168
+ */
1169
+ function describeCapture(capture) {
1170
+ const lines = [`scene ${capture.name}${capture.dimension ? ` (${capture.dimension})` : ""}`];
1171
+ for (const node of capture.nodes) {
1172
+ const schema = defaultsFor(node.type);
1173
+ const deltas = Object.entries(node.props).filter(([key, value]) => !schema || !jsonEquals(value, schema[key]?.default ?? null)).map(([key, value]) => `${key}=${JSON.stringify(value)}`);
1174
+ const extras = [
1175
+ node.script ? `script=${node.script}` : "",
1176
+ node.groups ? `groups=${node.groups.join(",")}` : "",
1177
+ ...deltas
1178
+ ].filter(Boolean);
1179
+ lines.push(`${node.path} ${node.type}${extras.length ? ` ${extras.join(" ")}` : ""}`);
1180
+ }
1181
+ return lines.join("\n");
1182
+ }
1183
+ function defaultsFor(typeName) {
1184
+ try {
1185
+ return getNodeSchema(typeName);
1186
+ } catch {
1187
+ return null;
1188
+ }
1189
+ }
1190
+ /**
1191
+ * Run every hard load-time check headlessly. Unregistered behaviors are
1192
+ * stubbed by default (structure-only validation, no TypeScript needed) —
1193
+ * pass the real classes via `behaviors` to validate script props too.
1194
+ */
1195
+ /**
1196
+ * `environment.preset` is resolved deep inside Renderer3D, which no headless
1197
+ * tool reaches — so a typo there passed `incanto-check` and hard-failed at boot.
1198
+ * It cannot live in the core loader (core must never import 3d), but it belongs
1199
+ * exactly here: the test entry already registers the 3D nodes.
1200
+ */
1201
+ function checkEnvironment(json) {
1202
+ const env = json?.environment;
1203
+ if (!env || typeof env !== "object") return;
1204
+ if (env.preset === void 0) return;
1205
+ if (typeof env.preset !== "string") throw new IncantoError("BAD_FORMAT", `Scene "environment.preset" must be a string, got ${JSON.stringify(env.preset)}.`);
1206
+ try {
1207
+ resolveEnvironmentHdri(env);
1208
+ } catch (error) {
1209
+ throw new IncantoError("BAD_FORMAT", error instanceof Error ? error.message : String(error));
1210
+ }
1211
+ }
1212
+ function validateScene(json, opts = {}) {
1213
+ registerAllNodes();
1214
+ for (const [name, ctor] of Object.entries(opts.behaviors ?? {})) registerBehavior(name, ctor, { replace: true });
1215
+ try {
1216
+ checkEnvironment(json);
1217
+ loadScene(json, {
1218
+ resolveScene: opts.resolveScene,
1219
+ stubMissingBehaviors: !opts.strictBehaviors
1220
+ }).root.free();
1221
+ return { ok: true };
1222
+ } catch (e) {
1223
+ if (e instanceof IncantoError) return {
1224
+ ok: false,
1225
+ error: e
1226
+ };
1227
+ throw e;
1228
+ }
1229
+ }
1230
+ /**
1231
+ * Load a scene and play it headlessly at a fixed timestep, driving input by
1232
+ * ACTION intent. This is the e2e loop without a browser: script → simulate →
1233
+ * snapshot → assert.
1234
+ */
1235
+ async function runScript(json, opts) {
1236
+ registerAllNodes();
1237
+ for (const [name, ctor] of Object.entries(opts.behaviors ?? {})) registerBehavior(name, ctor, { replace: true });
1238
+ const engine = new Engine({
1239
+ seed: opts.seed,
1240
+ fixedHz: opts.fixedHz,
1241
+ scheduler: () => () => {}
1242
+ });
1243
+ const scene = loadScene(structuredClone(json), { resolveScene: opts.resolveScene });
1244
+ engine.setScene(scene);
1245
+ const physics = opts.physics ?? "auto";
1246
+ if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
1247
+ const { enablePhysics2D } = await import("./physics-2d-OfKb0PV_.js").then((n) => n.r);
1248
+ await enablePhysics2D(engine);
1249
+ } else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
1250
+ const { enablePhysics3D } = await import("./physics-3d-C8242nXr.js").then((n) => n.r);
1251
+ await enablePhysics3D(engine);
1252
+ }
1253
+ const failures = [];
1254
+ const snapshots = [];
1255
+ const ctx = {
1256
+ engine,
1257
+ scene,
1258
+ timeMs: 0,
1259
+ getNode: (path) => scene.root.getNode(path),
1260
+ capture: () => captureScene(scene)
1261
+ };
1262
+ const applyStep = (step) => {
1263
+ if (step.press) engine.input.pressAction(step.press);
1264
+ if (step.release) engine.input.releaseAction(step.release);
1265
+ if (step.vector) engine.input.setActionVector(...step.vector);
1266
+ if (step.key) engine.input.handleKey(step.key[0], step.key[1]);
1267
+ step.do?.(ctx);
1268
+ if (step.assert) try {
1269
+ if (step.assert(ctx) === false) failures.push({
1270
+ atMs: step.atMs,
1271
+ label: step.label,
1272
+ message: "assert returned false"
1273
+ });
1274
+ } catch (e) {
1275
+ failures.push({
1276
+ atMs: step.atMs,
1277
+ label: step.label,
1278
+ message: e instanceof Error ? e.message : String(e)
1279
+ });
1280
+ }
1281
+ };
1282
+ const stepMs = 1e3 / (opts.fixedHz ?? 60);
1283
+ const totalTicks = Math.round(opts.durationMs / stepMs);
1284
+ const steps = [...opts.steps ?? []].sort((a, b) => a.atMs - b.atMs);
1285
+ let stepIndex = 0;
1286
+ let nextSnapshot = opts.snapshotEveryMs ?? Number.POSITIVE_INFINITY;
1287
+ for (let i = 1; i <= totalTicks; i++) {
1288
+ const t = i * stepMs;
1289
+ ctx.timeMs = t;
1290
+ while (stepIndex < steps.length && steps[stepIndex].atMs <= t) {
1291
+ applyStep(steps[stepIndex]);
1292
+ stepIndex += 1;
1293
+ }
1294
+ engine.step();
1295
+ if (t >= nextSnapshot - 1e-6) {
1296
+ snapshots.push({
1297
+ atMs: Math.round(t),
1298
+ capture: captureScene(scene)
1299
+ });
1300
+ nextSnapshot += opts.snapshotEveryMs;
1301
+ }
1302
+ }
1303
+ for (; stepIndex < steps.length; stepIndex++) {
1304
+ const missed = steps[stepIndex];
1305
+ failures.push({
1306
+ atMs: missed.atMs,
1307
+ label: missed.label,
1308
+ message: `step never executed — atMs ${missed.atMs} is beyond the ${opts.durationMs}ms run`
1309
+ });
1310
+ }
1311
+ const finalCapture = captureScene(scene);
1312
+ const logs = [...engine.log.entries()];
1313
+ const swallowed = engine.stats().errors;
1314
+ if (swallowed > 0) failures.push({
1315
+ atMs: opts.durationMs,
1316
+ label: "engine",
1317
+ message: `${swallowed} error(s) were swallowed to keep the loop alive — the logs above name the node, the behavior and the phase`
1318
+ });
1319
+ engine.dispose();
1320
+ const ok = failures.length === 0;
1321
+ return {
1322
+ ok,
1323
+ failures,
1324
+ snapshots,
1325
+ logs,
1326
+ finalCapture,
1327
+ describe: () => {
1328
+ const engineWarnings = logs.filter((l) => l.level === "warn" || l.level === "error");
1329
+ const head = `run ${ok ? "OK" : "FAILED"} — ${opts.durationMs}ms simulated, ${failures.length} failure(s), ${logs.length} log(s)`;
1330
+ const fails = failures.map((f) => ` ✗ at ${f.atMs}ms${f.label ? ` [${f.label}]` : ""}: ${f.message}`);
1331
+ const noisy = engineWarnings.map((l) => ` ! ${l.level}: ${l.parts.map((p) => typeof p === "string" ? p : String(p)).join(" ")}`);
1332
+ return [
1333
+ head,
1334
+ ...fails,
1335
+ ...noisy,
1336
+ describeCapture(finalCapture)
1337
+ ].join("\n");
1338
+ }
1339
+ };
1340
+ }
1341
+ /**
1342
+ * The headless play loop behind `incanto-play`: load → feed inputs by intent
1343
+ * (`session.engine.input.pressAction(...)`) → `step(ms)` → `capture()` the
1344
+ * state AS A SCENE FILE. What a screenshot is to humans, the capture is to
1345
+ * agents: complete, structured, and in the exact format they already read.
1346
+ */
1347
+ async function createPlaySession(json, opts = {}) {
1348
+ registerAllNodes();
1349
+ for (const [name, ctor] of Object.entries(opts.behaviors ?? {})) registerBehavior(name, ctor, { replace: true });
1350
+ const engine = new Engine({
1351
+ seed: opts.seed,
1352
+ fixedHz: opts.fixedHz,
1353
+ scheduler: () => () => {}
1354
+ });
1355
+ const scene = loadScene(structuredClone(json), {
1356
+ resolveScene: opts.resolveScene,
1357
+ stubMissingBehaviors: opts.stubMissingBehaviors,
1358
+ engine
1359
+ });
1360
+ engine.setScene(scene);
1361
+ const physics = opts.physics ?? "auto";
1362
+ if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
1363
+ const { enablePhysics2D } = await import("./physics-2d-OfKb0PV_.js").then((n) => n.r);
1364
+ await enablePhysics2D(engine);
1365
+ } else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
1366
+ const { enablePhysics3D } = await import("./physics-3d-C8242nXr.js").then((n) => n.r);
1367
+ await enablePhysics3D(engine);
1368
+ }
1369
+ const stepMs = 1e3 / (opts.fixedHz ?? 60);
1370
+ let timeMs = 0;
1371
+ let logCursor = 0;
1372
+ return {
1373
+ engine,
1374
+ scene,
1375
+ get timeMs() {
1376
+ return timeMs;
1377
+ },
1378
+ step(ms) {
1379
+ const ticks = Math.max(1, Math.round(ms / stepMs));
1380
+ for (let i = 0; i < ticks; i++) engine.step();
1381
+ timeMs += ticks * stepMs;
1382
+ },
1383
+ capture() {
1384
+ return scene.toJSON();
1385
+ },
1386
+ describe() {
1387
+ return describeCapture(captureScene(scene));
1388
+ },
1389
+ framing() {
1390
+ return framingText(describeFraming(scene));
1391
+ },
1392
+ drainLogs() {
1393
+ const all = engine.log.entries();
1394
+ const fresh = all.filter((e) => e.seq > logCursor);
1395
+ if (all.length > 0) logCursor = all[all.length - 1].seq;
1396
+ return [...fresh];
1397
+ },
1398
+ dispose() {
1399
+ engine.dispose();
1400
+ }
1401
+ };
1402
+ }
1403
+ //#endregion
1404
+ export { registerAllNodes as a, feelReport as c, findPlayer as d, playtest as f, framingText as h, findFloatingProps as i, feelText as l, describeFraming as m, createPlaySession as n, runScript as o, playtestText as p, describeCapture as r, validateScene as s, captureScene as t, failingReplays as u };