incanto 0.69.0 → 0.71.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 (147) hide show
  1. package/bin/_behaviors-loader.mjs +22 -0
  2. package/bin/_read-json.mjs +28 -0
  3. package/bin/incanto-assets.mjs +5 -4
  4. package/bin/incanto-check.mjs +58 -31
  5. package/bin/incanto-editor.mjs +128 -5
  6. package/bin/incanto-env.mjs +3 -2
  7. package/bin/incanto-feel.mjs +24 -13
  8. package/bin/incanto-frame.mjs +8 -1
  9. package/bin/incanto-multiplay.mjs +11 -9
  10. package/bin/incanto-new.mjs +105 -10
  11. package/bin/incanto-play.mjs +158 -16
  12. package/bin/incanto-playtest.mjs +66 -23
  13. package/bin/incanto-skills.mjs +14 -2
  14. package/bin/incanto-verify.mjs +121 -102
  15. package/bin/incanto.mjs +3 -2
  16. package/dist/2d.d.ts +296 -34
  17. package/dist/2d.js +4 -4
  18. package/dist/3d.d.ts +157 -9
  19. package/dist/3d.js +8 -8
  20. package/dist/{agent8-CvsfVskX.js → agent8-Cfmd3ar_.js} +61 -8
  21. package/dist/{audio-player-C0j2k-yB.d.ts → audio-player-L8yccdMP.d.ts} +2 -2
  22. package/dist/{behavior-rm8vVzu_.d.ts → behavior-Dcz0fr1S.d.ts} +755 -46
  23. package/dist/{create-game-CwxEDlCm.js → create-game-CH4kt78v.js} +143 -60
  24. package/dist/{create-game-CvJ1wSRP.js → create-game-LCHBLfA4.js} +98 -48
  25. package/dist/debug.d.ts +1 -1
  26. package/dist/debug.js +2 -3
  27. package/dist/diagnostics-Cu85N3tL.d.ts +12 -0
  28. package/dist/{editor-switch-CnIOiyNJ.d.ts → editor-switch-CFU9mCec.d.ts} +22 -13
  29. package/dist/editor.js +1088 -882
  30. package/dist/env.d.ts +1 -1
  31. package/dist/env.js +5 -3
  32. package/dist/{environment-presets-BO-7XoFV.js → environment-presets-99BzP_L-.js} +298 -45
  33. package/dist/{frame-report-Dlq13Gyj.js → frame-report-D-_7YF2G.js} +1 -1
  34. package/dist/{gameplay-D1KixWK8.js → gameplay-CNULJvwh.js} +914 -214
  35. package/dist/gameplay.d.ts +254 -10
  36. package/dist/gameplay.js +2 -2
  37. package/dist/{heightmap-CRK0M4jT.js → heightmap-BYgD5Edk.js} +1 -1
  38. package/dist/index.d.ts +179 -13
  39. package/dist/index.js +10 -12
  40. package/dist/json-CfTjpvW8.js +67 -0
  41. package/dist/{loader-DadV2QCT.d.ts → loader-8-IHvbKD.d.ts} +2 -2
  42. package/dist/net.d.ts +27 -3
  43. package/dist/net.js +2 -2
  44. package/dist/{noise-CGUMx44x.js → noise-D3nPpmFg.js} +1 -1
  45. package/dist/{physics-2d-CF5yyjbj.js → physics-2d-FhrXUc6B.js} +136 -11
  46. package/dist/{physics-3d-DbYaEcZy.js → physics-3d-CFEGxBXZ.js} +104 -23
  47. package/dist/{teardown-CMqFVxO9.js → picking-DVo7fI13.js} +104 -13
  48. package/dist/react.d.ts +2 -2
  49. package/dist/react.js +2 -2
  50. package/dist/{register-DbVArsns.js → register-BYQCBySi.js} +429 -28
  51. package/dist/{register-mmfugpaQ.js → register-DuVdzxbq.js} +594 -831
  52. package/dist/{replay-BEHLDcQV.js → replay-DEvp3kyV.js} +224 -14
  53. package/dist/{replay-BQfkX4x7.d.ts → replay-F7IZHdFR.d.ts} +20 -3
  54. package/dist/{schema-B6ugCV1Q.d.ts → rng-Bb-IutXB.d.ts} +38 -21
  55. package/dist/{rng-DP-SR7eg.js → rng-CDOMybym.js} +22 -0
  56. package/dist/{loader-DZE7B07H.js → save-slots-Bvuh2p_r.js} +4619 -2318
  57. package/dist/{split-screen-Cg76MCV5.d.ts → split-screen-CIYf1zSf.d.ts} +36 -4
  58. package/dist/{split-screen-CauMwu6y.js → split-screen-eULetcg-.js} +159 -26
  59. package/dist/{sprite-animation-CY-mrr1L.js → sprite-animation-CqR2o3SA.js} +39 -8
  60. package/dist/{src-CYEu-_S9.js → src-_jk0qLsS.js} +2 -17
  61. package/dist/test-CmN54kWv.js +4264 -0
  62. package/dist/test.d.ts +639 -30
  63. package/dist/test.js +3 -3
  64. package/dist/touch-BnCyPA0G.js +519 -0
  65. package/dist/vite.d.ts +20 -37
  66. package/dist/vite.js +177 -7
  67. package/dist/{webgl-unavailable-Z5_g8x9H.js → webgl-unavailable-C8aDbGmR.js} +44 -0
  68. package/editor/assets/agent8-yLIEYHbd.js +1 -0
  69. package/editor/assets/{debug-CMULinBW.js → debug-u31w_yhq.js} +2 -2
  70. package/editor/assets/index-DK9xMGpW.js +11046 -0
  71. package/editor/index.html +1 -1
  72. package/package.json +4 -14
  73. package/schemas/scene.schema.json +34 -3
  74. package/skills/incanto-3d-character.md +14 -1
  75. package/skills/incanto-3d-models.md +12 -0
  76. package/skills/incanto-assets.md +9 -1
  77. package/skills/incanto-audio.md +8 -2
  78. package/skills/incanto-behaviors-and-scripts.md +105 -7
  79. package/skills/incanto-building-2d-games.md +97 -10
  80. package/skills/incanto-building-3d-games.md +58 -4
  81. package/skills/incanto-editor.md +46 -7
  82. package/skills/incanto-environment.md +19 -1
  83. package/skills/incanto-game-feel.md +70 -0
  84. package/skills/incanto-gameplay-behaviors.md +172 -20
  85. package/skills/incanto-hud.md +142 -7
  86. package/skills/incanto-localization.md +13 -5
  87. package/skills/incanto-multiplayer.md +83 -3
  88. package/skills/incanto-node-reference.md +239 -59
  89. package/skills/incanto-performance.md +52 -0
  90. package/skills/incanto-physics-and-input.md +123 -24
  91. package/skills/incanto-playtesting.md +112 -2
  92. package/skills/incanto-save-slots.md +217 -7
  93. package/skills/incanto-scene-json-authoring.md +69 -12
  94. package/skills/incanto-verifying-your-game.md +207 -10
  95. package/skills/incanto-web-integration.md +28 -0
  96. package/skills/incanto-your-first-game.md +3 -1
  97. package/templates-app/beacon-isle-3d/generate-world.ts +77 -9
  98. package/templates-app/beacon-isle-3d/package.json +2 -2
  99. package/templates-app/beacon-isle-3d/src/behaviors.ts +22 -0
  100. package/templates-app/beacon-isle-3d/src/game.scene.json +143 -600
  101. package/templates-app/beacon-isle-3d/src/main.ts +11 -1
  102. package/templates-app/beacon-isle-3d/tsconfig.json +1 -1
  103. package/templates-app/beacon-isle-3d/verify.ts +3 -1
  104. package/templates-app/molehill-2d/.incanto/playtest/lost-seed1.json +4277 -0
  105. package/templates-app/molehill-2d/PROJECT/Context.md +58 -0
  106. package/templates-app/molehill-2d/PROJECT/Requirements.md +39 -0
  107. package/templates-app/molehill-2d/PROJECT/Status.md +27 -0
  108. package/templates-app/molehill-2d/PROJECT/Structure.md +48 -0
  109. package/templates-app/molehill-2d/docs/project-2d-rules.md +44 -0
  110. package/templates-app/molehill-2d/index.html +73 -0
  111. package/templates-app/molehill-2d/package.json +23 -0
  112. package/templates-app/molehill-2d/src/behaviors.ts +198 -0
  113. package/templates-app/molehill-2d/src/game.scene.json +1205 -0
  114. package/templates-app/molehill-2d/src/main.ts +41 -0
  115. package/templates-app/molehill-2d/tsconfig.json +13 -0
  116. package/templates-app/molehill-2d/verify.ts +247 -0
  117. package/templates-app/molehill-2d/vite.config.ts +12 -0
  118. package/templates-app/platformer-2d/package.json +2 -2
  119. package/templates-app/platformer-2d/src/game.scene.json +178 -986
  120. package/templates-app/platformer-2d/src/main.ts +11 -1
  121. package/templates-app/platformer-2d/tsconfig.json +1 -1
  122. package/templates-app/star-survivor/package.json +2 -2
  123. package/templates-app/star-survivor/src/game.scene.json +43 -216
  124. package/templates-app/star-survivor/src/main.ts +11 -1
  125. package/templates-app/star-survivor/tsconfig.json +1 -1
  126. package/templates-app/tps-3d/PROJECT/Context.md +1 -1
  127. package/templates-app/tps-3d/package.json +2 -2
  128. package/templates-app/tps-3d/src/behaviors.ts +19 -1
  129. package/templates-app/tps-3d/src/game.scene.json +66 -263
  130. package/templates-app/tps-3d/src/main.ts +13 -2
  131. package/templates-app/tps-3d/tsconfig.json +1 -1
  132. package/templates-app/village-quest-3d/.incanto/playtest/swapped-seed1.json +1735 -0
  133. package/templates-app/village-quest-3d/package.json +2 -2
  134. package/templates-app/village-quest-3d/src/behaviors.ts +42 -4
  135. package/templates-app/village-quest-3d/src/grove.scene.json +68 -306
  136. package/templates-app/village-quest-3d/src/main.ts +11 -1
  137. package/templates-app/village-quest-3d/src/village.scene.json +202 -863
  138. package/templates-app/village-quest-3d/tsconfig.json +1 -1
  139. package/templates-app/village-quest-3d/verify.ts +14 -1
  140. package/dist/duplicate-Dj4L-5AA.js +0 -22
  141. package/dist/json-CwwhxQgb.js +0 -36
  142. package/dist/registry-CF70EArN.js +0 -212
  143. package/dist/rolldown-runtime-D7D4PA-g.js +0 -13
  144. package/dist/test-D6ISmuFE.js +0 -2383
  145. package/dist/touch-DEAmqGdf.js +0 -225
  146. package/editor/assets/agent8-BDvaZMQJ.js +0 -1
  147. package/editor/assets/index-CZIRr5EL.js +0 -10958
@@ -0,0 +1,4264 @@
1
+ import { n as jsonEquals, t as jsonClone } from "./json-CfTjpvW8.js";
2
+ import { B as HudLayer, M as resetMemorySaves, O as resolveViewport, V as HudWidgetBase, c as isCloned, h as loadScene, ot as registerBehavior, tt as StubBehavior } from "./save-slots-Bvuh2p_r.js";
3
+ import { v as Engine } from "./register-DuVdzxbq.js";
4
+ import { t as IncantoError } from "./errors-BpWbnbb_.js";
5
+ import { t as Rng } from "./rng-CDOMybym.js";
6
+ import { c as getNodeSchema, d as mergeStaticProps, u as getNodeType } from "./touch-BnCyPA0G.js";
7
+ import { n as startRecording } from "./replay-DEvp3kyV.js";
8
+ import { Q as worldPosition, n as registerGameplayBehaviors } from "./gameplay-CNULJvwh.js";
9
+ import { t as registerNodes2D } from "./register-BYQCBySi.js";
10
+ import { n as registerNodes3D, t as resolveEnvironmentHdri } from "./environment-presets-99BzP_L-.js";
11
+ import { a as NetworkManager, c as readSyncKey, i as sanitizeName, n as registerNodesNet, r as NetworkSpawner, s as findOwnerNode, t as createSplitScreen } from "./split-screen-eULetcg-.js";
12
+ import { Box3, Euler, Frustum, Matrix4, PerspectiveCamera, Quaternion, Ray, Vector3 } from "three";
13
+ //#region src/core/asset-urls.ts
14
+ /** Keys inside a `material` object that hold a texture URL. */
15
+ const MATERIAL_URL_KEYS = ["map", "normalMap"];
16
+ /**
17
+ * Walk the tree and collect every URL-valued asset prop.
18
+ *
19
+ * `$ref` values are somebody else's problem (`validateAssetRefs` resolves them
20
+ * against `assets{}` at load). Unknown node types are skipped rather than
21
+ * thrown on: this is a warning path, and the loader reports an unknown type far
22
+ * better than a walker could.
23
+ */
24
+ function sceneAssetUrls(json) {
25
+ const out = [];
26
+ const root = json?.root;
27
+ const visit = (n, parentPath) => {
28
+ if (typeof n !== "object" || n === null) return;
29
+ const node = n;
30
+ const path = `${parentPath}/${typeof node.name === "string" ? node.name : "?"}`;
31
+ const props = node.props ?? {};
32
+ if (typeof node.type === "string") {
33
+ let schema = null;
34
+ try {
35
+ schema = mergeStaticProps(getNodeType(node.type));
36
+ } catch {
37
+ schema = null;
38
+ }
39
+ if (schema) for (const [key, def] of Object.entries(schema)) {
40
+ if (!def.asset) continue;
41
+ const value = props[key];
42
+ if (typeof value === "string" && value !== "" && !value.startsWith("$")) {
43
+ const kind = def.asset.kind;
44
+ out.push({
45
+ path,
46
+ prop: key,
47
+ kind: typeof kind === "string" ? kind : "",
48
+ url: value
49
+ });
50
+ }
51
+ }
52
+ const material = props.material;
53
+ if (material && typeof material === "object" && !Array.isArray(material)) for (const key of MATERIAL_URL_KEYS) {
54
+ const value = material[key];
55
+ if (typeof value === "string" && value !== "" && !value.startsWith("$")) out.push({
56
+ path,
57
+ prop: `material.${key}`,
58
+ kind: "texture",
59
+ url: value
60
+ });
61
+ }
62
+ }
63
+ if (Array.isArray(node.children)) for (const c of node.children) visit(c, path);
64
+ };
65
+ visit(root, "");
66
+ return out;
67
+ }
68
+ //#endregion
69
+ //#region src/test/framing.ts
70
+ /**
71
+ * What the camera is actually looking at — without a GPU.
72
+ *
73
+ * Nearly every decision in a 3D game is visual: is the camera framing the
74
+ * island, is anything lit, is the gem buried inside the platform. An agent
75
+ * building a game could answer none of them. `captureScene()` lists nodes and
76
+ * their props, which tells you a gem is at `[4, 1, -2]` and nothing whatsoever
77
+ * about whether you can SEE it — and the engine ships no pixel output at all,
78
+ * so "look at it" was a human's job.
79
+ *
80
+ * This is the geometry half of the answer, and it needs no renderer, no canvas
81
+ * and no browser: the camera's own matrices, the world transforms composed from
82
+ * the tree, and the colliders already declared in the JSON.
83
+ *
84
+ * What it measures: the node's ORIGIN, widened to its declared `size` (a
85
+ * MeshInstance3D's box, a Terrain3D's ground) or its collider when it has one.
86
+ * A 200 m island whose origin is off to the left still counts as on screen,
87
+ * because its box is. For a node that declares neither, read `offscreen` as
88
+ * "its origin is off-screen" — a loaded GLB's real silhouette needs the model,
89
+ * and the model needs a GPU.
90
+ */
91
+ const DEG = Math.PI / 180;
92
+ const CAMERA_TYPES = new Set(["Camera3D"]);
93
+ const LIGHT_TYPES = new Set(["DirectionalLight3D", "OmniLight3D"]);
94
+ /** Screen-space subtrees: they ignore the camera, so framing has nothing to say. */
95
+ const SCREEN_SPACE$1 = new Set(["UILayer", "HudLayer"]);
96
+ /** Depth a 2D collider is given on the axis it does not have. */
97
+ const FLAT_DEPTH = 1e3;
98
+ /** How far two colliders must actually interpenetrate to be a finding. */
99
+ const TOUCH_EPS_2D = 1;
100
+ const TOUCH_EPS_3D = .01;
101
+ /** What a 2D scene shows when it declares neither a viewport nor a canvas. */
102
+ const DEFAULT_VIEW_2D = [960, 540];
103
+ /** `frameWidth`/`frameHeight` of the spritesheet a node references, if any. */
104
+ function sheetSize(scene, node) {
105
+ const ref = node.sheet;
106
+ if (typeof ref !== "string" || !ref.startsWith("$")) return null;
107
+ const decl = scene.assets?.[ref.slice(1)];
108
+ const w = decl?.frameWidth;
109
+ const h = decl?.frameHeight;
110
+ return typeof w === "number" && typeof h === "number" && w > 0 && h > 0 ? [w, h] : null;
111
+ }
112
+ /**
113
+ * World transform for every node, composed from the tree.
114
+ *
115
+ * NOT read off `_object3D`: that is the renderer's copy, and headless there may
116
+ * be no renderer to have written it. The props are the truth here.
117
+ */
118
+ /** @internal Shared with `picker.ts` — the same world transforms, once. */
119
+ function place(scene) {
120
+ const out = [];
121
+ const walk = (node, parent, hiddenAbove) => {
122
+ const type = node.constructor.typeName ?? "Node";
123
+ const n = node;
124
+ const local = new Matrix4();
125
+ if (Array.isArray(n.position)) {
126
+ const r = n.rotation;
127
+ const euler = typeof r === "number" ? new Euler(0, 0, r * DEG) : new Euler((r?.[0] ?? 0) * DEG, (r?.[1] ?? 0) * DEG, (r?.[2] ?? 0) * DEG, "XYZ");
128
+ local.compose(new Vector3(n.position[0] ?? 0, n.position[1] ?? 0, n.position[2] ?? 0), new Quaternion().setFromEuler(euler), new Vector3(n.scale?.[0] ?? 1, n.scale?.[1] ?? 1, n.scale?.[2] ?? 1));
129
+ }
130
+ const matrix = new Matrix4().multiplyMatrices(parent, local);
131
+ const hidden = hiddenAbove || node.visible === false;
132
+ out.push({
133
+ node,
134
+ type,
135
+ matrix,
136
+ position: new Vector3().setFromMatrixPosition(matrix),
137
+ hidden,
138
+ blank: hasNoArt(node, type),
139
+ scale: new Vector3().setFromMatrixScale(matrix),
140
+ drawn: sheetSize(scene, node)
141
+ });
142
+ if (SCREEN_SPACE$1.has(type)) return;
143
+ for (const child of node.children) walk(child, matrix, hidden);
144
+ };
145
+ walk(scene.root, new Matrix4(), false);
146
+ return out;
147
+ }
148
+ /**
149
+ * A node draws something if its class builds its own three object — OR hangs
150
+ * one off the inherited container.
151
+ *
152
+ * The second half is not a nicety. `Particles3D` builds its `Points` in
153
+ * `_syncObject3D`, like every 2D drawable does, so a create-hook test alone
154
+ * reported every explosion, every trail of embers and every bit of magic in a
155
+ * 3D scene as drawing nothing. `draws2D` below already carries this warning for
156
+ * exactly the same reason; 3D had one node shaped like the 2D ones and missed
157
+ * it.
158
+ */
159
+ /** @internal */
160
+ function draws(node) {
161
+ const proto = Object.getPrototypeOf(node);
162
+ let base = proto;
163
+ while (base && base.constructor?.typeName !== "Node3D") {
164
+ base = Object.getPrototypeOf(base);
165
+ if (!base) return false;
166
+ }
167
+ return HOOKS_3D.some((hook) => proto[hook] !== base[hook]);
168
+ }
169
+ /** Either hook can be where a node's geometry comes from. */
170
+ const HOOKS_3D = ["_createObject3D", "_syncObject3D"];
171
+ /**
172
+ * A 2D node draws something if its class puts pixels on its backing object.
173
+ *
174
+ * NOT `_createObject2D` — the 3D test's counterpart, and wrong here. 2D
175
+ * drawables all inherit the plain container and hang a quad off it in
176
+ * `_syncObject2D`, so the create hook is identical for a sprite and for a bare
177
+ * `Node2D`, and testing it reports a scene full of sprites as drawing nothing.
178
+ */
179
+ /** @internal */
180
+ /**
181
+ * The art a node needs before it can draw anything, by type.
182
+ *
183
+ * A CLOSED list beside an open one — every node type with an empty-string prop
184
+ * it cannot draw without. `the-art-a-node-needs.test.ts` enumerates that set
185
+ * from the sources and makes each member either land here or be exempted by
186
+ * name with a reason, because a list like this is only ever wrong by omission
187
+ * and the symptom is a report that does NOT mention a node.
188
+ *
189
+ * `Label`/`Label3D` were the two it opened without: text is the art of a text
190
+ * node, and a label with none draws exactly as much as a sprite with no
191
+ * texture. Everything else — `ColorRect2D`, meshes, particles — draws from its
192
+ * own props.
193
+ */
194
+ const NEEDS_ART = {
195
+ AnimatedSprite2D: "sheet",
196
+ AnimatedSprite3D: "sheet",
197
+ Label: "text",
198
+ Label3D: "text",
199
+ ModelInstance3D: "model",
200
+ Sprite2D: "texture",
201
+ Sprite3D: "texture",
202
+ TileMap2D: "texture"
203
+ };
204
+ /** Does this node have a class that draws, and nothing for it to draw? */
205
+ /**
206
+ * Every widget a HudLayer would put on screen, by path.
207
+ *
208
+ * A widget under a hidden widget is not counted — a button in a closed panel is
209
+ * not merely unpressable, it is not there, which is the same rule
210
+ * `HudLayer.focusables()` uses.
211
+ */
212
+ function hudWidgets(root) {
213
+ const out = [];
214
+ const walk = (node, hidden) => {
215
+ const type = node.constructor.typeName ?? "";
216
+ const off = hidden || node.visible === false;
217
+ if (!off && type.startsWith("Ui")) out.push(node.getPath());
218
+ for (const child of node.children) walk(child, off);
219
+ };
220
+ const find = (node) => {
221
+ if (SCREEN_SPACE$1.has(node.constructor.typeName ?? "")) {
222
+ walk(node, node.visible === false);
223
+ return;
224
+ }
225
+ for (const child of node.children) find(child);
226
+ };
227
+ find(root);
228
+ return out;
229
+ }
230
+ function hasNoArt(node, type) {
231
+ const prop = NEEDS_ART[type];
232
+ if (prop === void 0) return false;
233
+ return node[prop] === "";
234
+ }
235
+ function draws2D(node) {
236
+ const proto = Object.getPrototypeOf(node);
237
+ let base = proto;
238
+ while (base && base.constructor?.typeName !== "Node2D") {
239
+ base = Object.getPrototypeOf(base);
240
+ if (!base) return false;
241
+ }
242
+ return proto._syncObject2D !== base._syncObject2D;
243
+ }
244
+ /**
245
+ * A 2D node's half-size in pixels, and where its box hangs off the origin.
246
+ *
247
+ * Most 2D nodes are centred on their origin, but a `TileMap2D` hangs cell (0,0)
248
+ * by its TOP-LEFT corner — a whole level reported as a point at its corner is
249
+ * how you conclude the map is off screen when you are standing on it.
250
+ */
251
+ /** @internal */
252
+ function box2D(node) {
253
+ if ((node.constructor.typeName ?? "") === "TileMap2D") {
254
+ const map = node;
255
+ const rows = Array.isArray(map.cells) ? map.cells : [];
256
+ const cols = rows.reduce((n, r) => Math.max(n, String(r).length), 0);
257
+ const ts = map.tileSize ?? 32;
258
+ if (rows.length === 0 || cols === 0) return null;
259
+ const half = [cols * ts / 2, rows.length * ts / 2];
260
+ return {
261
+ half,
262
+ centre: half
263
+ };
264
+ }
265
+ const size = node.size;
266
+ if (Array.isArray(size) && typeof size[0] === "number" && typeof size[1] === "number") return {
267
+ half: [Math.abs(size[0]) / 2, Math.abs(size[1]) / 2],
268
+ centre: [0, 0]
269
+ };
270
+ const half = halfExtents(node);
271
+ return half ? {
272
+ half: [half.x, half.y],
273
+ centre: [0, 0]
274
+ } : null;
275
+ }
276
+ /**
277
+ * How big the node is on screen, from whatever it declares.
278
+ *
279
+ * `size` first (a MeshInstance3D's box, a Terrain3D's ground), then a collider.
280
+ * This is the FRAMING question — "does this thing fill the view" — and a mesh's
281
+ * own size answers it better than the body wrapped around it.
282
+ */
283
+ /**
284
+ * The node's own box, in its own units — the scene's answer where it has one.
285
+ *
286
+ * A sprite's ANCHOR moves the quad off the origin (`[0.5, 0]` stands it on its
287
+ * feet), exactly as the renderer does it, so a foot-anchored character is
288
+ * measured where it is drawn and not half a body below.
289
+ *
290
+ * @internal
291
+ */
292
+ function localBox2D(p) {
293
+ if (p.drawn) {
294
+ const [w, h] = p.drawn;
295
+ const anchor = p.node.anchor;
296
+ const ax = Array.isArray(anchor) && typeof anchor[0] === "number" ? anchor[0] : .5;
297
+ const ay = Array.isArray(anchor) && typeof anchor[1] === "number" ? anchor[1] : .5;
298
+ return {
299
+ half: [w / 2, h / 2],
300
+ centre: [(.5 - ax) * w, (.5 - ay) * h]
301
+ };
302
+ }
303
+ return box2D(p.node);
304
+ }
305
+ /**
306
+ * A placed node's box in WORLD space — the axis-aligned one that contains the
307
+ * transformed box, so scale AND rotation are in it.
308
+ *
309
+ * `box2D` and `extentOf` read the node's own props, which describe it at
310
+ * scale 1 and rotation 0. `place()` has composed both into its matrices all
311
+ * along and these were the only readers that never asked, so a 200x20 bar
312
+ * rotated 90 degrees was measured 200 wide and 20 tall while it drew 20 wide
313
+ * and 200 tall — the wrong axis, not a near miss.
314
+ *
315
+ * Corners rather than a scale multiply: a rotated box has no half-extent in
316
+ * its own axes any more, and the four (or eight) corners are the only honest
317
+ * answer an AABB can give.
318
+ *
319
+ * @internal
320
+ */
321
+ function box2DOf(p) {
322
+ const b = localBox2D(p);
323
+ if (!b) return null;
324
+ const min = new Vector3(Infinity, Infinity, 0);
325
+ const max = new Vector3(-Infinity, -Infinity, 0);
326
+ const corner = new Vector3();
327
+ for (const sx of [-1, 1]) for (const sy of [-1, 1]) {
328
+ corner.set(b.centre[0] + sx * b.half[0], b.centre[1] + sy * b.half[1], 0).applyMatrix4(p.matrix);
329
+ min.min(corner);
330
+ max.max(corner);
331
+ }
332
+ return {
333
+ half: [(max.x - min.x) / 2, (max.y - min.y) / 2],
334
+ centre: [(max.x + min.x) / 2 - p.position.x, (max.y + min.y) / 2 - p.position.y]
335
+ };
336
+ }
337
+ /** A placed node's 3D half-extent in WORLD space — scale and rotation in it. @internal */
338
+ function extentOfPlaced(p) {
339
+ const half = extentOf(p.node);
340
+ if (!half) return null;
341
+ const min = new Vector3(Infinity, Infinity, Infinity);
342
+ const max = new Vector3(-Infinity, -Infinity, -Infinity);
343
+ const corner = new Vector3();
344
+ for (const sx of [-1, 1]) for (const sy of [-1, 1]) for (const sz of [-1, 1]) {
345
+ corner.set(sx * half.x, sy * half.y, sz * half.z).applyMatrix4(p.matrix);
346
+ min.min(corner);
347
+ max.max(corner);
348
+ }
349
+ return new Vector3((max.x - min.x) / 2, (max.y - min.y) / 2, (max.z - min.z) / 2);
350
+ }
351
+ /**
352
+ * Is this world point inside the node's box, EXACTLY — rotation and all?
353
+ *
354
+ * The AABB above is what a report can print; a click deserves the real answer,
355
+ * and the real answer is cheap: put the point in the node's own space, where
356
+ * the box is axis-aligned by construction.
357
+ *
358
+ * @internal
359
+ */
360
+ function containsPoint2D(p, x, y) {
361
+ const b = localBox2D(p);
362
+ if (!b) return false;
363
+ const local = new Vector3(x, y, 0).applyMatrix4(new Matrix4().copy(p.matrix).invert());
364
+ return Math.abs(local.x - b.centre[0]) <= b.half[0] && Math.abs(local.y - b.centre[1]) <= b.half[1];
365
+ }
366
+ /** @internal */
367
+ function extentOf(node) {
368
+ const sections = node.sections;
369
+ if (Array.isArray(sections) && sections.length > 0) {
370
+ let x = 0;
371
+ let y = 0;
372
+ let z = 0;
373
+ for (const raw of sections) {
374
+ if (!raw || typeof raw !== "object") continue;
375
+ const s = raw;
376
+ const w = typeof s.width === "number" ? Math.abs(s.width) / 2 : 0;
377
+ const h = typeof s.height === "number" ? Math.abs(s.height) / 2 : 0;
378
+ const cy = typeof s.y === "number" ? s.y : 0;
379
+ const cz = typeof s.z === "number" ? s.z : 0;
380
+ x = Math.max(x, w);
381
+ y = Math.max(y, Math.abs(cy) + h);
382
+ z = Math.max(z, Math.abs(cz));
383
+ }
384
+ if (x > 0 || y > 0 || z > 0) return new Vector3(x, y, z);
385
+ }
386
+ const treeHeight = node.height;
387
+ if (node.constructor.typeName === "Tree3D" && typeof treeHeight === "number" && treeHeight > 0) {
388
+ const count = node.count;
389
+ const area = node.area;
390
+ const grove = typeof count === "number" && count > 1 && Array.isArray(area);
391
+ const x = grove ? Math.abs(area[0] ?? treeHeight) / 2 : treeHeight * .3;
392
+ const z = grove ? Math.abs(area[1] ?? treeHeight) / 2 : treeHeight * .3;
393
+ return new Vector3(x, treeHeight / 2, z);
394
+ }
395
+ const riverPath = node.path;
396
+ if (node.constructor.typeName === "River3D" && Array.isArray(riverPath) && riverPath.length > 0) {
397
+ const width = node.width;
398
+ const depth = node.depth;
399
+ const margin = (typeof width === "number" ? Math.abs(width) : 6) / 2;
400
+ let minX = Number.POSITIVE_INFINITY;
401
+ let maxX = Number.NEGATIVE_INFINITY;
402
+ let minZ = Number.POSITIVE_INFINITY;
403
+ let maxZ = Number.NEGATIVE_INFINITY;
404
+ for (const point of riverPath) {
405
+ if (!Array.isArray(point)) continue;
406
+ const px = point[0];
407
+ const pz = point[point.length - 1];
408
+ if (typeof px !== "number" || typeof pz !== "number") continue;
409
+ minX = Math.min(minX, px);
410
+ maxX = Math.max(maxX, px);
411
+ minZ = Math.min(minZ, pz);
412
+ maxZ = Math.max(maxZ, pz);
413
+ }
414
+ if (Number.isFinite(minX)) return new Vector3((maxX - minX) / 2 + margin, Math.max(typeof depth === "number" ? Math.abs(depth) : .8, .5) / 2, (maxZ - minZ) / 2 + margin);
415
+ }
416
+ const size = node.size;
417
+ if (Array.isArray(size) && size.length >= 2 && typeof size[0] === "number") {
418
+ const mesh = node.mesh;
419
+ const round = mesh === "sphere" || mesh === "gem" || mesh === "cylinder" || mesh === "capsule";
420
+ const w = size[0];
421
+ const h = typeof size[1] === "number" ? size[1] : w;
422
+ if (round) {
423
+ const y = mesh === "sphere" || mesh === "gem" ? w : mesh === "capsule" ? h / 2 + w : h / 2;
424
+ return new Vector3(Math.abs(w), Math.abs(y), Math.abs(w));
425
+ }
426
+ const x = w / 2;
427
+ const y = size.length >= 3 ? h / 2 : x;
428
+ const z = (size[size.length - 1] ?? w) / 2;
429
+ return new Vector3(Math.abs(x), Math.abs(y), Math.abs(z));
430
+ }
431
+ return halfExtents(node);
432
+ }
433
+ /**
434
+ * Where a node's box sits relative to its own ORIGIN.
435
+ *
436
+ * Every extent this file returns is a half-size around the node's position,
437
+ * which is right for a mesh (a `MeshInstance3D` is centred on itself) and wrong
438
+ * for the two shapes that are anchored at one end: a `Tree3D` stands ON its
439
+ * position, and a `LoftMesh3D`'s sections run from wherever the author put the
440
+ * first one to wherever they put the last.
441
+ *
442
+ * Without this the picker asks a box that reaches as far BELOW a tree as the
443
+ * tree is tall, and half of the tree is not in it. Zero for everything else,
444
+ * which is every other node type.
445
+ */
446
+ function extentCentreOf(node) {
447
+ const type = node.constructor.typeName ?? "";
448
+ if (type === "Tree3D") {
449
+ const height = node.height;
450
+ if (typeof height === "number" && height > 0) return new Vector3(0, height / 2, 0);
451
+ }
452
+ if (type === "LoftMesh3D") {
453
+ const sections = node.sections;
454
+ if (Array.isArray(sections) && sections.length > 0) {
455
+ let minZ = Number.POSITIVE_INFINITY;
456
+ let maxZ = Number.NEGATIVE_INFINITY;
457
+ for (const raw of sections) {
458
+ const z = raw?.z;
459
+ if (typeof z !== "number") continue;
460
+ minZ = Math.min(minZ, z);
461
+ maxZ = Math.max(maxZ, z);
462
+ }
463
+ if (Number.isFinite(minZ)) return new Vector3(0, 0, (minZ + maxZ) / 2);
464
+ }
465
+ }
466
+ return new Vector3();
467
+ }
468
+ /**
469
+ * A COLLIDER's half-extents, when the node declares one.
470
+ *
471
+ * Colliders live on bodies, not meshes — which is exactly right for the
472
+ * overlap report: "the gem is inside the platform" is a question about the
473
+ * physical shapes, and two meshes touching (a floor meeting a wall) is not a
474
+ * finding.
475
+ */
476
+ /**
477
+ * The half-extent of a node's own COLLIDER, or null.
478
+ *
479
+ * @internal `picker.ts` uses it for a body standing in for a subtree nothing
480
+ * can measure — a `ModelInstance3D` sizes itself from a GLB no harness loads.
481
+ */
482
+ function halfExtentsOf(node) {
483
+ return halfExtents(node);
484
+ }
485
+ function halfExtents(node) {
486
+ const collider = node.collider;
487
+ if (!collider || typeof collider !== "object") return null;
488
+ const shape = collider.shape;
489
+ if (shape === "box") {
490
+ const size = collider.size ?? [
491
+ 1,
492
+ 1,
493
+ 1
494
+ ];
495
+ return new Vector3((size[0] ?? 1) / 2, (size[1] ?? 1) / 2, (size[2] ?? 1) / 2);
496
+ }
497
+ if (shape === "sphere") {
498
+ const r = collider.radius ?? .5;
499
+ return new Vector3(r, r, r);
500
+ }
501
+ if (shape === "capsule" || shape === "cylinder") {
502
+ const r = collider.radius ?? .5;
503
+ return new Vector3(r, (collider.height ?? 1) / 2 + r, r);
504
+ }
505
+ if (shape === "rect") {
506
+ const size = collider.size ?? [1, 1];
507
+ return new Vector3((size[0] ?? 1) / 2, (size[1] ?? 1) / 2, .5);
508
+ }
509
+ if (shape === "circle") {
510
+ const r = collider.radius ?? .5;
511
+ return new Vector3(r, r, .5);
512
+ }
513
+ return null;
514
+ }
515
+ /**
516
+ * The same question in 2D: an orthographic window of design pixels, centred on
517
+ * the Camera2D and CLAMPED by its limits.
518
+ *
519
+ * The clamp is not a detail — it is the whole point of `limits`, and a report
520
+ * that skips it describes a view the renderer never draws. A following camera
521
+ * sits at the player's y, which in a 544 px world is hundreds of pixels past
522
+ * anything a 540 px window can show.
523
+ *
524
+ * With no camera at all a 2D scene still draws: the documented default view is
525
+ * `(0,0)`–`(w,h)`, so "no camera" is not "nothing is framed" the way it is in 3D.
526
+ */
527
+ /**
528
+ * The `Camera2D` a 2D frame is drawn through: the `current` one, else the first.
529
+ *
530
+ * @internal shared with `picker.ts`, which has to agree with this file about
531
+ * what a click can reach — a harness that clicks what the camera cannot see
532
+ * proves a click the game will not honour.
533
+ */
534
+ function camera2D(placed) {
535
+ const cameras = placed.filter((p) => p.type === "Camera2D");
536
+ return cameras.find((p) => p.node.current === true) ?? cameras[0] ?? null;
537
+ }
538
+ /**
539
+ * The world rect that camera shows, in design pixels, CLAMPED by its limits.
540
+ *
541
+ * The clamp is not a detail — it is the whole point of `limits`, and a report
542
+ * that skips it describes a view the renderer never draws. With no camera at
543
+ * all a 2D scene still draws: the documented default view is `(0,0)`–`(w,h)`.
544
+ *
545
+ * @internal shared with `picker.ts`.
546
+ */
547
+ function viewRect2D(chosen, vw, vh) {
548
+ if (!chosen) return {
549
+ minX: 0,
550
+ minY: 0,
551
+ maxX: vw,
552
+ maxY: vh
553
+ };
554
+ const cam = chosen.node;
555
+ const zoom = Math.max(.01, cam.zoom ?? 1);
556
+ const halfW = vw / (2 * zoom);
557
+ const halfH = vh / (2 * zoom);
558
+ let cx = chosen.position.x;
559
+ let cy = chosen.position.y;
560
+ const limits = cam.limits ?? [];
561
+ if (limits.length === 4) {
562
+ const [minX, minY, maxX, maxY] = limits;
563
+ cx = clampCentred(cx, minX + halfW, maxX - halfW);
564
+ cy = clampCentred(cy, minY + halfH, maxY - halfH);
565
+ }
566
+ return {
567
+ minX: cx - halfW,
568
+ minY: cy - halfH,
569
+ maxX: cx + halfW,
570
+ maxY: cy + halfH
571
+ };
572
+ }
573
+ function describeFraming2D(scene, placed, opts) {
574
+ const design = resolveViewport(scene.viewport)?.design;
575
+ const [vw, vh] = opts.viewport ?? design ?? DEFAULT_VIEW_2D;
576
+ const chosen = camera2D(placed);
577
+ let camera = null;
578
+ const view = viewRect2D(chosen, vw, vh);
579
+ if (chosen) {
580
+ const cam = chosen.node;
581
+ const zoom = Math.max(.01, cam.zoom ?? 1);
582
+ const cx = (view.minX + view.maxX) / 2;
583
+ const cy = (view.minY + view.maxY) / 2;
584
+ camera = {
585
+ path: chosen.node.getPath(),
586
+ at: [
587
+ round(cx),
588
+ round(cy),
589
+ 0
590
+ ],
591
+ view: [round(vw / zoom), round(vh / zoom)],
592
+ zoom
593
+ };
594
+ }
595
+ const wanted = opts.types ? new Set(opts.types) : null;
596
+ const centre = [(view.minX + view.maxX) / 2, (view.minY + view.maxY) / 2];
597
+ const entries = [];
598
+ for (const p of placed) {
599
+ if (p.type === "Camera2D") continue;
600
+ if (!(wanted ? wanted.has(p.type) : draws2D(p.node))) continue;
601
+ const b = box2DOf(p);
602
+ const cx = p.position.x + (b?.centre[0] ?? 0);
603
+ const cy = p.position.y + (b?.centre[1] ?? 0);
604
+ const hx = b?.half[0] ?? 0;
605
+ const hy = b?.half[1] ?? 0;
606
+ const onScreen = cx + hx >= view.minX && cx - hx <= view.maxX && cy + hy >= view.minY && cy - hy <= view.maxY;
607
+ entries.push({
608
+ path: p.node.getPath(),
609
+ type: p.type,
610
+ at: [
611
+ round(p.position.x),
612
+ round(p.position.y),
613
+ 0
614
+ ],
615
+ where: p.hidden ? "hidden" : p.blank ? "blank" : onScreen ? "onScreen" : "offscreen",
616
+ screen: [round((cx - centre[0]) / ((view.maxX - view.minX) / 2)), round((cy - centre[1]) / ((view.maxY - view.minY) / 2))],
617
+ distance: round(Math.hypot(cx - centre[0], cy - centre[1]))
618
+ });
619
+ }
620
+ return {
621
+ dimension: "2d",
622
+ camera,
623
+ entries,
624
+ widgets: hudWidgets(scene.root),
625
+ lights: [],
626
+ overlaps: findOverlaps(placed, TOUCH_EPS_2D)
627
+ };
628
+ }
629
+ function clampCentred(v, lo, hi) {
630
+ if (lo > hi) return (lo + hi) / 2;
631
+ return Math.min(hi, Math.max(lo, v));
632
+ }
633
+ function describeFraming(scene, opts = {}) {
634
+ const placed = place(scene);
635
+ if (scene.dimension === "2d") return describeFraming2D(scene, placed, opts);
636
+ const cameras = placed.filter((p) => CAMERA_TYPES.has(p.type));
637
+ const chosen = cameras.find((p) => p.node.current === true) ?? cameras[0] ?? null;
638
+ const lights = [];
639
+ for (const p of placed) if (LIGHT_TYPES.has(p.type)) lights.push(`${p.node.getPath()} (${p.type})`);
640
+ const env = scene.environment;
641
+ if (env) {
642
+ if (env.preset !== void 0) lights.push(`environment.preset = ${JSON.stringify(env.preset)}`);
643
+ if (env.sky !== void 0) lights.push("environment.sky");
644
+ if (env.hdri !== void 0) lights.push("environment.hdri");
645
+ const ambient = env.ambient;
646
+ if (typeof ambient?.intensity === "number" && ambient.intensity > 0) lights.push(`environment.ambient.intensity = ${ambient.intensity}`);
647
+ }
648
+ const wanted = opts.types ? new Set(opts.types) : null;
649
+ const subjects = placed.filter((p) => !CAMERA_TYPES.has(p.type) && !LIGHT_TYPES.has(p.type) && (wanted ? wanted.has(p.type) : draws(p.node)));
650
+ const entries = [];
651
+ let camera = null;
652
+ if (chosen) {
653
+ const node = chosen.node;
654
+ const cam = new PerspectiveCamera(node.fov ?? 60, opts.aspect ?? 16 / 9, node.near ?? .1, node.far ?? 1e3);
655
+ cam.matrixWorld.copy(chosen.matrix);
656
+ cam.matrixWorldInverse.copy(chosen.matrix).invert();
657
+ cam.updateProjectionMatrix();
658
+ const orientation = new Quaternion().setFromRotationMatrix(chosen.matrix);
659
+ const forward = new Vector3(0, 0, -1).applyQuaternion(orientation);
660
+ const up = new Vector3(0, 1, 0).applyQuaternion(orientation);
661
+ const right = new Vector3(1, 0, 0).applyQuaternion(orientation);
662
+ camera = {
663
+ path: chosen.node.getPath(),
664
+ at: chosen.position.toArray(),
665
+ forward: [
666
+ round(forward.x),
667
+ round(forward.y),
668
+ round(forward.z)
669
+ ],
670
+ up: [
671
+ round(up.x),
672
+ round(up.y),
673
+ round(up.z)
674
+ ],
675
+ rollDeg: round(Math.atan2(right.y, up.y) * 180 / Math.PI),
676
+ fovDeg: node.fov ?? 60
677
+ };
678
+ if (up.y < 0 && Math.abs(forward.y) <= .98) camera.upsideDown = true;
679
+ if (!cam.projectionMatrix.elements.every((v) => Number.isFinite(v))) {
680
+ for (const p of subjects) entries.push({
681
+ path: p.node.getPath(),
682
+ type: p.type,
683
+ at: p.position.toArray(),
684
+ where: "offscreen",
685
+ screen: [0, 0],
686
+ distance: round(p.position.distanceTo(chosen.position))
687
+ });
688
+ return {
689
+ dimension: "3d",
690
+ camera: {
691
+ ...camera,
692
+ degenerate: true
693
+ },
694
+ entries,
695
+ widgets: hudWidgets(scene.root),
696
+ lights,
697
+ overlaps: findOverlaps(placed, TOUCH_EPS_3D)
698
+ };
699
+ }
700
+ const frustum = new Frustum().setFromProjectionMatrix(new Matrix4().multiplyMatrices(cam.projectionMatrix, cam.matrixWorldInverse));
701
+ for (const p of subjects) {
702
+ const distance = p.position.distanceTo(chosen.position);
703
+ if (p.hidden || p.blank) {
704
+ entries.push({
705
+ path: p.node.getPath(),
706
+ type: p.type,
707
+ at: p.position.toArray(),
708
+ where: p.hidden ? "hidden" : "blank",
709
+ distance: round(distance)
710
+ });
711
+ continue;
712
+ }
713
+ const view = p.position.clone().applyMatrix4(cam.matrixWorldInverse);
714
+ const half = extentOfPlaced(p);
715
+ const box = half ? new Box3().setFromCenterAndSize(p.position, half.clone().multiplyScalar(2)) : null;
716
+ if (box ? !frustum.intersectsBox(box) && view.z > -(node.near ?? .1) : view.z > -(node.near ?? .1)) {
717
+ entries.push({
718
+ path: p.node.getPath(),
719
+ type: p.type,
720
+ at: p.position.toArray(),
721
+ where: "behind",
722
+ distance: round(distance)
723
+ });
724
+ continue;
725
+ }
726
+ const ndc = p.position.clone().project(cam);
727
+ let onScreen = Math.abs(ndc.x) <= 1 && Math.abs(ndc.y) <= 1;
728
+ if (!onScreen && box) onScreen = frustum.intersectsBox(box);
729
+ entries.push({
730
+ path: p.node.getPath(),
731
+ type: p.type,
732
+ at: p.position.toArray(),
733
+ where: onScreen ? "onScreen" : "offscreen",
734
+ screen: [round(ndc.x), round(ndc.y)],
735
+ distance: round(distance)
736
+ });
737
+ }
738
+ }
739
+ return {
740
+ dimension: "3d",
741
+ camera,
742
+ entries,
743
+ widgets: hudWidgets(scene.root),
744
+ lights,
745
+ overlaps: findOverlaps(placed, TOUCH_EPS_3D)
746
+ };
747
+ }
748
+ /**
749
+ * Declared colliders that intersect.
750
+ *
751
+ * The colliders, not the meshes: they are in the JSON, so this needs no GPU and
752
+ * no loaded model — and a prop buried inside a platform is the thing you were
753
+ * trying to see anyway.
754
+ */
755
+ function findOverlaps(placed, epsilon) {
756
+ const shapes = [];
757
+ for (const p of placed) {
758
+ const shape = shapeOf(p.node, p.position);
759
+ if (!shape) continue;
760
+ shapes.push({
761
+ path: p.node.getPath(),
762
+ internal: p.node.name.startsWith("__"),
763
+ shape
764
+ });
765
+ }
766
+ const out = [];
767
+ for (let i = 0; i < shapes.length; i++) for (let j = i + 1; j < shapes.length; j++) {
768
+ const a = shapes[i];
769
+ const b = shapes[j];
770
+ if (!a || !b) continue;
771
+ if (a.path.startsWith(`${b.path}/`) || b.path.startsWith(`${a.path}/`)) continue;
772
+ if (a.internal && b.internal) continue;
773
+ if (penetrates(a.shape, b.shape, epsilon)) out.push({
774
+ a: a.path,
775
+ b: b.path
776
+ });
777
+ }
778
+ return out;
779
+ }
780
+ function shapeOf(node, centre) {
781
+ const collider = node.collider;
782
+ const kind = collider?.shape;
783
+ if (kind === "sphere" || kind === "circle") return {
784
+ kind: "ball",
785
+ centre,
786
+ radius: collider?.radius ?? .5
787
+ };
788
+ const half = halfExtents(node);
789
+ if (!half) return null;
790
+ return {
791
+ kind: "box",
792
+ centre,
793
+ half: kind === "rect" ? new Vector3(half.x, half.y, FLAT_DEPTH) : half
794
+ };
795
+ }
796
+ /** True when the two shapes share more than `eps` of space, in every axis. */
797
+ function penetrates(a, b, eps) {
798
+ if (a.kind === "ball" && b.kind === "ball") return a.centre.distanceTo(b.centre) < a.radius + b.radius - eps;
799
+ if (a.kind === "ball" || b.kind === "ball") {
800
+ const ball = a.kind === "ball" ? a : b;
801
+ const box = a.kind === "ball" ? b : a;
802
+ return new Vector3(Math.max(0, Math.abs(ball.centre.x - box.centre.x) - box.half.x), Math.max(0, Math.abs(ball.centre.y - box.centre.y) - box.half.y), Math.max(0, Math.abs(ball.centre.z - box.centre.z) - box.half.z)).length() < ball.radius - eps;
803
+ }
804
+ return Math.abs(a.centre.x - b.centre.x) < a.half.x + b.half.x - eps && Math.abs(a.centre.y - b.centre.y) < a.half.y + b.half.y - eps && Math.abs(a.centre.z - b.centre.z) < a.half.z + b.half.z - eps;
805
+ }
806
+ function round(n) {
807
+ return Math.round(n * 1e3) / 1e3;
808
+ }
809
+ /** The report as something to read in a terminal. */
810
+ function framingText(report) {
811
+ const lines = [];
812
+ const is2d = report.dimension === "2d";
813
+ const unit = is2d ? "px" : "m";
814
+ const c = report.camera;
815
+ if (c && is2d) {
816
+ const [vw = 0, vh = 0] = c.view ?? [];
817
+ const zoom = c.zoom === 1 ? "" : ` zoom ${c.zoom}`;
818
+ lines.push(`camera ${c.path} centred [${c.at[0]}, ${c.at[1]}] showing ${vw}×${vh}px${zoom}`);
819
+ } else if (c) {
820
+ lines.push(`camera ${c.path} at [${c.at.map(round).join(", ")}] looking [${(c.forward ?? []).join(", ")}] up [${(c.up ?? []).join(", ")}] fov ${c.fovDeg}`);
821
+ if (c.rollDeg !== void 0 && Math.abs(c.rollDeg) > .5 && !c.upsideDown) lines.push(`camera ${c.path} rolls the horizon ${c.rollDeg}° — deliberate is a dutch angle, accidental is a two-angle \`rotation\` guess. \`lookAt\` aims with world up and cannot roll.`);
822
+ if (c.upsideDown) lines.push(`camera ${c.path} is UPSIDE DOWN — its up vector is [${(c.up ?? []).join(", ")}], so the whole frame is rolled over. Every other number in this report is the same as an upright camera would print. Check the third element of \`rotation\`.`);
823
+ if (c.degenerate) lines.push(`camera ${c.path} has a DEGENERATE frustum (fov ${c.fovDeg}) — its projection matrix is not finite, so every vertex lands at NaN and the screen is black. fov must be between 0 and 180 exclusive, near above 0, and near below far.`);
824
+ } else if (is2d) lines.push("camera: NONE — the view is (0,0)–(design), which may be intentional");
825
+ else lines.push("camera: NONE — this scene frames nothing (no Camera3D)");
826
+ if (!is2d) lines.push(report.lights.length ? `lit by: ${report.lights.join(", ")}` : "lit by: NOTHING — this scene renders black");
827
+ const on = report.entries.filter((e) => e.where === "onScreen");
828
+ const off = report.entries.filter((e) => e.where === "offscreen");
829
+ const behind = report.entries.filter((e) => e.where === "behind");
830
+ const hidden = report.entries.filter((e) => e.where === "hidden");
831
+ const blank = report.entries.filter((e) => e.where === "blank");
832
+ lines.push((is2d ? `${on.length} in view, ${off.length} outside it` : `${on.length} on screen, ${off.length} off screen, ${behind.length} behind the camera`) + (hidden.length > 0 ? `, ${hidden.length} hidden (visible: false)` : "") + (blank.length > 0 ? `, ${blank.length} with no art to draw` : ""));
833
+ if (report.widgets.length > 0) lines.push(`${report.widgets.length} HUD widget(s) on screen — ${report.widgets.slice(0, 4).join(", ")}` + (report.widgets.length > 4 ? `, +${report.widgets.length - 4} more` : ""));
834
+ if (report.entries.length === 0 && report.widgets.length === 0) lines.push("nothing on screen — this scene has no drawable node and no HUD widget in it. A scene that draws nothing looks exactly like one that failed to load.");
835
+ if (report.entries.length > 0 && on.length === 0 && blank.length > 0) lines.push(`NOTHING IS DRAWN — ${blank.length} of ${report.entries.length} drawable node(s) have no art (a sprite with no texture, a tilemap with no atlas), and none of the rest is on screen. The canvas is blank; declare the assets and point the nodes at them.`);
836
+ if (report.entries.length > 0 && on.length === 0 && hidden.length > 0) lines.push(`NOTHING IS DRAWN — ${hidden.length} of ${report.entries.length} drawable node(s) are hidden, and none of the rest is on screen. \`visible: false\` is a full stop: the renderer skips the node and its whole subtree.`);
837
+ for (const e of report.entries) {
838
+ const at = `[${(is2d ? e.at.slice(0, 2) : e.at).map(round).join(", ")}]`;
839
+ const screen = e.screen ? ` screen [${e.screen.join(", ")}]` : "";
840
+ lines.push(` ${e.where.padEnd(9)} ${e.path} (${e.type}) ${at}${screen} ${e.distance}${unit}`);
841
+ }
842
+ for (const o of report.overlaps) lines.push(` overlap ${o.a} ∩ ${o.b}`);
843
+ return lines.join("\n");
844
+ }
845
+ //#endregion
846
+ //#region src/test/picker.ts
847
+ const DEFAULT_VIEW = [960, 540];
848
+ const CAMERAS = new Set(["Camera3D"]);
849
+ const LIGHTS = new Set(["DirectionalLight3D", "OmniLight3D"]);
850
+ /**
851
+ * A picker over `scene`, in the scene's own units.
852
+ *
853
+ * 2D takes WORLD pixels — headless there is no canvas to convert from, and the
854
+ * design window is the coordinate space every 2D position in the file is
855
+ * already written in. 3D takes pixels within `viewport` and casts a ray from
856
+ * the scene's current camera.
857
+ *
858
+ * A hidden node is never picked, which matches what the player can click.
859
+ */
860
+ function geometricPicker(scene, opts = {}) {
861
+ if (typeof scene === "function") return (x, y) => geometricPicker(scene(), opts)(x, y);
862
+ return scene.dimension === "2d" ? picker2D(scene, opts) : picker3D(scene, opts);
863
+ }
864
+ function picker2D(scene, opts) {
865
+ const design = scene.viewport?.design;
866
+ const [vw = DEFAULT_VIEW[0], vh = DEFAULT_VIEW[1]] = opts.viewport ?? design ?? DEFAULT_VIEW;
867
+ return (x, y) => {
868
+ const view = viewRect2D(camera2D(place(scene)), vw, vh);
869
+ if (x < view.minX || x > view.maxX || y < view.minY || y > view.maxY) return null;
870
+ let best = null;
871
+ let seen = 0;
872
+ for (const p of drawables(scene, true)) {
873
+ seen += 1;
874
+ if (!containsPoint2D(p, x, y)) continue;
875
+ const order = orderOf(p.node) * 1e6 + seen;
876
+ if (!best || order >= best.order) best = {
877
+ node: p.node,
878
+ order
879
+ };
880
+ }
881
+ return best?.node ?? null;
882
+ };
883
+ }
884
+ /** The camera a click is seen through: the `current` one, else the first. */
885
+ function cameraOf(scene) {
886
+ const placed = place(scene);
887
+ return placed.find((p) => CAMERAS.has(p.type) && p.node.current === true) ?? placed.find((p) => CAMERAS.has(p.type)) ?? null;
888
+ }
889
+ /** The three.js camera posed where `chosen` is, ready to project through. */
890
+ function cameraAt(chosen, vw, vh) {
891
+ const node = chosen.node;
892
+ const cam = new PerspectiveCamera(node.fov ?? 60, vw / vh, node.near ?? .1, node.far ?? 1e3);
893
+ cam.matrixWorld.copy(chosen.matrix);
894
+ cam.matrixWorldInverse.copy(chosen.matrix).invert();
895
+ cam.updateProjectionMatrix();
896
+ return cam;
897
+ }
898
+ /**
899
+ * `engine.toWorld` / `engine.toScreen` from GEOMETRY, with no renderer — the
900
+ * aiming half of a mouse game, the way `geometricPicker` is the clicking half.
901
+ *
902
+ * A renderer installs these on the engine so a Behavior can ask where the
903
+ * cursor IS; headless nothing did, so a drag-and-launch, a tower placed on the
904
+ * map or a unit steered by the cursor could be clicked in a harness and never
905
+ * aimed. 2D is the identity (the picker's coordinates are already world pixels
906
+ * — there is no canvas to convert from); 3D unprojects onto the `groundY`
907
+ * plane, which is where a 3D game puts the thing you pointed at.
908
+ */
909
+ function geometricView(scene, opts = {}) {
910
+ const live = () => typeof scene === "function" ? scene() : scene;
911
+ const [vw = DEFAULT_VIEW[0], vh = DEFAULT_VIEW[1]] = opts.viewport ?? DEFAULT_VIEW;
912
+ const groundY = opts.groundY ?? 0;
913
+ return {
914
+ toWorld: (x, y) => {
915
+ const now = live();
916
+ if (now.dimension === "2d") return [x, y];
917
+ const chosen = cameraOf(now);
918
+ if (!chosen) return null;
919
+ const cam = cameraAt(chosen, vw, vh);
920
+ const origin = new Vector3().setFromMatrixPosition(chosen.matrix);
921
+ const dir = new Vector3(x / vw * 2 - 1, -(y / vh * 2 - 1), .5).unproject(cam).sub(origin);
922
+ if (Math.abs(dir.y) < 1e-6) return null;
923
+ const t = (groundY - origin.y) / dir.y;
924
+ if (t < 0) return null;
925
+ return [
926
+ origin.x + dir.x * t,
927
+ groundY,
928
+ origin.z + dir.z * t
929
+ ];
930
+ },
931
+ /**
932
+ * The general form of `toWorld`, and the one a shot needs.
933
+ *
934
+ * `toWorld` lands on a plane; a game aiming at whatever the cursor is OVER
935
+ * feeds this straight to `engine.physics.castRay`. The renderer installs
936
+ * one and the geometric side did not, so a tower placed on uneven ground —
937
+ * the case the docs send you here for — was the half of a 3D mouse game
938
+ * that stayed untestable after the other two were closed.
939
+ *
940
+ * 2D has no depth to cast into, so it answers null there, exactly as
941
+ * `Renderer2D` installs nothing.
942
+ */
943
+ screenRay: (x, y) => {
944
+ const now = live();
945
+ if (now.dimension === "2d") return null;
946
+ const chosen = cameraOf(now);
947
+ if (!chosen) return null;
948
+ const cam = cameraAt(chosen, vw, vh);
949
+ const origin = new Vector3().setFromMatrixPosition(chosen.matrix);
950
+ const dir = new Vector3(x / vw * 2 - 1, -(y / vh * 2 - 1), .5).unproject(cam).sub(origin);
951
+ if (dir.lengthSq() < 1e-12) return null;
952
+ dir.normalize();
953
+ return {
954
+ origin: origin.toArray(),
955
+ dir: dir.toArray()
956
+ };
957
+ },
958
+ toScreen: (world) => {
959
+ const now = live();
960
+ if (now.dimension === "2d") return {
961
+ x: world[0] ?? 0,
962
+ y: world[1] ?? 0,
963
+ behind: false
964
+ };
965
+ const chosen = cameraOf(now);
966
+ if (!chosen) return null;
967
+ const cam = cameraAt(chosen, vw, vh);
968
+ const p = new Vector3(world[0] ?? 0, world[1] ?? 0, world[2] ?? 0).project(cam);
969
+ return {
970
+ x: (p.x + 1) / 2 * vw,
971
+ y: (1 - p.y) / 2 * vh,
972
+ behind: p.z > 1
973
+ };
974
+ }
975
+ };
976
+ }
977
+ function picker3D(scene, opts) {
978
+ const chosen = cameraOf(scene);
979
+ const [vw = DEFAULT_VIEW[0], vh = DEFAULT_VIEW[1]] = opts.viewport ?? DEFAULT_VIEW;
980
+ return (x, y) => {
981
+ if (!chosen) return null;
982
+ if (x < 0 || x > vw || y < 0 || y > vh) return null;
983
+ const cam = cameraAt(chosen, vw, vh);
984
+ const ndc = new Vector3(x / vw * 2 - 1, -(y / vh * 2 - 1), .5);
985
+ const origin = new Vector3().setFromMatrixPosition(chosen.matrix);
986
+ const dir = ndc.clone().unproject(cam).sub(origin);
987
+ if (dir.lengthSq() < 1e-12) return null;
988
+ const ray = new Ray(origin, dir.normalize());
989
+ let best = null;
990
+ const hit = new Vector3();
991
+ for (const p of drawables(scene, false)) {
992
+ const half = extentOf(p.node);
993
+ if (!half) continue;
994
+ const toLocal = new Matrix4().copy(p.matrix).invert();
995
+ const localRay = new Ray().copy(ray).applyMatrix4(toLocal);
996
+ const box = new Box3().setFromCenterAndSize(extentCentreOf(p.node), half.clone().multiplyScalar(2));
997
+ if (!localRay.intersectBox(box, hit)) continue;
998
+ hit.applyMatrix4(p.matrix);
999
+ const distance = origin.distanceTo(hit);
1000
+ if (!best || distance < best.distance) best = {
1001
+ node: p.node,
1002
+ distance
1003
+ };
1004
+ }
1005
+ return best?.node ?? null;
1006
+ };
1007
+ }
1008
+ /**
1009
+ * Everything that draws, minus what the cursor can never be over.
1010
+ *
1011
+ * `!p.hidden` and `!p.blank` are the same rule `nodeFromHits` applies to a real
1012
+ * raycast: a pixel the frame does not draw has nothing under it. The two
1013
+ * answers have to agree, or a harness proves a click the game will not honour.
1014
+ */
1015
+ function drawables(scene, is2d) {
1016
+ const placed = place(scene);
1017
+ const drawn = (p) => !p.hidden && !p.blank && !CAMERAS.has(p.type) && !LIGHTS.has(p.type) && (is2d ? draws2D(p.node) : draws(p.node));
1018
+ const measured = (p) => (is2d ? localBox2D(p) : extentOf(p.node)) !== null;
1019
+ const out = placed.filter((p) => drawn(p) && measured(p));
1020
+ const covered = /* @__PURE__ */ new Set();
1021
+ for (const p of out) for (let at = p.node; at; at = at.parent) covered.add(at);
1022
+ const subtreeDraws = (node) => placed.some((q) => drawn(q) && q.node !== node && isUnder(q.node, node));
1023
+ for (const p of placed) {
1024
+ if (p.hidden || covered.has(p.node) || drawn(p)) continue;
1025
+ if (halfExtentsOf(p.node) === null || !subtreeDraws(p.node)) continue;
1026
+ out.push(p);
1027
+ }
1028
+ return out;
1029
+ }
1030
+ /** Is `node` inside `root`'s subtree? */
1031
+ function isUnder(node, root) {
1032
+ for (let at = node; at; at = at.parent) if (at === root) return true;
1033
+ return false;
1034
+ }
1035
+ function orderOf(node) {
1036
+ const n = node;
1037
+ return n.effectiveRenderOrder ?? n.renderOrder ?? 0;
1038
+ }
1039
+ /**
1040
+ * Where a node is, in the coordinates `geometricPicker` answers in.
1041
+ *
1042
+ * The click half of a headless driver: `at %Mole` has to become a point before
1043
+ * a press can land on it, and the space differs by dimension — 2D pixels are
1044
+ * the world, 3D pixels are a projection through the scene's camera. Both
1045
+ * drivers that click headlessly (`runScript` and `incanto-play`) need exactly
1046
+ * this, and the first one had it inline.
1047
+ */
1048
+ function pointAtNode(scene, path, opts = {}) {
1049
+ const node = scene.root.getNodeOrNull(path);
1050
+ if (!node) return null;
1051
+ const at = worldPosition(node);
1052
+ if (at.length < 2) return null;
1053
+ const seen = describeFraming(scene, opts.viewport ? { viewport: opts.viewport } : {}).entries.find((e) => e.path === node.getPath());
1054
+ if (seen && seen.where === "offscreen") return null;
1055
+ if (scene.dimension === "2d") return [at[0] ?? 0, at[1] ?? 0];
1056
+ if (!seen?.screen) return null;
1057
+ const [vw = DEFAULT_VIEW[0], vh = DEFAULT_VIEW[1]] = opts.viewport ?? DEFAULT_VIEW;
1058
+ return [(seen.screen[0] + 1) / 2 * vw, (1 - seen.screen[1]) / 2 * vh];
1059
+ }
1060
+ //#endregion
1061
+ //#region src/test/playtest.ts
1062
+ /**
1063
+ * Behaviours that ASK about the mouse — the open set `Clickable` is one member
1064
+ * of. A game can be entirely mouse-driven and carry no `Clickable` at all: a
1065
+ * slingshot, a drawing game, a box-select, a swipe. Property names survive
1066
+ * minification, so this reads a built game too.
1067
+ */
1068
+ const ASKS_ABOUT_THE_MOUSE = /\b(mouseJustPressed|mouseJustReleased|mousePressed|pointerPosition|pointerWorld|toWorld)\b/;
1069
+ /** Frames one drag takes: long enough that a game reading per frame sees a move. */
1070
+ const DRAG_FRAMES = 6;
1071
+ /**
1072
+ * The node types that call `engine.effects.record` — the DECLARED half of the
1073
+ * `feels` rung's comparison. Three of the four were listed and `Trail3D` was
1074
+ * not, so a trail could only ever be reported as wired-but-never-fired.
1075
+ */
1076
+ const EFFECT_NODE_TYPES = new Set([
1077
+ "Particles2D",
1078
+ "Particles3D",
1079
+ "Trail3D"
1080
+ ]);
1081
+ /**
1082
+ * Screen-space subtrees, skipped whole. Shared with `framing.ts`, which is
1083
+ * where the set already lived.
1084
+ */
1085
+ const SCREEN_SPACE = new Set(["UILayer", "HudLayer"]);
1086
+ /**
1087
+ * Out of the world, in pixels — about two screens under the spawn.
1088
+ *
1089
+ * Generous on purpose: a player who has actually left the level is falling
1090
+ * forever, so a far line costs a few frames and never mistakes a long drop
1091
+ * down a shaft for one.
1092
+ */
1093
+ const FALL_2D_PX = 1e3;
1094
+ /** "Reached it", in pixels — one 32 px tile, next to a ~34 px character. */
1095
+ const REACH_2D_PX = 32;
1096
+ /**
1097
+ * How hard this scene pulls, in its own units — 0 only for a scene that says so.
1098
+ *
1099
+ * Gravity is what makes "left the world downward" mean anything, and a
1100
+ * top-down game has none. But ABSENT is not ZERO: the physics adapters default
1101
+ * an undeclared scene to [0, 980] px/s² in 2D and [0, -9.81, 0] m/s² in 3D, so
1102
+ * a platformer that never wrote a physics header still falls, and reading
1103
+ * absent as zero would have stopped reporting the pit it fell into.
1104
+ */
1105
+ /** Is anything in this tree driving the player without a down? */
1106
+ function hasTopDownController(root) {
1107
+ const stack = [root];
1108
+ while (stack.length > 0) {
1109
+ const node = stack.pop();
1110
+ if (node.constructor.typeName === "CharacterController2D") {
1111
+ if (node.mode === "topDown") return true;
1112
+ }
1113
+ for (const child of node.children) stack.push(child);
1114
+ }
1115
+ return false;
1116
+ }
1117
+ function gravityMagnitude(physics) {
1118
+ const g = physics?.gravity;
1119
+ if (g === void 0) return 1;
1120
+ if (!Array.isArray(g)) return 1;
1121
+ return g.reduce((sum, v) => sum + (typeof v === "number" ? Math.abs(v) : 0), 0);
1122
+ }
1123
+ /** Furthest the player ever got from its spawn, below which it went nowhere. */
1124
+ const STUCK_3D_M = 3;
1125
+ const STUCK_2D_PX = 96;
1126
+ const CONTROLLERS = new Set(["CharacterController2D", "CharacterController3D"]);
1127
+ /**
1128
+ * Who the driver is playing as.
1129
+ *
1130
+ * In order: the body carrying a character controller, then group `player`, then
1131
+ * a node literally called Player. A scene that satisfies none of them is not
1132
+ * one this can drive, and says so rather than guessing.
1133
+ */
1134
+ function findPlayer(root) {
1135
+ let found = null;
1136
+ const walk = (node) => {
1137
+ if (found) return;
1138
+ for (const child of node.children) if (CONTROLLERS.has(child.constructor.typeName ?? "")) {
1139
+ found = node;
1140
+ return;
1141
+ }
1142
+ for (const child of node.children) walk(child);
1143
+ };
1144
+ walk(root);
1145
+ if (found) return found;
1146
+ let byGroup = null;
1147
+ const groups = (node) => {
1148
+ if (byGroup) return;
1149
+ if (node.isInGroup("player")) byGroup = node;
1150
+ for (const child of node.children) groups(child);
1151
+ };
1152
+ groups(root);
1153
+ if (byGroup) return byGroup;
1154
+ return root.getNodeOrNull("%Player");
1155
+ }
1156
+ function positionOf$1(node) {
1157
+ const p = node.position;
1158
+ if (!Array.isArray(p)) return [
1159
+ 0,
1160
+ 0,
1161
+ 0
1162
+ ];
1163
+ return [
1164
+ p[0] ?? 0,
1165
+ p[1] ?? 0,
1166
+ p[2] ?? 0
1167
+ ];
1168
+ }
1169
+ /** World position by walking the parent chain — no renderer, no matrices. */
1170
+ function worldOf(node) {
1171
+ let x = 0;
1172
+ let y = 0;
1173
+ let z = 0;
1174
+ for (let n = node; n; n = n.parent) {
1175
+ const [px, py, pz] = positionOf$1(n);
1176
+ x += px;
1177
+ y += py;
1178
+ z += pz;
1179
+ }
1180
+ return [
1181
+ x,
1182
+ y,
1183
+ z
1184
+ ];
1185
+ }
1186
+ /** Trigger volumes are destinations even without a script — that IS their job. */
1187
+ const TRIGGERS = new Set(["Area2D", "Area3D"]);
1188
+ /**
1189
+ * Is this node somewhere the player is meant to GO?
1190
+ *
1191
+ * Author intent, not geometry. Keying off the tree alone reported 150 unreached
1192
+ * nodes on the village scene and 138 of them were roof beams and fence posts —
1193
+ * a report nobody would read twice. A node is a destination when the author
1194
+ * marked it as one: it runs a behavior, it carries a group, or it is a trigger
1195
+ * volume. Decoration is silent.
1196
+ */
1197
+ function isTarget(node) {
1198
+ const type = node.constructor.typeName ?? "";
1199
+ if (type.startsWith("Ui")) return false;
1200
+ if (TRIGGERS.has(type)) return true;
1201
+ if (node.groups.size > 0) return true;
1202
+ return node.script?.name !== void 0;
1203
+ }
1204
+ /**
1205
+ * What the SCENE says winning and losing are.
1206
+ *
1207
+ * Nothing here is a heuristic about game design — `GameFlow` already models
1208
+ * `'won'`/`'gameover'`, `ScoreKeeper` already emits `won`/`lost`, and `Health`
1209
+ * already emits `died`. The scene declared all of it; this only listens.
1210
+ */
1211
+ function makeOracle(engine, player) {
1212
+ let outcome = null;
1213
+ let damage = 0;
1214
+ const offs = [];
1215
+ let declaresEnd = false;
1216
+ /** First verdict wins — a run ends at its first conclusion, not its last. */
1217
+ const settle = (verdict) => {
1218
+ if (outcome === null) outcome = verdict;
1219
+ };
1220
+ const isPlayerSide = (node) => {
1221
+ for (let at = node; at; at = at.parent) if (at === player) return true;
1222
+ return false;
1223
+ };
1224
+ const walk = (node) => {
1225
+ if (node.behavior) for (const signal of node.declaredSignalNames()) {
1226
+ if (signal === "won" || signal === "lost" || signal === "flowChanged") declaresEnd = true;
1227
+ if (signal === "won") offs.push(node.on(signal, () => settle("won")));
1228
+ if (signal === "lost") offs.push(node.on(signal, () => settle("lost")));
1229
+ if (signal === "died" && isPlayerSide(node)) offs.push(node.on(signal, () => settle("lost")));
1230
+ if (signal === "damaged" && isPlayerSide(node)) offs.push(node.on(signal, () => {
1231
+ damage += 1;
1232
+ }));
1233
+ if (signal === "flowChanged") offs.push(node.on(signal, (state) => {
1234
+ if (state === "won") settle("won");
1235
+ if (state === "gameover") settle("lost");
1236
+ }));
1237
+ }
1238
+ for (const child of node.children) walk(child);
1239
+ };
1240
+ const root = engine.scene?.root;
1241
+ if (root) walk(root);
1242
+ return {
1243
+ outcome: () => outcome,
1244
+ declaresEnd: () => declaresEnd,
1245
+ damage: () => damage,
1246
+ dispose: () => {
1247
+ for (const off of offs) off();
1248
+ }
1249
+ };
1250
+ }
1251
+ /**
1252
+ * A monkey with a memory.
1253
+ *
1254
+ * Not an attempt to play well — an attempt to play DIFFERENTLY, which is what
1255
+ * makes automated testers find what humans do not. It holds a direction for a
1256
+ * while (a per-frame coin flip goes nowhere), presses buttons at intervals, and
1257
+ * biases its next heading toward the part of the world it has visited least.
1258
+ * That last part is the whole difference between "wanders near spawn forever"
1259
+ * and "eventually finds the door".
1260
+ */
1261
+ var Driver = class {
1262
+ engine;
1263
+ rand;
1264
+ heading = 0;
1265
+ headingLeft = 0;
1266
+ visits = /* @__PURE__ */ new Map();
1267
+ buttons;
1268
+ /**
1269
+ * Buttons the bot pressed and will never press again, because pressing them
1270
+ * STOPPED THE CLOCK.
1271
+ *
1272
+ * It drove every declared button action, and `incanto-your-first-game.md`
1273
+ * tells you to declare `pause`. Measured on one losable scene: 0/10 losses
1274
+ * with `pause` declared, 10/10 without it, same seed, same everything else.
1275
+ * A frozen game cannot hurt you, so the run also reported the game as having
1276
+ * no way to lose.
1277
+ *
1278
+ * This is OBSERVED, not guessed from the name — a game is free to call it
1279
+ * `menu`, `escape`, `halt` or nothing at all, and the bot notices the same
1280
+ * way a player would: the world stopped moving.
1281
+ */
1282
+ frozeTheGame = /* @__PURE__ */ new Set();
1283
+ /**
1284
+ * Buttons an EARLIER run proved reload the scene, so this one never tries.
1285
+ *
1286
+ * Unlike a pause, a restart cannot be recovered from mid-run — the tree the
1287
+ * driver holds is gone — so the blame has to outlive the run that paid for
1288
+ * it. One run of the ten is spent learning; the other nine play the game.
1289
+ */
1290
+ avoid = /* @__PURE__ */ new Set();
1291
+ /** What was pressed most recently, so a freeze can be attributed to it. */
1292
+ lastPressed = null;
1293
+ moveAction;
1294
+ /** The button the scene's controller calls its jump — `jump` unless it says otherwise. */
1295
+ jumpButton = "jump";
1296
+ /**
1297
+ * The action vocabulary this driver was bound to, so it can notice a swap.
1298
+ *
1299
+ * A game that changes scene changes its `input{}` with it, and the bot had
1300
+ * captured the FIRST scene's list once, in the constructor. So it went on
1301
+ * pressing buttons the new scene never declared — and `releaseAction` on an
1302
+ * action the current scene does not declare is a hard `BAD_FORMAT`, which is
1303
+ * correct for game code and fatal here: it threw out of `runOnce`, and an
1304
+ * eight-run playtest of a two-scene game died with a raw stack trace instead
1305
+ * of eight results.
1306
+ *
1307
+ * Measured on `examples/village-quest-3d`, whose grove declares `pause` and
1308
+ * whose village does not:
1309
+ *
1310
+ * ```
1311
+ * ✗ plays — the playtest could not run — playtest failed: Unknown input
1312
+ * action 'pause'. Declared actions: [move, jump, sprint, interact, attack,
1313
+ * restart].
1314
+ * ```
1315
+ *
1316
+ * The report has always called `swapped` a normal outcome. The driver had not
1317
+ * been told.
1318
+ */
1319
+ vocabulary = "";
1320
+ held = /* @__PURE__ */ new Map();
1321
+ /**
1322
+ * Everything in the scene a click can reach.
1323
+ *
1324
+ * The bot drove declared ACTIONS and nothing else, so on a mouse game it could
1325
+ * only ever watch. Measured across one round: a turn-based tactics game, a
1326
+ * tower defense and a duel all reported the identical
1327
+ * `? plays — 8 runs played without reaching a win`, and nobody filed it
1328
+ * because the message is gracious about it. A whole class — tactics, RTS,
1329
+ * tower defense, point-and-click, board games — was structurally unmeasurable
1330
+ * by the rung that exists to answer "does this game work".
1331
+ *
1332
+ * Headless there is no raycast, so a picker is installed that answers from the
1333
+ * TREE — the exact recipe `incanto-physics-and-input.md` gives for testing a
1334
+ * mouse game — and the click goes through the real `Clickable`, the real
1335
+ * `mouseJustPressed` edge, and the real connection. Nothing is faked but the
1336
+ * cursor's arrival.
1337
+ */
1338
+ clickables;
1339
+ /**
1340
+ * Does anything in this game ASK about the mouse?
1341
+ *
1342
+ * `Clickable` is a closed list of one beside the open set it stands for —
1343
+ * every behaviour that reads the cursor. A drag-and-launch, a drawing game, a
1344
+ * box-select and a swipe carry no `Clickable` at all, and the bot reported
1345
+ * them as games nobody could drive.
1346
+ *
1347
+ * Read from the behaviour's own source, which is the only place the question
1348
+ * is answerable before a frame has run. Property names survive minification,
1349
+ * so this holds in a built game too.
1350
+ */
1351
+ readsMouse;
1352
+ view;
1353
+ drags = 0;
1354
+ dragCooldown = 0;
1355
+ /** Frames left in the gesture in flight; 0 = none. */
1356
+ dragFrames = 0;
1357
+ dragFrom = [0, 0];
1358
+ dragTo = [0, 0];
1359
+ clickCooldown = 0;
1360
+ /**
1361
+ * HUD widgets pressed this run — the whole interface of a menu game.
1362
+ *
1363
+ * The bot looked for a character controller, a `player` group, a node named
1364
+ * Player, a `Clickable`, and a behaviour that reads the cursor. A `UiButton`
1365
+ * was on none of those lists, so an idle game built from the tarball — whose
1366
+ * every verb is a button — reported
1367
+ *
1368
+ * · nothing here is drivable … there was nobody to move and nothing to
1369
+ * point at
1370
+ * ✗ stuck in 2/2
1371
+ *
1372
+ * about a game that plays perfectly. The same closed-dictionary shape the
1373
+ * `click` step had one layer up, and the same answer: press them.
1374
+ */
1375
+ presses = 0;
1376
+ /** Declared button actions pressed — see `PlaytestRun.actionPresses`. */
1377
+ actionPresses = 0;
1378
+ /** HUD drags that landed — see `PlaytestRun.widgetDrags`. */
1379
+ widgetDrags = 0;
1380
+ dragWidgetCooldown = 0;
1381
+ pressCooldown = 0;
1382
+ clicking = null;
1383
+ /** press -> release -> let go. `clicked` fires on RELEASE, so the picker must
1384
+ * still be answering with the same node on the frame the release is read. */
1385
+ clickPhase = "idle";
1386
+ constructor(engine, rand) {
1387
+ this.engine = engine;
1388
+ this.rand = rand;
1389
+ this.moveAction = null;
1390
+ this.buttons = [];
1391
+ this.rebind();
1392
+ this.clickables = [];
1393
+ const root = engine.scene?.root;
1394
+ let asks = false;
1395
+ if (root) {
1396
+ const walk = (node) => {
1397
+ if (node.behavior?.constructor.name === "Clickable") this.clickables.push(node);
1398
+ if (node.behavior && ASKS_ABOUT_THE_MOUSE.test(node.behavior.constructor.toString())) asks = true;
1399
+ for (const child of node.children) walk(child);
1400
+ };
1401
+ walk(root);
1402
+ if (this.clickables.length > 0 && !engine.picker) engine.picker = () => this.clicking;
1403
+ if (asks && !engine.toWorld) {
1404
+ const view = geometricView(() => engine.scene);
1405
+ engine.toWorld = view.toWorld;
1406
+ engine.toScreen ??= view.toScreen;
1407
+ }
1408
+ }
1409
+ if (root) {
1410
+ const stack = [root];
1411
+ while (stack.length > 0) {
1412
+ const node = stack.pop();
1413
+ if (CONTROLLERS.has(node.constructor.typeName ?? "")) {
1414
+ const want = node.jumpAction;
1415
+ if (typeof want === "string" && want !== "") this.jumpButton = want;
1416
+ break;
1417
+ }
1418
+ for (const child of node.children) stack.push(child);
1419
+ }
1420
+ }
1421
+ this.readsMouse = asks;
1422
+ const design = engine.scene?.viewport?.design;
1423
+ this.view = [design?.[0] ?? 960, design?.[1] ?? 540];
1424
+ }
1425
+ /** How many nodes this bot can click. Reported, so a ✓ says what it drove. */
1426
+ get clickableCount() {
1427
+ return this.clickables.length;
1428
+ }
1429
+ /**
1430
+ * Clicks this run LANDED — the half the report was missing.
1431
+ *
1432
+ * The bot has driven the mouse for a while; nothing counted it. So a flip
1433
+ * puzzle whose every tile the bot clicked, whose `clicked` wires all fired
1434
+ * and whose board changed state, was reported as
1435
+ * `nothing here is drivable … the runs went nowhere because there was nobody
1436
+ * to move`, every run `stuck`, and the ladder's advice was to add a character
1437
+ * controller to a game that has no character. The evidence was in the same
1438
+ * report's own `fired` list.
1439
+ */
1440
+ get widgetsPressed() {
1441
+ return this.presses;
1442
+ }
1443
+ /** Declared button actions pressed this run — the keyboard's share. */
1444
+ get actionsPressed() {
1445
+ return this.actionPresses;
1446
+ }
1447
+ /** HUD drags this run that landed on a drop target. */
1448
+ get slotsDragged() {
1449
+ return this.widgetDrags;
1450
+ }
1451
+ get clicksLanded() {
1452
+ return this.clicks;
1453
+ }
1454
+ /** Drags this run COMPLETED — press, move, release. */
1455
+ get dragsMade() {
1456
+ return this.drags;
1457
+ }
1458
+ clicks = 0;
1459
+ /** Grid cell key — coarse on purpose, this is novelty not navigation. */
1460
+ cell(p) {
1461
+ return `${Math.round(p[0] / 4)},${Math.round(p[2] / 4)}`;
1462
+ }
1463
+ /** Seconds the clock has been stopped for, so a hit-stop is not a pause. */
1464
+ frozenFor = 0;
1465
+ /** What was pressed when the clock stopped — read at the deadline, not then. */
1466
+ frozeSuspect = null;
1467
+ /** Actions this run avoided after they stopped the clock. */
1468
+ paused() {
1469
+ return [...this.frozeTheGame];
1470
+ }
1471
+ /** Never press these — an earlier run watched them reload the scene. */
1472
+ avoidPressing(buttons) {
1473
+ this.avoid = buttons;
1474
+ }
1475
+ /** Whatever was pressed most recently, so a reload can be attributed to it. */
1476
+ blame() {
1477
+ return this.lastPressed;
1478
+ }
1479
+ step(dt, at) {
1480
+ this.rebind();
1481
+ if (this.engine.timeScale === 0) {
1482
+ if (this.frozenFor === 0) this.frozeSuspect = this.lastPressed;
1483
+ this.frozenFor += dt;
1484
+ if (this.frozenFor >= .5) {
1485
+ if (this.frozeSuspect) this.frozeTheGame.add(this.frozeSuspect);
1486
+ for (const button of this.buttons) {
1487
+ this.engine.input.releaseAction(button);
1488
+ this.held.set(button, -1);
1489
+ }
1490
+ this.engine.timeScale = 1;
1491
+ this.lastPressed = null;
1492
+ this.frozeSuspect = null;
1493
+ this.frozenFor = 0;
1494
+ }
1495
+ } else this.frozenFor = 0;
1496
+ this.visits.set(this.cell(at), (this.visits.get(this.cell(at)) ?? 0) + 1);
1497
+ this.headingLeft -= dt;
1498
+ if (this.headingLeft <= 0) {
1499
+ this.heading = this.pickHeading(at);
1500
+ this.headingLeft = .4 + this.rand() * 1.2;
1501
+ }
1502
+ if (this.moveAction) this.engine.input.setActionVector(this.moveAction, Math.cos(this.heading), Math.sin(this.heading));
1503
+ for (const button of this.buttons) {
1504
+ if (this.frozeTheGame.has(button) || this.avoid.has(button)) continue;
1505
+ const left = (this.held.get(button) ?? 0) - dt;
1506
+ if (left > 0) {
1507
+ this.held.set(button, left);
1508
+ continue;
1509
+ }
1510
+ if (left > -.05 && left <= 0) {
1511
+ this.engine.input.releaseAction(button);
1512
+ this.held.set(button, -1);
1513
+ continue;
1514
+ }
1515
+ const chance = button === this.jumpButton ? .06 : .02;
1516
+ if (this.rand() < chance) {
1517
+ this.engine.input.pressAction(button);
1518
+ this.actionPresses += 1;
1519
+ this.lastPressed = button;
1520
+ this.held.set(button, .08 + this.rand() * .12);
1521
+ }
1522
+ }
1523
+ this.maybeClick(dt);
1524
+ this.maybeDrag(dt);
1525
+ this.maybePressWidget(dt);
1526
+ this.maybeDragWidget(dt);
1527
+ }
1528
+ /**
1529
+ * Every focusable widget a player can currently see.
1530
+ *
1531
+ * Recomputed each time rather than captured: a menu game's buttons come and
1532
+ * go — a shop opens, a pause panel closes — and `HudLayer.focusables()`
1533
+ * already answers "on screen only", walking no further into a hidden panel
1534
+ * than a player's eye does.
1535
+ */
1536
+ shownWidgets() {
1537
+ const root = this.engine.scene?.root;
1538
+ if (!root) return [];
1539
+ const out = [];
1540
+ const walk = (node) => {
1541
+ if (node instanceof HudLayer) {
1542
+ out.push(...node.focusables());
1543
+ return;
1544
+ }
1545
+ for (const child of node.children) walk(child);
1546
+ };
1547
+ walk(root);
1548
+ return out;
1549
+ }
1550
+ /** Press one of them, occasionally — the menu game's equivalent of a step. */
1551
+ maybePressWidget(dt) {
1552
+ this.pressCooldown -= dt;
1553
+ if (this.pressCooldown > 0) return;
1554
+ this.pressCooldown = .25;
1555
+ const widgets = this.shownWidgets();
1556
+ if (widgets.length === 0) return;
1557
+ const target = widgets[Math.floor(this.rand() * widgets.length)];
1558
+ if (!target) return;
1559
+ target._activate(this.rand() < .5 ? 0 : this.rand() < .5 ? -1 : 1);
1560
+ this.presses += 1;
1561
+ }
1562
+ /** Every widget in the tree that can be picked up, or dropped onto. */
1563
+ dragParts() {
1564
+ const root = this.engine.scene?.root;
1565
+ const items = [];
1566
+ const targets = [];
1567
+ if (!root) return {
1568
+ items,
1569
+ targets
1570
+ };
1571
+ const walk = (node) => {
1572
+ if (node instanceof HudWidgetBase) {
1573
+ if (!node.visible) return;
1574
+ if (node.draggable) items.push(node);
1575
+ if (node.dropTarget) targets.push(node);
1576
+ }
1577
+ for (const child of node.children) walk(child);
1578
+ };
1579
+ walk(root);
1580
+ return {
1581
+ items,
1582
+ targets
1583
+ };
1584
+ }
1585
+ /**
1586
+ * Pick something up and put it somewhere, occasionally.
1587
+ *
1588
+ * `dropOnto` is the same entry point a harness uses and the same rules as the
1589
+ * pointer path — a drop back into the slot it came from is not a move, so the
1590
+ * bot picks a target that is not the item's own parent and lets the widget
1591
+ * refuse anything else.
1592
+ */
1593
+ maybeDragWidget(dt) {
1594
+ this.dragWidgetCooldown -= dt;
1595
+ if (this.dragWidgetCooldown > 0) return;
1596
+ this.dragWidgetCooldown = .3;
1597
+ const { items, targets } = this.dragParts();
1598
+ if (items.length === 0 || targets.length === 0) return;
1599
+ const item = items[Math.floor(this.rand() * items.length)];
1600
+ const target = targets[Math.floor(this.rand() * targets.length)];
1601
+ if (!item || !target || item === target || item.parent === target) return;
1602
+ if (item.dropOnto(target)) this.widgetDrags += 1;
1603
+ }
1604
+ /**
1605
+ * Press and release over one Clickable, occasionally.
1606
+ *
1607
+ * `clicked` fires on RELEASE over the same node the press started on, so this
1608
+ * has to hold the target across two frames — which is also what makes it a
1609
+ * real click rather than a synthetic signal.
1610
+ */
1611
+ /**
1612
+ * Drag: press somewhere, move, let go somewhere else.
1613
+ *
1614
+ * The gesture `maybeClick` cannot make and a whole genre is made of — a
1615
+ * slingshot pulled back, a box drawn across a map, a swipe. The points are in
1616
+ * the scene's own design rect, which in 2D IS world space (the coordinates
1617
+ * every position in the file is written in) and in 3D is the canvas.
1618
+ *
1619
+ * Only when the game asks about the mouse: a game that never reads the cursor
1620
+ * must not be handed gestures it cannot see, which would make the report a
1621
+ * claim about a game nobody shipped.
1622
+ */
1623
+ maybeDrag(dt) {
1624
+ if (!this.readsMouse || this.clickPhase !== "idle") return;
1625
+ if (this.dragFrames > 0) {
1626
+ this.dragFrames -= 1;
1627
+ const done = this.dragFrames === 0;
1628
+ const t = done ? 1 : 1 - this.dragFrames / DRAG_FRAMES;
1629
+ this.engine.input.setPointerPosition((this.dragFrom[0] ?? 0) + ((this.dragTo[0] ?? 0) - (this.dragFrom[0] ?? 0)) * t, (this.dragFrom[1] ?? 0) + ((this.dragTo[1] ?? 0) - (this.dragFrom[1] ?? 0)) * t);
1630
+ if (done) {
1631
+ this.engine.input.handleMouseButton(0, false);
1632
+ this.drags += 1;
1633
+ }
1634
+ return;
1635
+ }
1636
+ this.dragCooldown -= dt;
1637
+ if (this.dragCooldown > 0) return;
1638
+ this.dragCooldown = .4 + this.rand() * .8;
1639
+ const scene = this.engine.scene;
1640
+ const [w, h] = this.view;
1641
+ const view = scene ? viewRect2D(scene.dimension === "2d" ? camera2D(place(scene)) : null, w, h) : {
1642
+ minX: 0,
1643
+ minY: 0,
1644
+ maxX: w,
1645
+ maxY: h
1646
+ };
1647
+ const at = () => [view.minX + this.rand() * (view.maxX - view.minX), view.minY + this.rand() * (view.maxY - view.minY)];
1648
+ this.dragFrom = at();
1649
+ this.dragTo = at();
1650
+ this.dragFrames = DRAG_FRAMES;
1651
+ this.engine.input.setPointerPosition(this.dragFrom[0], this.dragFrom[1]);
1652
+ this.engine.input.handleMouseButton(0, true);
1653
+ }
1654
+ maybeClick(dt) {
1655
+ if (this.clickables.length === 0) return;
1656
+ this.engine.input.setPointerPosition(1, 1);
1657
+ if (this.clickPhase === "pressed") {
1658
+ this.engine.input.handleMouseButton(0, false);
1659
+ this.clickPhase = "released";
1660
+ return;
1661
+ }
1662
+ if (this.clickPhase === "released") {
1663
+ this.clicking = null;
1664
+ this.clickPhase = "idle";
1665
+ return;
1666
+ }
1667
+ this.clickCooldown -= dt;
1668
+ if (this.clickCooldown > 0) return;
1669
+ this.clickCooldown = .25 + this.rand() * .6;
1670
+ const target = this.clickables[Math.floor(this.rand() * this.clickables.length)];
1671
+ if (!target || target.tree === null) return;
1672
+ this.clicking = target;
1673
+ this.clickPhase = "pressed";
1674
+ this.clicks += 1;
1675
+ this.engine.input.handleMouseButton(0, true);
1676
+ }
1677
+ /** Prefer the least-visited neighbouring cell; ties break randomly. */
1678
+ pickHeading(at) {
1679
+ let best = Number.POSITIVE_INFINITY;
1680
+ let bestAngle = this.rand() * Math.PI * 2;
1681
+ for (let i = 0; i < 8; i++) {
1682
+ const angle = i / 8 * Math.PI * 2 + this.rand() * .3;
1683
+ const probe = [
1684
+ at[0] + Math.cos(angle) * 4,
1685
+ at[1],
1686
+ at[2] + Math.sin(angle) * 4
1687
+ ];
1688
+ const seen = (this.visits.get(this.cell(probe)) ?? 0) + this.rand();
1689
+ if (seen < best) {
1690
+ best = seen;
1691
+ bestAngle = angle;
1692
+ }
1693
+ }
1694
+ return bestAngle;
1695
+ }
1696
+ /**
1697
+ * Re-read the action list, and forget what the last scene declared.
1698
+ *
1699
+ * Called every step, not just on a swap: `declaredActions()` is a handful of
1700
+ * strings, and the alternative is a flag somebody has to remember to set.
1701
+ * Buttons that survive the swap keep their hold timer; ones that do not are
1702
+ * dropped WITHOUT a release, because there is nothing left to release them
1703
+ * on.
1704
+ */
1705
+ rebind() {
1706
+ const declared = this.engine.input.declaredActions();
1707
+ const signature = declared.map((a) => `${a.type}:${a.name}`).join(",");
1708
+ if (signature === this.vocabulary) return;
1709
+ this.vocabulary = signature;
1710
+ this.moveAction = declared.find((a) => a.type === "vector2")?.name ?? null;
1711
+ this.buttons = declared.filter((a) => a.type === "button").map((a) => a.name);
1712
+ const still = new Set(this.buttons);
1713
+ for (const button of [...this.held.keys()]) if (!still.has(button)) this.held.delete(button);
1714
+ if (this.lastPressed && !still.has(this.lastPressed)) this.lastPressed = null;
1715
+ if (this.frozeSuspect && !still.has(this.frozeSuspect)) this.frozeSuspect = null;
1716
+ }
1717
+ release() {
1718
+ this.rebind();
1719
+ for (const button of this.buttons) this.engine.input.releaseAction(button);
1720
+ if (this.moveAction) this.engine.input.setActionVector(this.moveAction, 0, 0);
1721
+ }
1722
+ };
1723
+ async function runOnce(json, seed, opts, avoid = /* @__PURE__ */ new Set()) {
1724
+ const session = await createPlaySession(json, {
1725
+ seed,
1726
+ ...opts.behaviors ? { behaviors: opts.behaviors } : {},
1727
+ stubMissingBehaviors: opts.stubMissingBehaviors ?? true,
1728
+ ...opts.resolveScene ? { resolveScene: opts.resolveScene } : {}
1729
+ });
1730
+ const { engine, scene } = session;
1731
+ const player = findPlayer(scene.root);
1732
+ const oracle = makeOracle(engine, player);
1733
+ const targets = [];
1734
+ const signals = [];
1735
+ const fired = /* @__PURE__ */ new Set();
1736
+ const offs = [];
1737
+ const wired = /* @__PURE__ */ new Set();
1738
+ for (const conn of scene.source.connections ?? []) {
1739
+ if (typeof conn.from !== "string" || typeof conn.signal !== "string") continue;
1740
+ const node = scene.root.getNodeOrNull(conn.from);
1741
+ if (node) wired.add(`${node.getPath()}.${conn.signal}`);
1742
+ }
1743
+ /**
1744
+ * Wired signals inside a screen-space subtree — collected, not walked.
1745
+ *
1746
+ * The early return below drops the whole HUD, which is right for TARGETS (a
1747
+ * HUD is not somewhere the player can walk) and took the signals with it. So
1748
+ * every wire an author draws on a widget — `pressed` on a button, `changed`
1749
+ * on a slider, `dropped` on an inventory slot — was invisible to the line
1750
+ * that exists to say "this wire does nothing", for the entire HUD.
1751
+ */
1752
+ const collectSignals = (node) => {
1753
+ for (const signal of node.declaredSignalNames()) {
1754
+ const key = `${node.getPath()}.${signal}`;
1755
+ if (wired.has(key)) signals.push(key);
1756
+ offs.push(node.on(signal, () => void fired.add(key)));
1757
+ }
1758
+ for (const child of node.children) collectSignals(child);
1759
+ };
1760
+ const walkAll = (node) => {
1761
+ if (SCREEN_SPACE.has(node.constructor.typeName ?? "")) {
1762
+ collectSignals(node);
1763
+ return;
1764
+ }
1765
+ const isDestination = node !== player && isTarget(node);
1766
+ if (isDestination) targets.push(node.getPath());
1767
+ for (const signal of node.declaredSignalNames()) {
1768
+ const key = `${node.getPath()}.${signal}`;
1769
+ if (wired.has(key) || isDestination) signals.push(key);
1770
+ offs.push(node.on(signal, () => void fired.add(key)));
1771
+ }
1772
+ for (const child of node.children) walkAll(child);
1773
+ };
1774
+ walkAll(scene.root);
1775
+ const hazards = countHazardsIn(json);
1776
+ const targetNodes = [];
1777
+ const collect = (node) => {
1778
+ if (SCREEN_SPACE.has(node.constructor.typeName ?? "")) return;
1779
+ if (node !== player && isTarget(node)) targetNodes.push([node.getPath(), node]);
1780
+ for (const child of node.children) collect(child);
1781
+ };
1782
+ collect(scene.root);
1783
+ const start = player ? worldOf(player) : [
1784
+ 0,
1785
+ 0,
1786
+ 0
1787
+ ];
1788
+ const down = scene.dimension === "2d" ? 1 : -1;
1789
+ const pull = gravityMagnitude(scene.physics);
1790
+ const topDown = hasTopDownController(scene.root);
1791
+ const floor = opts.fallBelow ?? (pull > 0 && !topDown ? start[1] + down * (down > 0 ? FALL_2D_PX : 50) : null);
1792
+ const radius = opts.reachRadius ?? (down > 0 ? REACH_2D_PX : 2);
1793
+ const recorder = startRecording(engine);
1794
+ const botRng = new Rng((seed ^ 2654435769) >>> 0);
1795
+ const rand = () => botRng.next();
1796
+ const driver = new Driver(engine, rand);
1797
+ driver.avoidPressing(avoid);
1798
+ const reached = /* @__PURE__ */ new Set();
1799
+ const stepMs = 1e3 / 60;
1800
+ const deadline = (opts.seconds ?? 60) * 1e3;
1801
+ let outcome = null;
1802
+ let swappedAtMs = null;
1803
+ let restartedBy = null;
1804
+ const heardSoFar = /* @__PURE__ */ new Set();
1805
+ const shownSoFar = /* @__PURE__ */ new Set();
1806
+ const playing = scene.source.name;
1807
+ const offSwap = engine.sceneChanged.connect((next) => {
1808
+ swappedAtMs ??= session.timeMs;
1809
+ if (next && next.source.name === playing) restartedBy ??= driver.blame();
1810
+ });
1811
+ let at = start;
1812
+ let wentAsFarAs = 0;
1813
+ const travelled = (a, b) => scene.dimension === "2d" ? Math.hypot((a[0] ?? 0) - (b[0] ?? 0), (a[1] ?? 0) - (b[1] ?? 0)) : Math.hypot((a[0] ?? 0) - (b[0] ?? 0), (a[2] ?? 0) - (b[2] ?? 0));
1814
+ const cloneCensus = () => {
1815
+ const out = /* @__PURE__ */ new Map();
1816
+ const walk = (node) => {
1817
+ if (isCloned(node)) {
1818
+ const at2 = node.wiringPath();
1819
+ out.set(at2, (out.get(at2) ?? 0) + 1);
1820
+ return;
1821
+ }
1822
+ for (const child of node.children) walk(child);
1823
+ };
1824
+ const root = engine.scene?.root;
1825
+ if (root) walk(root);
1826
+ return out;
1827
+ };
1828
+ let halfway = null;
1829
+ while (session.timeMs < deadline) {
1830
+ if (halfway === null && session.timeMs >= deadline / 2) halfway = cloneCensus();
1831
+ at = player ? worldOf(player) : start;
1832
+ wentAsFarAs = Math.max(wentAsFarAs, travelled(at, start));
1833
+ driver.step(stepMs / 1e3, at);
1834
+ for (const path of engine.audio.sources()) heardSoFar.add(path);
1835
+ for (const path of engine.effects.sources()) shownSoFar.add(path);
1836
+ session.step(stepMs);
1837
+ if (player) for (const [path, node] of targetNodes) {
1838
+ if (reached.has(path)) continue;
1839
+ const p = worldOf(node);
1840
+ if (Math.hypot(at[0] - p[0], at[1] - p[1], at[2] - p[2]) <= radius) reached.add(path);
1841
+ }
1842
+ const verdict = oracle.outcome();
1843
+ if (verdict) {
1844
+ outcome = verdict;
1845
+ break;
1846
+ }
1847
+ if (engine.stats().errors > 0) {
1848
+ outcome = "error";
1849
+ break;
1850
+ }
1851
+ if (player && floor !== null && (at[1] - floor) * down > 0) {
1852
+ outcome = "fell";
1853
+ break;
1854
+ }
1855
+ if (swappedAtMs !== null) {
1856
+ outcome = "swapped";
1857
+ break;
1858
+ }
1859
+ }
1860
+ driver.release();
1861
+ offSwap();
1862
+ const aliveClones = [...cloneCensus()].map(([path, count]) => ({
1863
+ path,
1864
+ count,
1865
+ half: halfway?.get(path) ?? 0
1866
+ })).sort((a, b) => b.count - a.count);
1867
+ const WENT_NOWHERE = scene.dimension === "2d" ? STUCK_2D_PX : STUCK_3D_M;
1868
+ outcome ??= wentAsFarAs > WENT_NOWHERE || driver.clicksLanded > 0 || driver.dragsMade > 0 || driver.widgetsPressed > 0 || driver.slotsDragged > 0 ? "unfinished" : "stuck";
1869
+ for (const path of engine.audio.sources()) heardSoFar.add(path);
1870
+ for (const path of engine.effects.sources()) shownSoFar.add(path);
1871
+ const heard = [...heardSoFar];
1872
+ const shown = [...shownSoFar];
1873
+ const bad = outcome !== "won" && outcome !== "unfinished";
1874
+ const run = {
1875
+ seed,
1876
+ outcome,
1877
+ timeMs: session.timeMs,
1878
+ reached: [...reached],
1879
+ fired: [...fired],
1880
+ damageTaken: oracle.damage(),
1881
+ errors: engine.stats().errors,
1882
+ errorMessages: logLines(engine, "error"),
1883
+ aliveClones,
1884
+ warnings: logLines(engine, "warn"),
1885
+ replay: bad ? recorder.stop() : null,
1886
+ endedAt: at,
1887
+ heard: [...new Set(heard)],
1888
+ shown: [...new Set(shown)],
1889
+ clicks: driver.clicksLanded,
1890
+ drags: driver.dragsMade,
1891
+ presses: driver.widgetsPressed,
1892
+ actionPresses: driver.actionsPressed,
1893
+ widgetDrags: driver.slotsDragged
1894
+ };
1895
+ if (!bad) recorder.stop();
1896
+ for (const off of offs) off();
1897
+ oracle.dispose();
1898
+ const unreadNow = session.engine.input.unreadActions();
1899
+ session.dispose();
1900
+ return {
1901
+ run,
1902
+ targets,
1903
+ signals,
1904
+ declaresEnd: oracle.declaresEnd(),
1905
+ hazards,
1906
+ hasPlayer: player !== null,
1907
+ froze: driver.paused(),
1908
+ restartedBy,
1909
+ unread: unreadNow
1910
+ };
1911
+ }
1912
+ /**
1913
+ * How many nodes the scene FILE wires to hurt the player.
1914
+ *
1915
+ * Read from the JSON, not the tree: a `Spawner` detaches its prefab at ready, so
1916
+ * a wave game's hazards exist only in the file until the first wave lands.
1917
+ */
1918
+ function countHazardsIn(json) {
1919
+ let n = 0;
1920
+ const walk = (node) => {
1921
+ if (typeof node !== "object" || node === null) return;
1922
+ const nd = node;
1923
+ if (nd.script?.name === "DamageOnContact") n += 1;
1924
+ if (Array.isArray(nd.children)) for (const child of nd.children) walk(child);
1925
+ };
1926
+ walk(json?.root);
1927
+ return n;
1928
+ }
1929
+ /** Play the scene `runs` times and report what happened. */
1930
+ async function playtest(json, opts = {}) {
1931
+ const count = Number.isFinite(opts.runs) ? Math.max(1, opts.runs) : 20;
1932
+ const base = opts.seed ?? 1;
1933
+ const runs = [];
1934
+ let targets = [];
1935
+ let signals = [];
1936
+ let declaresWin = false;
1937
+ let hasPlayer = false;
1938
+ let hazards = 0;
1939
+ const froze = /* @__PURE__ */ new Set();
1940
+ const restarted = /* @__PURE__ */ new Set();
1941
+ /**
1942
+ * An action is inert only if NO run read it — one run that took a branch the
1943
+ * others missed is enough to clear it.
1944
+ */
1945
+ let unread = null;
1946
+ for (let i = 0; i < count; i++) {
1947
+ resetMemorySaves();
1948
+ const one = await runOnce(json, base + i, opts, restarted);
1949
+ runs.push(one.run);
1950
+ targets = one.targets;
1951
+ signals = one.signals;
1952
+ declaresWin = declaresWin || one.declaresEnd;
1953
+ hasPlayer = hasPlayer || one.hasPlayer;
1954
+ hazards = one.hazards;
1955
+ for (const action of one.froze) froze.add(action);
1956
+ if (one.restartedBy) restarted.add(one.restartedBy);
1957
+ unread = unread === null ? one.unread : unread.filter((a) => one.unread.includes(a));
1958
+ }
1959
+ const probe = await createPlaySession(json, {
1960
+ seed: base,
1961
+ stubMissingBehaviors: opts.stubMissingBehaviors ?? true,
1962
+ ...opts.resolveScene ? { resolveScene: opts.resolveScene } : {}
1963
+ });
1964
+ const actions = probe.engine.input.declaredActions().map((a) => a.name);
1965
+ const feedbackNodes = {
1966
+ audio: [],
1967
+ effects: []
1968
+ };
1969
+ const walkFeedback = (node) => {
1970
+ const type = node.constructor.typeName ?? "";
1971
+ const path = node.getPath();
1972
+ if (type === "AudioPlayer") feedbackNodes.audio.push(path);
1973
+ if (EFFECT_NODE_TYPES.has(type)) feedbackNodes.effects.push(path);
1974
+ for (const child of node.children) walkFeedback(child);
1975
+ };
1976
+ const walkFeedbackJson = (value, path = "") => {
1977
+ if (!value || typeof value !== "object") return;
1978
+ const node = value;
1979
+ if (node.root !== void 0) {
1980
+ walkFeedbackJson(node.root, "");
1981
+ return;
1982
+ }
1983
+ if (typeof node.name !== "string") return;
1984
+ const here = `${path}/${node.name}`;
1985
+ if (node.type === "AudioPlayer" && !feedbackNodes.audio.includes(here)) feedbackNodes.audio.push(here);
1986
+ if (typeof node.type === "string" && EFFECT_NODE_TYPES.has(node.type) && !feedbackNodes.effects.includes(here)) feedbackNodes.effects.push(here);
1987
+ if (Array.isArray(node.children)) for (const child of node.children) walkFeedbackJson(child, here);
1988
+ };
1989
+ const probeRoot = probe.scene.tree.root;
1990
+ if (probeRoot) walkFeedback(probeRoot);
1991
+ probe.dispose();
1992
+ walkFeedbackJson(json);
1993
+ return {
1994
+ runs,
1995
+ targets,
1996
+ signals: [...new Set(signals)],
1997
+ actions,
1998
+ inertActions: unread ?? [],
1999
+ errorMessages: [...new Set(runs.flatMap((r) => r.errorMessages))],
2000
+ warnings: [...new Set(runs.flatMap((r) => r.warnings))],
2001
+ declaresWin,
2002
+ hasPlayer,
2003
+ hazards,
2004
+ froze: [...froze],
2005
+ restarted: [...restarted],
2006
+ seconds: opts.seconds ?? 60,
2007
+ feedback: {
2008
+ declaredAudio: feedbackNodes.audio,
2009
+ declaredEffects: feedbackNodes.effects,
2010
+ heard: [...new Set(runs.flatMap((r) => r.heard))],
2011
+ shown: [...new Set(runs.flatMap((r) => r.shown))]
2012
+ }
2013
+ };
2014
+ }
2015
+ /**
2016
+ * The error lines the engine logged during a run, deduped and trimmed to the
2017
+ * sentence that identifies the fault.
2018
+ *
2019
+ * A quarantined script reports ONCE per node — the engine already made sure of
2020
+ * that — but two runs of the same broken game produce the same line twice, and
2021
+ * a report that prints it twenty times is a report nobody reads to the end.
2022
+ */
2023
+ function logLines(engine, level) {
2024
+ const out = /* @__PURE__ */ new Set();
2025
+ for (const entry of engine.log.entries()) {
2026
+ if (entry.level !== level) continue;
2027
+ const said = entry.parts.map((part) => part instanceof Error ? `${part.name}: ${part.message}` : String(part)).join(" ").replace(/^\[incanto\] /, "").replace(/\s+/g, " ").trim();
2028
+ if (said) out.add(said);
2029
+ }
2030
+ return [...out];
2031
+ }
2032
+ function pct(n, total) {
2033
+ return `${n}/${total}`;
2034
+ }
2035
+ function median(values) {
2036
+ if (values.length === 0) return 0;
2037
+ const s = [...values].sort((a, b) => a - b);
2038
+ return s[Math.floor(s.length / 2)];
2039
+ }
2040
+ /**
2041
+ * The report, as prose an agent reads and a person skims.
2042
+ *
2043
+ * Deliberately says what is WRONG and stays quiet about what is fine — a wall
2044
+ * of green is a wall nobody reads.
2045
+ */
2046
+ function playtestText(report) {
2047
+ const { runs, targets, signals, hazards } = report;
2048
+ const total = runs.length;
2049
+ const lines = [];
2050
+ lines.push(`${total} runs × up to ${report.seconds}s`);
2051
+ lines.push("");
2052
+ const won = runs.filter((r) => r.outcome === "won");
2053
+ if (won.length > 0) lines.push(` ✓ reached "won" in ${pct(won.length, total)} median ${Math.round(median(won.map((r) => r.timeMs)) / 1e3)}s`);
2054
+ else lines.push(report.declaresWin ? ` ✗ never reached "won" in ${total} runs — a win IS declared, and no run got to it` : ` · no win declared in this scene — nothing emits won/lost, so there is nothing to reach`);
2055
+ /**
2056
+ * How the runs ENDED — and the one section below the player-less branch's
2057
+ * early `return` that was never about walking.
2058
+ *
2059
+ * A mouse game whose behaviour threw was reported as `never reached "won"`,
2060
+ * because everything after that return was written off as "about someone
2061
+ * WALKING" and this list went with it. Only `fell` is; the rest is how the
2062
+ * run ended, which every game has. `playtestFailed` knew (exit 1 on `error`)
2063
+ * while the prose beside it did not, and an instrument's exit code and its
2064
+ * own report must not know different things.
2065
+ */
2066
+ const pushWarnings = () => {
2067
+ const said = report.warnings ?? [];
2068
+ if (said.length === 0) return;
2069
+ lines.push(` ! ${said[0]}`);
2070
+ for (const line of said.slice(1, 3)) lines.push(` ! ${line}`);
2071
+ if (said.length > 3) lines.push(` ! … and ${said.length - 3} more`);
2072
+ };
2073
+ /**
2074
+ * What the runs were still holding when they ended, by template.
2075
+ *
2076
+ * The MEDIAN across runs, because one run that happened to end mid-wave says
2077
+ * less than what every run ends with. Reported, never judged: `4 × Tower` is
2078
+ * furniture and `5 × Bolt` is a leak, and the engine cannot tell which of an
2079
+ * author's clones are meant to outlive the run — but the author can, in one
2080
+ * glance, which is exactly what was missing when a shipped game kept every
2081
+ * projectile that missed alive forever.
2082
+ */
2083
+ const pushAlive = () => {
2084
+ const worst = /* @__PURE__ */ new Map();
2085
+ for (const run of runs) for (const { path, count, half } of run.aliveClones ?? []) {
2086
+ const at = worst.get(path);
2087
+ if (!at || count > at.end) worst.set(path, {
2088
+ end: count,
2089
+ half
2090
+ });
2091
+ }
2092
+ const named = [...worst].filter(([, x]) => x.end >= 4).map(([path, x]) => ({
2093
+ path,
2094
+ ...x
2095
+ })).sort((a, b) => b.end - a.end);
2096
+ if (named.length === 0) return;
2097
+ const shown = named.slice(0, 3).map((x) => `${x.end} × ${x.path} (${x.half} at halfway)`);
2098
+ lines.push(` · made and still held: ${shown.join(", ")}` + (named.length > 3 ? ` (+${named.length - 3} more)` : "") + " — clones alive when the run ended, and how many there were at the halfway mark. Furniture holds steady; a leak climbs.");
2099
+ };
2100
+ const pushOutcomes = () => {
2101
+ for (const bad of [
2102
+ "error",
2103
+ "fell",
2104
+ "lost",
2105
+ "stuck",
2106
+ "unfinished",
2107
+ "swapped"
2108
+ ]) {
2109
+ const hits = runs.filter((r) => r.outcome === bad);
2110
+ if (hits.length === 0) continue;
2111
+ const where = bad === "fell" ? ` (last at y=${hits[0].endedAt[1].toFixed(1)})` : "";
2112
+ const mark = bad === "unfinished" || bad === "swapped" ? "·" : "✗";
2113
+ const what = bad === "unfinished" ? "ran out of time still playing" : bad === "swapped" ? "the game changed scene — this bot is bound to the tree it started in, so nothing after the swap was measured (not a defect)" : `${bad} in ${pct(hits.length, total)}`;
2114
+ lines.push(bad === "unfinished" || bad === "swapped" ? ` ${mark} ${pct(hits.length, total)} ${what}` : ` ${mark} ${what}${where}`);
2115
+ if (bad === "error") {
2116
+ const said = report.errorMessages ?? [];
2117
+ for (const line of said.slice(0, 3)) lines.push(` ${line}`);
2118
+ if (said.length > 3) lines.push(` … and ${said.length - 3} more`);
2119
+ }
2120
+ }
2121
+ };
2122
+ const clicks = runs.reduce((n, r) => n + r.clicks, 0);
2123
+ const drags = runs.reduce((n, r) => n + r.drags, 0);
2124
+ const presses = runs.reduce((n, r) => n + r.presses, 0);
2125
+ const keys = runs.reduce((n, r) => n + r.actionPresses, 0);
2126
+ const slots = runs.reduce((n, r) => n + r.widgetDrags, 0);
2127
+ if (!report.hasPlayer) {
2128
+ const named = report.actions.slice(0, 4).join(", ");
2129
+ const hands = [];
2130
+ if (clicks > 0) hands.push(`${clicks} click(s)`);
2131
+ if (drags > 0) hands.push(`${drags} drag(s)`);
2132
+ if (presses > 0) hands.push(`${presses} HUD press(es)`);
2133
+ if (slots > 0) hands.push(`${slots} slot drag(s)`);
2134
+ if (keys > 0) hands.push(`${keys} press(es) of ${report.actions.length} declared action(s) (${named}${report.actions.length > 4 ? ", …" : ""})`);
2135
+ lines.push(hands.length > 0 ? ` · played with ${hands.join(", ")} across ${total} runs, and no character to walk. Nothing below is about walking, so it is not reported.` + (keys > 0 ? " A game whose verbs are keys is played by TIMING them, which a random bot does not do — read the outcomes below as a floor, not a verdict." : "") : " · nothing here is drivable — no character controller, no node in the `player` group, no node named Player, nothing carrying `Clickable`, no focusable HUD widget on screen, no declared input action, and no behaviour that reads the cursor. The runs went nowhere because there was nobody to move and nothing to press, not because anything is wedged.");
2136
+ if ((clicks > 0 || drags > 0 || keys > 0 || slots > 0) && report.signals.length > 0) {
2137
+ const fired = new Set(runs.flatMap((r) => r.fired));
2138
+ const silent = report.signals.filter((s) => !fired.has(s));
2139
+ lines.push(silent.length === 0 ? ` ✓ every one of the ${report.signals.length} wired signal(s) fired` : ` · ${report.signals.length - silent.length} of ${report.signals.length} wired signal(s) fired; never fired: ${silent.slice(0, 5).join(", ")}` + (silent.length > 5 ? ` (+${silent.length - 5} more)` : ""));
2140
+ }
2141
+ pushOutcomes();
2142
+ pushWarnings();
2143
+ if (report.froze.length > 0) lines.push(` · the bot stopped pressing ${report.froze.map((x) => `\`${x}\``).join(", ")} — pressing it stopped the clock, and a run spent inside a pause menu measures nothing. Your game is fine; this is the bot getting out of its own way.`);
2144
+ if (report.restarted.length > 0) lines.push(` · the bot stopped pressing ${report.restarted.map((x) => `\`${x}\``).join(", ")} — pressing it reloaded the scene, which ends the run: the bot is bound to the tree it started in. One run paid for that; the rest played the game.`);
2145
+ pushAlive();
2146
+ return lines.join("\n");
2147
+ }
2148
+ pushOutcomes();
2149
+ pushWarnings();
2150
+ const everReached = new Set(runs.flatMap((r) => r.reached));
2151
+ const missed = targets.filter((path) => !everReached.has(path));
2152
+ const anySwapped = runs.some((r) => r.outcome === "swapped");
2153
+ if (missed.length > 0 && anySwapped) lines.push(` · ${missed.length} destination(s) unmeasured — a run changed scene and the bot stopped there. Judge these with a scripted run.`);
2154
+ else if (missed.length > 0) {
2155
+ lines.push("");
2156
+ lines.push(` never reached (${missed.length} of ${targets.length}):`);
2157
+ for (const path of missed.slice(0, 12)) lines.push(` ${path}`);
2158
+ if (missed.length > 12) lines.push(` …and ${missed.length - 12} more`);
2159
+ }
2160
+ const everFired = new Set(runs.flatMap((r) => r.fired));
2161
+ const silent = signals.filter((s) => !everFired.has(s));
2162
+ if (silent.length > 0) {
2163
+ lines.push("");
2164
+ lines.push(` never fired (${silent.length} of ${signals.length}):`);
2165
+ for (const s of silent.slice(0, 12)) lines.push(` ${s}`);
2166
+ if (silent.length > 12) lines.push(` …and ${silent.length - 12} more`);
2167
+ }
2168
+ const damage = runs.reduce((n, r) => n + r.damageTaken, 0);
2169
+ const lost = runs.filter((r) => r.outcome === "lost").length;
2170
+ if (report.froze.length > 0) {
2171
+ lines.push("");
2172
+ lines.push(` · the bot stopped pressing ${report.froze.map((a) => `\`${a}\``).join(", ")} — pressing it stopped the clock, and a run spent inside a pause menu measures nothing. Your game is fine; this is the bot getting out of its own way.`);
2173
+ }
2174
+ if (report.restarted.length > 0) {
2175
+ lines.push("");
2176
+ lines.push(` · the bot stopped pressing ${report.restarted.map((a) => `\`${a}\``).join(", ")} — pressing it reloaded the scene, which ends the run: the bot is bound to the tree it started in. One run paid for that; the rest played the game.`);
2177
+ }
2178
+ pushAlive();
2179
+ if (report.inertActions.length > 0) {
2180
+ lines.push("");
2181
+ lines.push(` · declared and never read: ${report.inertActions.map((a) => `\`${a}\``).join(", ")} — the scene binds a key for it and no code asks whether it is pressed, so the control list promises something the game does not do. Read it, or drop it from \`input\`. (An action only polled on a screen no run reached is a false alarm.)`);
2182
+ }
2183
+ lines.push("");
2184
+ if (damage > 0) lines.push(` danger: ${damage} hits across ${total} runs (${(damage / total).toFixed(1)} per run)`);
2185
+ else if (hazards === 0 && lost > 0) lines.push(` danger: nothing DEALT DAMAGE — no DamageOnContact, no hazard group — and yet ${lost} of ${total} runs were lost. The loss comes from something else: a fall, a timer, a trigger, or running out of lives.`);
2186
+ else if (hazards === 0) lines.push(" danger: nothing in this scene can hurt the player — no DamageOnContact, no hazard group. A game with no way to lose is a walkabout.");
2187
+ else lines.push(` danger: ${hazards} hazard(s) are wired and none of them landed a hit in ${total} runs. They may be unreachable — or the bot may simply have outrun them, which a real player would not. Judge it with a scripted run.`);
2188
+ return lines.join("\n");
2189
+ }
2190
+ /**
2191
+ * Did this playtest find something WRONG? — the exit code, as a function.
2192
+ *
2193
+ * `incanto-playtest` exited 1 whenever no run reached a win, and its own report
2194
+ * says when that is not a failure. Measured on a walkabout scene:
2195
+ *
2196
+ * ```
2197
+ * · no win declared in this scene — nothing emits won/lost, so there is
2198
+ * nothing to reach
2199
+ * · 2/2 ran out of time still playing
2200
+ * $ echo $?
2201
+ * 1
2202
+ * ```
2203
+ *
2204
+ * The `·` is this file's own marker for "not applicable", printed above an exit
2205
+ * code that says failure — so a CI step on a walkabout, a title screen, or any
2206
+ * scene whose ending is a quest a random walker cannot finish is red forever,
2207
+ * and its author learns to stop reading it. `incanto-verify` already reads the
2208
+ * JSON and calls those `unmeasured`; only the standalone bin disagreed.
2209
+ *
2210
+ * What stays a failure regardless of a declared win: a run that THREW, and a
2211
+ * run that left the world. Neither is a matter of what the scene set out to be.
2212
+ */
2213
+ function playtestFailed(report) {
2214
+ const any = (o) => report.runs.some((r) => r.outcome === o);
2215
+ if (any("error") || any("fell")) return true;
2216
+ if (!report.hasPlayer) return false;
2217
+ if (!report.declaresWin) return false;
2218
+ return !any("won");
2219
+ }
2220
+ /** The failing runs worth saving, newest problem first. */
2221
+ function failingReplays(report) {
2222
+ const order = [
2223
+ "error",
2224
+ "fell",
2225
+ "stuck",
2226
+ "lost"
2227
+ ];
2228
+ return report.runs.filter((r) => r.replay !== null).sort((a, b) => order.indexOf(a.outcome) - order.indexOf(b.outcome)).map((r) => ({
2229
+ seed: r.seed,
2230
+ outcome: r.outcome,
2231
+ replay: r.replay
2232
+ }));
2233
+ }
2234
+ //#endregion
2235
+ //#region src/test/facing.ts
2236
+ const DEG2RAD = Math.PI / 180;
2237
+ /** The forward vector of a yaw (degrees) for a given art convention. */
2238
+ function forwardOf(yawDeg, axis) {
2239
+ const yaw = yawDeg * DEG2RAD;
2240
+ const base = [Math.sin(yaw), Math.cos(yaw)];
2241
+ switch (axis) {
2242
+ case "-z": return [-base[0], -base[1]];
2243
+ case "+x": return [base[1], -base[0]];
2244
+ case "-x": return [-base[1], base[0]];
2245
+ default: return base;
2246
+ }
2247
+ }
2248
+ /**
2249
+ * The skin, and the yaw the art is DECLARED to be rotated by.
2250
+ *
2251
+ * `skinYawOffset` is how a model that does not face +Z says so, and it is the
2252
+ * supported fix rather than a bug. Subtracting it is what stops this check
2253
+ * crying wolf on every correctly-configured non-+Z model.
2254
+ */
2255
+ function findSkin(player, explicit) {
2256
+ if (explicit) return {
2257
+ skin: player.getNodeOrNull(explicit) ?? null,
2258
+ declaredOffset: 0
2259
+ };
2260
+ for (const child of player.children) {
2261
+ const ctl = child;
2262
+ if (typeof ctl.skinPath === "string" && ctl.skinPath !== "") {
2263
+ const skin = child.getNodeOrNull(ctl.skinPath);
2264
+ if (skin) return {
2265
+ skin,
2266
+ declaredOffset: ctl.skinYawOffset ?? 0
2267
+ };
2268
+ }
2269
+ }
2270
+ return {
2271
+ skin: player.getNodeOrNull("%Skin") ?? null,
2272
+ declaredOffset: 0
2273
+ };
2274
+ }
2275
+ /**
2276
+ * Drive the player forward and compare where the skin points with where it went.
2277
+ *
2278
+ * The travel direction is measured, not assumed: whatever the input map means by
2279
+ * "up", the character ends up going somewhere, and that somewhere is the truth
2280
+ * the art has to agree with.
2281
+ */
2282
+ async function facingReport(json, opts = {}) {
2283
+ const session = await createPlaySession(json, {
2284
+ seed: opts.seed ?? 1,
2285
+ ...opts.behaviors ? { behaviors: opts.behaviors } : {},
2286
+ stubMissingBehaviors: opts.stubMissingBehaviors ?? true,
2287
+ ...opts.resolveScene ? { resolveScene: opts.resolveScene } : {}
2288
+ });
2289
+ const player = findPlayer(session.scene.root);
2290
+ if (!player) {
2291
+ session.dispose();
2292
+ return {
2293
+ player: null,
2294
+ skin: null,
2295
+ dot: null,
2296
+ backwards: false,
2297
+ travelled: 0,
2298
+ basis: null,
2299
+ noAnswer: "no-player"
2300
+ };
2301
+ }
2302
+ const is2d = session.scene.dimension === "2d";
2303
+ const declared = session.engine.input.declaredActions();
2304
+ const move = opts.moveAction ?? declared.find((a) => a.type === "vector2")?.name ?? null;
2305
+ const posOf = () => {
2306
+ const p = player.position;
2307
+ return [p[0] ?? 0, (is2d ? p[1] : p[2]) ?? 0];
2308
+ };
2309
+ session.step(500);
2310
+ const from = posOf();
2311
+ if (move) session.engine.input.setActionVector(move, 1, 0);
2312
+ session.step(700);
2313
+ const to = posOf();
2314
+ if (move) session.engine.input.setActionVector(move, 0, 0);
2315
+ const dx = to[0] - from[0];
2316
+ const dz = to[1] - from[1];
2317
+ const travelled = Math.hypot(dx, dz);
2318
+ const { skin, declaredOffset } = findSkin(player, opts.skinPath);
2319
+ const skinPath = skin ? skin.getPath() : null;
2320
+ if (!skin || travelled < (is2d ? 2 : .05)) {
2321
+ session.dispose();
2322
+ return {
2323
+ player: player.getPath(),
2324
+ skin: skinPath,
2325
+ dot: null,
2326
+ backwards: false,
2327
+ travelled: Math.round(travelled * 1e3) / 1e3,
2328
+ basis: null,
2329
+ noAnswer: skin ? "did-not-move" : "no-skin"
2330
+ };
2331
+ }
2332
+ const heading = is2d ? forward2D(skin, dx, dz, declaredOffset) : {
2333
+ forward: forwardOf((skin.rotation?.[1] ?? 0) - declaredOffset, opts.forwardAxis ?? "+z"),
2334
+ basis: "yaw"
2335
+ };
2336
+ session.dispose();
2337
+ if (!heading.basis) return {
2338
+ player: player.getPath(),
2339
+ skin: skinPath,
2340
+ dot: null,
2341
+ backwards: false,
2342
+ travelled: Math.round(travelled * 1e3) / 1e3,
2343
+ basis: null,
2344
+ noAnswer: "no-heading"
2345
+ };
2346
+ const dot = (heading.forward[0] * dx + heading.forward[1] * dz) / travelled;
2347
+ return {
2348
+ player: player.getPath(),
2349
+ skin: skinPath,
2350
+ dot: Math.round(dot * 1e3) / 1e3,
2351
+ backwards: dot < -.5,
2352
+ travelled: Math.round(travelled * 1e3) / 1e3,
2353
+ basis: heading.basis,
2354
+ noAnswer: null
2355
+ };
2356
+ }
2357
+ /**
2358
+ * Which way 2D art points, and what was read to decide.
2359
+ *
2360
+ * A 2D skin says it in one of two ways and it is not the 3D one:
2361
+ *
2362
+ * - `flipX` — the side-on convention. Art faces +x unflipped, -x flipped.
2363
+ * Only answers a horizontally-dominant heading; a character walking straight
2364
+ * down a screen has nothing to disagree with.
2365
+ * - `rotation` — a NUMBER of degrees, not a 3-vector, clockwise in a y-DOWN
2366
+ * space (see `Node2D`), so heading 0 is +x and +90 is +y. Top-down art.
2367
+ *
2368
+ * `flipX` wins when both exist, because a sprite that is being flipped is one
2369
+ * whose facing is being driven.
2370
+ */
2371
+ function forward2D(skin, dx, dy, declaredOffset) {
2372
+ const s = skin;
2373
+ if (typeof s.flipX === "boolean") {
2374
+ if (Math.abs(dx) <= Math.abs(dy)) return {
2375
+ forward: [0, 0],
2376
+ basis: null
2377
+ };
2378
+ return {
2379
+ forward: [s.flipX ? -1 : 1, 0],
2380
+ basis: "flipX"
2381
+ };
2382
+ }
2383
+ if (typeof s.rotation === "number") {
2384
+ const yaw = (s.rotation - declaredOffset) * DEG2RAD;
2385
+ return {
2386
+ forward: [Math.cos(yaw), Math.sin(yaw)],
2387
+ basis: "yaw"
2388
+ };
2389
+ }
2390
+ return {
2391
+ forward: [0, 0],
2392
+ basis: null
2393
+ };
2394
+ }
2395
+ /** One line for a report, or the failure spelled out. */
2396
+ function facingText(report) {
2397
+ if (!report.player) return "facing: no player to drive";
2398
+ if (report.skin === null) return `facing: ${report.player} has no skin to check`;
2399
+ if (report.noAnswer === "no-heading") return `facing: ${report.skin} declares no heading (no flipX, no rotation) — nothing to compare its travel with`;
2400
+ if (report.dot === null) return `facing: ${report.player} did not move — nothing to compare`;
2401
+ if (report.backwards) return `facing: ${report.skin} RUNS BACKWARDS (dot ${report.dot.toFixed(2)}) — the skin points away from the direction of travel. The rule is +Z forward, yaw = atan2(dx, dz), with NO +180; for art that faces another way set skinYawOffset rather than adding to the yaw.`;
2402
+ return `facing: ${report.skin} faces its travel (dot ${report.dot.toFixed(2)})`;
2403
+ }
2404
+ //#endregion
2405
+ //#region src/test/agreement.ts
2406
+ /** Absolute slack, for values that are still converging out of a lerp. */
2407
+ const ABS_TOL = .001;
2408
+ /** Relative slack, so a large coordinate is not held to the absolute one. */
2409
+ const REL_TOL = .005;
2410
+ function closeEnough(a, b, absTol, relTol) {
2411
+ return Math.abs(a - b) <= Math.max(absTol, relTol * Math.abs(a));
2412
+ }
2413
+ function numbers(v) {
2414
+ if (typeof v === "number") return [v];
2415
+ if (Array.isArray(v) && v.every((x) => typeof x === "number")) return v;
2416
+ return null;
2417
+ }
2418
+ /**
2419
+ * Every way the clients disagree, most structural first.
2420
+ *
2421
+ * @param live snapshots taken after the match has quiesced
2422
+ * @param late the same, for a client that joined AFTER everything settled
2423
+ */
2424
+ function agreementProblems(live, late = [], opts = {}) {
2425
+ const absTol = opts.absTol ?? ABS_TOL;
2426
+ const relTol = opts.relTol ?? REL_TOL;
2427
+ const out = [];
2428
+ const compare = (snapshots, lateJoin) => {
2429
+ const found = [];
2430
+ for (const s of snapshots) {
2431
+ if (!lateJoin) for (const key of s.unreadable ?? []) found.push({
2432
+ kind: "unreadable",
2433
+ account: s.account,
2434
+ observer: s.account,
2435
+ key,
2436
+ summary: `${s.account} declares \`${key}\` in its \`network.sync\` and cannot READ it — it is never sent, so no client will ever see it change. Check the prop name and the node path (the key is a last-dot split: \`Skin.animation\`).`
2437
+ });
2438
+ for (const [observer, seen] of Object.entries(s.seen)) {
2439
+ if (seen === null) {
2440
+ found.push({
2441
+ kind: "missing",
2442
+ account: s.account,
2443
+ observer,
2444
+ key: "",
2445
+ summary: `${observer} never materialised ${s.account} at all — a NetworkSpawner spawns one instance per OTHER account, so check its \`scene\` key is registered and its \`source\` names a real collection.`
2446
+ });
2447
+ continue;
2448
+ }
2449
+ for (const [key, truth] of Object.entries(s.truth)) {
2450
+ const mine = seen[key];
2451
+ if (mine === void 0) {
2452
+ found.push({
2453
+ kind: lateJoin ? "erased" : "absent",
2454
+ account: s.account,
2455
+ observer,
2456
+ key,
2457
+ truth,
2458
+ summary: lateJoin ? `${observer} joined late and never got ${s.account}'s \`${key}\` — the live clients have it, so the value is gone from the AUTHORITATIVE state, not from the wire. A partial patch replaces the stored \`sync\` object.` : `${observer} has no \`${key}\` for ${s.account} — the key path does not resolve on the spawned scene (a renamed child?), or it was never sent.`
2459
+ });
2460
+ continue;
2461
+ }
2462
+ const a = numbers(truth);
2463
+ const b = numbers(mine);
2464
+ if (a && b) {
2465
+ if (a.length !== b.length) {
2466
+ found.push({
2467
+ kind: "shape",
2468
+ account: s.account,
2469
+ observer,
2470
+ key,
2471
+ truth,
2472
+ seen: mine,
2473
+ summary: `${observer} holds ${b.length} components of ${s.account}'s \`${key}\` where there are ${a.length} — the interpolator treats a missing component as already correct, so this never converges and never complains.`
2474
+ });
2475
+ continue;
2476
+ }
2477
+ if (a.every((v, i) => closeEnough(v, b[i], absTol, relTol))) continue;
2478
+ found.push({
2479
+ kind: "mismatch",
2480
+ account: s.account,
2481
+ observer,
2482
+ key,
2483
+ truth,
2484
+ seen: mine,
2485
+ summary: `${observer} has ${s.account}'s \`${key}\` as ${JSON.stringify(mine)}, not ${JSON.stringify(truth)}.`
2486
+ });
2487
+ continue;
2488
+ }
2489
+ if (jsonEquals(truth, mine)) continue;
2490
+ found.push({
2491
+ kind: "mismatch",
2492
+ account: s.account,
2493
+ observer,
2494
+ key,
2495
+ truth,
2496
+ seen: mine,
2497
+ summary: `${observer} has ${s.account}'s \`${key}\` as ${JSON.stringify(mine)}, not ${JSON.stringify(truth)}.`
2498
+ });
2499
+ }
2500
+ }
2501
+ }
2502
+ return found;
2503
+ };
2504
+ out.push(...compare(live, false));
2505
+ if (late.length > 0) {
2506
+ const alreadyAbsent = new Set(out.filter((d) => d.kind === "absent").map((d) => `${d.account}|${d.key}`));
2507
+ for (const d of compare(late, true)) {
2508
+ if (d.kind === "erased" && alreadyAbsent.has(`${d.account}|${d.key}`)) continue;
2509
+ out.push(d);
2510
+ }
2511
+ }
2512
+ const rank = {
2513
+ missing: 0,
2514
+ unspawned: 1,
2515
+ unreadable: 2,
2516
+ erased: 3,
2517
+ absent: 4,
2518
+ shape: 5,
2519
+ mismatch: 6
2520
+ };
2521
+ return out.sort((x, y) => rank[x.kind] - rank[y.kind]);
2522
+ }
2523
+ /**
2524
+ * Collections that arrived and became nothing, and collections two clients
2525
+ * disagree about.
2526
+ *
2527
+ * Pure over snapshots, like `agreementProblems`, and reported in the same
2528
+ * shape so one verdict covers both halves of what a spawner does.
2529
+ */
2530
+ function collectionProblems(snapshots) {
2531
+ const out = [];
2532
+ for (const s of snapshots) {
2533
+ if (s.received.length === 0) continue;
2534
+ if (s.materialised.length === 0) {
2535
+ out.push({
2536
+ kind: "unspawned",
2537
+ account: s.account,
2538
+ observer: s.account,
2539
+ key: s.id,
2540
+ summary: `${s.account}: ${s.spawner} received ${s.received.length} entit${s.received.length === 1 ? "y" : "ies"} for collection '${s.id}' and materialised NONE — check its \`scene\` key is registered with \`manager.registerScene\`.`
2541
+ });
2542
+ continue;
2543
+ }
2544
+ if (s.materialised.length !== s.received.length) out.push({
2545
+ kind: "unspawned",
2546
+ account: s.account,
2547
+ observer: s.account,
2548
+ key: s.id,
2549
+ summary: `${s.account}: ${s.spawner} holds ${s.materialised.length} instance(s) for ${s.received.length} entit${s.received.length === 1 ? "y" : "ies"} in collection '${s.id}' — some entity never became a node, or one outlived its entity.`
2550
+ });
2551
+ }
2552
+ const byId = /* @__PURE__ */ new Map();
2553
+ for (const s of snapshots) {
2554
+ const list = byId.get(s.id) ?? [];
2555
+ list.push(s);
2556
+ byId.set(s.id, list);
2557
+ }
2558
+ for (const [id, list] of byId) {
2559
+ const first = list[0];
2560
+ if (!first || list.length < 2) continue;
2561
+ const mine = [...first.received].sort().join(",");
2562
+ for (const other of list.slice(1)) {
2563
+ if ([...other.received].sort().join(",") === mine) continue;
2564
+ out.push({
2565
+ kind: "mismatch",
2566
+ account: first.account,
2567
+ observer: other.account,
2568
+ key: id,
2569
+ summary: `${other.account} and ${first.account} hold different contents for collection '${id}' (${other.received.length} vs ${first.received.length} entities) — they are playing different games.`
2570
+ });
2571
+ }
2572
+ }
2573
+ return out;
2574
+ }
2575
+ //#endregion
2576
+ //#region src/test/agreement-snapshot.ts
2577
+ /** Is any behavior in this tree a stand-in for TypeScript that was not loaded? */
2578
+ function hasStubbedBehavior(root) {
2579
+ if (root.behavior instanceof StubBehavior) return true;
2580
+ for (const child of root.children) if (hasStubbedBehavior(child)) return true;
2581
+ return false;
2582
+ }
2583
+ /** The last-dot split of the RECEIVE side, applied to a spawned instance. */
2584
+ function readFromInstance(instance, key) {
2585
+ const lastDot = key.lastIndexOf(".");
2586
+ const target = lastDot === -1 ? instance : instance.getNodeOrNull(key.slice(0, lastDot));
2587
+ if (!target) return void 0;
2588
+ return target[lastDot === -1 ? key : key.slice(lastDot + 1)];
2589
+ }
2590
+ /**
2591
+ * The interpolation TARGET a spawner holds for one of its instances.
2592
+ *
2593
+ * Found by asking every spawner in the tree, rather than by walking up from the
2594
+ * instance: a spawner's children are its own, so exactly one answers.
2595
+ */
2596
+ function replicatedPositionOf(engine, instance) {
2597
+ const root = engine.scene?.tree.root;
2598
+ if (!root) return void 0;
2599
+ let found;
2600
+ const walk = (node) => {
2601
+ if (found) return;
2602
+ const asSpawner = node;
2603
+ if (typeof asSpawner.replicatedPosition === "function") {
2604
+ const target = asSpawner.replicatedPosition(instance);
2605
+ if (target) {
2606
+ found = target;
2607
+ return;
2608
+ }
2609
+ }
2610
+ for (const child of node.children) walk(child);
2611
+ };
2612
+ walk(root);
2613
+ return found;
2614
+ }
2615
+ /** Every spawned instance in a client's tree, by the child name a spawner gives it. */
2616
+ function instancesByName(engine) {
2617
+ const out = /* @__PURE__ */ new Map();
2618
+ const root = engine.scene?.tree.root;
2619
+ if (!root) return out;
2620
+ const walk = (node) => {
2621
+ for (const child of node.children) {
2622
+ if (!out.has(child.name)) out.set(child.name, child);
2623
+ walk(child);
2624
+ }
2625
+ };
2626
+ walk(root);
2627
+ return out;
2628
+ }
2629
+ /**
2630
+ * One snapshot per account: its own truth, and what each other client believes.
2631
+ *
2632
+ * @param clients every client in the match
2633
+ * @param observers the clients whose beliefs to record (defaults to `clients`)
2634
+ */
2635
+ function takeAgreementSnapshots(clients, observers = clients) {
2636
+ const snapshots = [];
2637
+ for (const owner of clients) {
2638
+ const root = owner.engine.scene?.tree.root;
2639
+ if (!root) continue;
2640
+ const node = findOwnerNode(root);
2641
+ if (!node) continue;
2642
+ const keys = Array.isArray(node.network?.sync) ? node.network.sync : [];
2643
+ if (keys.length === 0) continue;
2644
+ const stubbed = hasStubbedBehavior(root);
2645
+ const truth = {};
2646
+ const unreadable = [];
2647
+ for (const key of keys) {
2648
+ const value = readSyncKey(node, key);
2649
+ if (value === void 0) {
2650
+ if (!stubbed) unreadable.push(key);
2651
+ } else truth[key] = value;
2652
+ }
2653
+ const seen = {};
2654
+ for (const other of observers) {
2655
+ if (other.account === owner.account) continue;
2656
+ const instance = instancesByName(other.engine).get(sanitizeName(owner.account));
2657
+ if (!instance) {
2658
+ seen[other.account] = null;
2659
+ continue;
2660
+ }
2661
+ const held = {};
2662
+ for (const key of Object.keys(truth)) held[key] = readFromInstance(instance, key);
2663
+ const target = replicatedPositionOf(other.engine, instance);
2664
+ if (target && "position" in held) held.position = [...target];
2665
+ seen[other.account] = held;
2666
+ }
2667
+ snapshots.push({
2668
+ account: owner.account,
2669
+ truth,
2670
+ seen,
2671
+ ...unreadable.length > 0 ? { unreadable } : {}
2672
+ });
2673
+ }
2674
+ return snapshots;
2675
+ }
2676
+ /**
2677
+ * What every `collection:` spawner RECEIVED, and what it made of it.
2678
+ *
2679
+ * The account-keyed comparison above cannot see these: a bullet has no account.
2680
+ * A spawner can take a hundred entities off the wire and materialise none of
2681
+ * them — an unregistered `scene` key does exactly that — and until this
2682
+ * existed the `agrees` rung had nothing to say about it.
2683
+ */
2684
+ function takeCollectionSnapshots(clients) {
2685
+ const out = [];
2686
+ for (const client of clients) {
2687
+ const root = client.engine.scene?.tree.root;
2688
+ const manager = NetworkManager.get(client.engine);
2689
+ if (!root || !manager) continue;
2690
+ const walk = (node) => {
2691
+ const source = node.source;
2692
+ if (typeof source === "string" && source.startsWith("collection:")) {
2693
+ const id = source.slice(11);
2694
+ out.push({
2695
+ account: client.account,
2696
+ spawner: node.getPath(),
2697
+ id,
2698
+ received: Object.keys(manager.latestCollection(id)),
2699
+ materialised: node.children.map((c) => c.name)
2700
+ });
2701
+ }
2702
+ for (const child of node.children) walk(child);
2703
+ };
2704
+ walk(root);
2705
+ }
2706
+ return out;
2707
+ }
2708
+ //#endregion
2709
+ //#region src/test/feel.ts
2710
+ const STEP_MS = 1e3 / 60;
2711
+ /** World units a 300-frame hold must cover before it counts as "it moved". */
2712
+ const MOVED = .05;
2713
+ /** Drop height for the buffer probe — long enough to outlast a coyote window. */
2714
+ const DROP = 12;
2715
+ /** Metres a 3D probe is lifted/dropped from. */
2716
+ const UNIT_LIFT = 40;
2717
+ /** Pixels a 2D probe is lifted/dropped from — a 2D world is ~30x the numbers. */
2718
+ const PX_LIFT = 1200;
2719
+ function positionOf(node) {
2720
+ const p = node.position;
2721
+ return [
2722
+ p?.[0] ?? 0,
2723
+ p?.[1] ?? 0,
2724
+ p?.[2] ?? 0
2725
+ ];
2726
+ }
2727
+ /**
2728
+ * A body's velocity, whichever name it keeps it under.
2729
+ *
2730
+ * `RigidBody2D/3D` call it `linearVelocity` (the solver owns it);
2731
+ * `CharacterBody2D/3D` call it `velocity` (Godot semantics — the game owns it
2732
+ * and calls moveAndSlide). Reading only the first reported **zero for every
2733
+ * CharacterBody game**, so `incanto-feel` answered `topSpeed 0` and every
2734
+ * derived window as null — a feel report that cannot see the character move.
2735
+ * It went unnoticed because every test in feel.test.ts drives a RigidBody3D.
2736
+ */
2737
+ function velocityOf(node) {
2738
+ const body = node;
2739
+ const v = body.linearVelocity ?? body.velocity;
2740
+ return [
2741
+ v?.[0] ?? 0,
2742
+ v?.[1] ?? 0,
2743
+ v?.[2] ?? 0
2744
+ ];
2745
+ }
2746
+ async function makeProbe(json, opts) {
2747
+ const session = await createPlaySession(json, {
2748
+ seed: opts.seed ?? 1,
2749
+ ...opts.behaviors ? { behaviors: opts.behaviors } : {},
2750
+ stubMissingBehaviors: opts.stubMissingBehaviors ?? true,
2751
+ ...opts.resolveScene ? { resolveScene: opts.resolveScene } : {}
2752
+ });
2753
+ const player = findPlayer(session.scene.root);
2754
+ const declared = session.engine.input.declaredActions();
2755
+ const moveOptions = declared.filter((a) => a.type === "vector2").map((a) => a.name);
2756
+ const named = (prop, type) => {
2757
+ const want = controllerProp(session.scene.root, prop);
2758
+ return want && declared.some((a) => a.name === want && a.type === type) ? want : null;
2759
+ };
2760
+ const move = opts.moveAction ?? named("moveAction", "vector2") ?? moveOptions[0] ?? null;
2761
+ const jump = opts.jumpAction ?? named("jumpAction", "button") ?? (declared.some((a) => a.name === "jump" && a.type === "button") ? "jump" : null);
2762
+ if (!player) {
2763
+ session.dispose();
2764
+ return {
2765
+ probe: null,
2766
+ move,
2767
+ jump,
2768
+ moveOptions
2769
+ };
2770
+ }
2771
+ const is2d = session.scene.dimension === "2d";
2772
+ return {
2773
+ probe: {
2774
+ session,
2775
+ engine: session.engine,
2776
+ player,
2777
+ is2d,
2778
+ up: is2d ? -1 : 1,
2779
+ lift: is2d ? PX_LIFT : UNIT_LIFT,
2780
+ step: (frames = 1) => session.step(frames * STEP_MS),
2781
+ pos: () => positionOf(player),
2782
+ vel: () => velocityOf(player),
2783
+ speed: () => {
2784
+ const v = velocityOf(player);
2785
+ return Math.hypot(v[0], v[2]);
2786
+ }
2787
+ },
2788
+ move,
2789
+ jump,
2790
+ moveOptions
2791
+ };
2792
+ }
2793
+ /** Let the character settle onto whatever it is standing on. */
2794
+ function settle$1(probe) {
2795
+ probe.step(60);
2796
+ }
2797
+ /** Speed in the plane the character walks on — x/z in 3D, x/y in 2D. */
2798
+ function planar(probe, v) {
2799
+ return probe.is2d ? Math.hypot(v[0], v[1]) : Math.hypot(v[0], v[2]);
2800
+ }
2801
+ /** The four ways one vector2 action can be pushed, best guess first. */
2802
+ const AXES = [
2803
+ [1, 0],
2804
+ [0, -1],
2805
+ [0, 1],
2806
+ [-1, 0]
2807
+ ];
2808
+ /** Hold one direction and learn what this character's plateau actually is. */
2809
+ function measureAccel(probe, move, axis) {
2810
+ settle$1(probe);
2811
+ const before = probe.pos();
2812
+ probe.engine.input.setActionVector(move, axis[0], axis[1]);
2813
+ let latency = null;
2814
+ const samples = [];
2815
+ let planarTop = 0;
2816
+ for (let f = 1; f <= 300; f++) {
2817
+ probe.step();
2818
+ if (latency === null) {
2819
+ const p = probe.pos();
2820
+ if (Math.hypot(p[0] - before[0], p[2] - before[2]) > .01) latency = f;
2821
+ }
2822
+ samples.push(probe.speed());
2823
+ planarTop = Math.max(planarTop, planar(probe, probe.vel()));
2824
+ }
2825
+ const sorted = [...samples].sort((x, y) => x - y);
2826
+ const top = sorted[Math.floor(sorted.length * .9)] ?? 0;
2827
+ const peak = sorted[sorted.length - 1] ?? 0;
2828
+ probe.engine.input.setActionVector(move, 0, 0);
2829
+ const after = probe.pos();
2830
+ const d = probe.is2d ? [
2831
+ after[0] - before[0],
2832
+ after[1] - before[1],
2833
+ 0
2834
+ ] : [
2835
+ after[0] - before[0],
2836
+ 0,
2837
+ after[2] - before[2]
2838
+ ];
2839
+ const travelled = Math.hypot(d[0], d[1], d[2]);
2840
+ const dir = travelled > 1e-6 ? [
2841
+ d[0] / travelled,
2842
+ d[1] / travelled,
2843
+ d[2] / travelled
2844
+ ] : [
2845
+ 0,
2846
+ 0,
2847
+ 0
2848
+ ];
2849
+ return {
2850
+ top: top > .05 ? top : planarTop,
2851
+ peak,
2852
+ latencyFrames: latency,
2853
+ travelled,
2854
+ dir
2855
+ };
2856
+ }
2857
+ /**
2858
+ * Time from standstill to 95% of top speed.
2859
+ *
2860
+ * Two passes on purpose: the first learns what top speed IS (it depends on the
2861
+ * scene's own props and surface), the second times the climb to 95% of it. One
2862
+ * pass would have to guess the target.
2863
+ */
2864
+ function timeToFullSpeed(probe, move, axis, top) {
2865
+ settle$1(probe);
2866
+ const target = top * .95;
2867
+ probe.engine.input.setActionVector(move, axis[0], axis[1]);
2868
+ for (let f = 1; f <= 600; f++) {
2869
+ probe.step();
2870
+ if (probe.speed() >= target) {
2871
+ probe.engine.input.setActionVector(move, 0, 0);
2872
+ return Math.round(f * STEP_MS);
2873
+ }
2874
+ }
2875
+ probe.engine.input.setActionVector(move, 0, 0);
2876
+ return null;
2877
+ }
2878
+ function timeToStop(probe, move, axis, top) {
2879
+ settle$1(probe);
2880
+ probe.engine.input.setActionVector(move, axis[0], axis[1]);
2881
+ for (let f = 0; f < 600 && probe.speed() < top * .9; f++) probe.step();
2882
+ probe.engine.input.setActionVector(move, 0, 0);
2883
+ for (let f = 1; f <= 600; f++) {
2884
+ probe.step();
2885
+ if (probe.speed() < top * .05) return Math.round(f * STEP_MS);
2886
+ }
2887
+ return null;
2888
+ }
2889
+ function turnaround(probe, move, axis, top, dir) {
2890
+ settle$1(probe);
2891
+ probe.engine.input.setActionVector(move, axis[0], axis[1]);
2892
+ for (let f = 0; f < 600 && probe.speed() < top * .9; f++) probe.step();
2893
+ probe.engine.input.setActionVector(move, -axis[0], -axis[1]);
2894
+ for (let f = 1; f <= 600; f++) {
2895
+ probe.step();
2896
+ const v = probe.vel();
2897
+ if (v[0] * dir[0] + v[1] * dir[1] + v[2] * dir[2] < -top * .9) return Math.round(f * STEP_MS);
2898
+ }
2899
+ probe.engine.input.setActionVector(move, 0, 0);
2900
+ return null;
2901
+ }
2902
+ /**
2903
+ * How high one jump goes — TAPPED by default, HELD when asked.
2904
+ *
2905
+ * This pressed for exactly one frame and released, which is a tap. With
2906
+ * `jumpCutMultiplier` — the variable-height jump the 3D character skill
2907
+ * recommends at `0.45` — a tap is a fraction of the real jump, and this number
2908
+ * is what the skill tells you to size your level against. Measured on one
2909
+ * scene, changing only that prop:
2910
+ *
2911
+ * ```
2912
+ * jumpCutMultiplier 1 apex 1.831 u time to apex 433 ms airtime 717 ms
2913
+ * jumpCutMultiplier 0.45 apex 0.478 u time to apex 200 ms airtime 367 ms
2914
+ * ```
2915
+ *
2916
+ * Under-reporting the jump by ~4x is how a level ends up with gaps the player
2917
+ * clears effortlessly, or ledges they cannot understand missing. Both are
2918
+ * reported now, and the text names which is which.
2919
+ */
2920
+ function jumpShape(probe, jump, hold = false) {
2921
+ settle$1(probe);
2922
+ const floor = probe.pos()[1];
2923
+ probe.engine.input.pressAction(jump);
2924
+ probe.step();
2925
+ if (!hold) probe.engine.input.releaseAction(jump);
2926
+ let apex = 0;
2927
+ let toApex = 0;
2928
+ let airtime = null;
2929
+ for (let f = 2; f <= 600; f++) {
2930
+ probe.step();
2931
+ const h = (probe.pos()[1] - floor) * probe.up;
2932
+ if (h > apex) {
2933
+ apex = h;
2934
+ toApex = f;
2935
+ }
2936
+ if (apex > .02 && h <= .02 && airtime === null) {
2937
+ airtime = Math.round(f * STEP_MS);
2938
+ break;
2939
+ }
2940
+ }
2941
+ if (hold) probe.engine.input.releaseAction(jump);
2942
+ const noise = probe.up > 0 ? .02 : 2;
2943
+ if (apex <= noise) return {
2944
+ apex: null,
2945
+ toApexMs: null,
2946
+ airtimeMs: null
2947
+ };
2948
+ return {
2949
+ apex: Number(apex.toFixed(3)),
2950
+ toApexMs: Math.round(toApex * STEP_MS),
2951
+ airtimeMs: airtime
2952
+ };
2953
+ }
2954
+ /**
2955
+ * How long after leaving the ground a jump still fires — measured, not read.
2956
+ *
2957
+ * Binary search over the delay: lift the character clear of its support (the
2958
+ * same thing walking off a ledge does), wait, press, and see whether it
2959
+ * launched. A `coyoteSeconds` the controller never consults reports 0 here,
2960
+ * which is the whole point — that was a real bug in this engine's 3D controller
2961
+ * until 0.37 and the schema looked correct the entire time.
2962
+ */
2963
+ async function measureCoyote(json, opts, jump) {
2964
+ const fired = async (delayFrames) => {
2965
+ const { probe } = await makeProbe(json, opts);
2966
+ if (!probe) return false;
2967
+ settle$1(probe);
2968
+ const p = probe.pos();
2969
+ probe.player.position = [
2970
+ p[0],
2971
+ p[1] + probe.lift * probe.up,
2972
+ p[2]
2973
+ ];
2974
+ probe.step(delayFrames);
2975
+ const before = probe.vel()[1];
2976
+ probe.engine.input.pressAction(jump);
2977
+ probe.step();
2978
+ const after = probe.vel()[1];
2979
+ probe.session.dispose();
2980
+ return (after - before) * probe.up > 1;
2981
+ };
2982
+ if (!await fired(1)) return 0;
2983
+ let lo = 1;
2984
+ let hi = 60;
2985
+ if (await fired(hi)) return null;
2986
+ while (hi - lo > 1) {
2987
+ const mid = Math.floor((lo + hi) / 2);
2988
+ if (await fired(mid)) lo = mid;
2989
+ else hi = mid;
2990
+ }
2991
+ return Math.round(lo * STEP_MS);
2992
+ }
2993
+ /**
2994
+ * `coyoteSeconds` as written in the scene, in ms — for CONTRAST, never instead.
2995
+ *
2996
+ * The whole point of this module is that a number is measured rather than read
2997
+ * off the schema. This one is read, and it earns its place by disagreeing: a
2998
+ * controller that declares 0.12 and measures "always" is telling you something
2999
+ * neither number says alone.
3000
+ */
3001
+ function declaredCoyote(json) {
3002
+ let found = null;
3003
+ const walk = (node) => {
3004
+ if (!node || typeof node !== "object") return;
3005
+ const n = node;
3006
+ if (typeof n.type === "string" && /^CharacterController[23]D$/.test(n.type)) {
3007
+ const value = n.props?.coyoteSeconds;
3008
+ if (typeof value === "number" && found === null) found = Math.round(value * 1e3);
3009
+ }
3010
+ for (const child of n.children ?? []) walk(child);
3011
+ };
3012
+ walk(json?.root);
3013
+ return found;
3014
+ }
3015
+ /**
3016
+ * How long BEFORE landing a press still survives.
3017
+ *
3018
+ * Same shape: drop the character, press at T frames before it would touch down,
3019
+ * and check whether it left the ground again afterwards.
3020
+ */
3021
+ async function measureBuffer(json, opts, jump) {
3022
+ const survived = async (earlyFrames) => {
3023
+ const { probe } = await makeProbe(json, opts);
3024
+ if (!probe) return false;
3025
+ settle$1(probe);
3026
+ const floor = probe.pos()[1];
3027
+ const drop = (probe.up > 0 ? DROP : probe.lift / 3) * probe.up;
3028
+ probe.player.position = [
3029
+ probe.pos()[0],
3030
+ floor + drop,
3031
+ probe.pos()[2]
3032
+ ];
3033
+ let frames = 0;
3034
+ while ((probe.pos()[1] - floor) * probe.up > .05 && frames < 200) {
3035
+ probe.step();
3036
+ frames += 1;
3037
+ }
3038
+ const fallFrames = frames;
3039
+ probe.session.dispose();
3040
+ const second = await makeProbe(json, opts);
3041
+ if (!second.probe) return false;
3042
+ const p2 = second.probe;
3043
+ settle$1(p2);
3044
+ p2.player.position = [
3045
+ p2.pos()[0],
3046
+ floor + drop,
3047
+ p2.pos()[2]
3048
+ ];
3049
+ p2.step(Math.max(0, fallFrames - earlyFrames));
3050
+ p2.engine.input.pressAction(jump);
3051
+ p2.step();
3052
+ p2.engine.input.releaseAction(jump);
3053
+ let rose = false;
3054
+ for (let f = 0; f < 40; f++) {
3055
+ p2.step();
3056
+ if (p2.vel()[1] * p2.up > 1) rose = true;
3057
+ }
3058
+ p2.session.dispose();
3059
+ return rose;
3060
+ };
3061
+ if (!await survived(1)) return 0;
3062
+ let lo = 1;
3063
+ let hi = 30;
3064
+ if (await survived(hi)) return null;
3065
+ while (hi - lo > 1) {
3066
+ const mid = Math.floor((lo + hi) / 2);
3067
+ if (await survived(mid)) lo = mid;
3068
+ else hi = mid;
3069
+ }
3070
+ return Math.round(lo * STEP_MS);
3071
+ }
3072
+ async function feelReport(json, opts = {}) {
3073
+ const first = await makeProbe(json, opts);
3074
+ const move = first.move;
3075
+ const jump = first.jump;
3076
+ if (!first.probe) return {
3077
+ player: null,
3078
+ moveAction: move,
3079
+ jumpAction: jump,
3080
+ topSpeed: 0,
3081
+ peakSpeed: null,
3082
+ timeToFullSpeedMs: null,
3083
+ timeToStopMs: null,
3084
+ turnaroundMs: null,
3085
+ jumpApex: null,
3086
+ heldJumpApex: null,
3087
+ heldAirtimeMs: null,
3088
+ timeToApexMs: null,
3089
+ airtimeMs: null,
3090
+ coyoteMs: null,
3091
+ jumpsInMidair: false,
3092
+ declaredCoyoteMs: null,
3093
+ jumpBufferMs: null,
3094
+ inputToMotionFrames: null,
3095
+ moveAxis: null,
3096
+ moveTried: [],
3097
+ errorMessages: [],
3098
+ warnings: []
3099
+ };
3100
+ const playerPath = first.probe.player.getPath();
3101
+ const said = {
3102
+ errors: /* @__PURE__ */ new Set(),
3103
+ warnings: /* @__PURE__ */ new Set()
3104
+ };
3105
+ const collect = (probe) => {
3106
+ if (!probe) return;
3107
+ for (const entry of probe.session.engine.log.entries()) {
3108
+ const line = entry.parts.map((part) => part instanceof Error ? `${part.name}: ${part.message}` : String(part)).join(" ").replace(/^\[incanto\] /, "").replace(/\s+/g, " ").trim();
3109
+ if (!line) continue;
3110
+ if (entry.level === "error") said.errors.add(line);
3111
+ else if (entry.level === "warn") said.warnings.add(line);
3112
+ }
3113
+ };
3114
+ const fresh = async () => (await makeProbe(json, opts)).probe;
3115
+ let topSpeed = 0;
3116
+ let peak = null;
3117
+ let latency = null;
3118
+ let action = move;
3119
+ let axis = null;
3120
+ let dir = [
3121
+ 0,
3122
+ 0,
3123
+ 0
3124
+ ];
3125
+ const tried = move ? [move] : [];
3126
+ if (move) {
3127
+ const primary = measureAccel(first.probe, move, AXES[0]);
3128
+ collect(first.probe);
3129
+ first.probe.session.dispose();
3130
+ let best = primary.travelled > MOVED ? primary : null;
3131
+ if (best) axis = AXES[0];
3132
+ else {
3133
+ const actions = opts.moveAction === void 0 ? [move, ...first.moveOptions.filter((n) => n !== move)] : [move];
3134
+ search: for (const candidate of actions) {
3135
+ if (!tried.includes(candidate)) tried.push(candidate);
3136
+ for (const way of AXES) {
3137
+ if (candidate === move && way === AXES[0]) continue;
3138
+ const probe = await fresh();
3139
+ if (!probe) break search;
3140
+ const ramp = measureAccel(probe, candidate, way);
3141
+ collect(probe);
3142
+ probe.session.dispose();
3143
+ if (ramp.travelled > MOVED && (!best || ramp.travelled > best.travelled)) {
3144
+ best = ramp;
3145
+ action = candidate;
3146
+ axis = way;
3147
+ }
3148
+ }
3149
+ }
3150
+ }
3151
+ if (best) {
3152
+ topSpeed = Number(best.top.toFixed(2));
3153
+ peak = best.peak > best.top * 1.1 ? Number(best.peak.toFixed(2)) : null;
3154
+ latency = best.latencyFrames;
3155
+ dir = best.dir;
3156
+ }
3157
+ } else {
3158
+ collect(first.probe);
3159
+ first.probe.session.dispose();
3160
+ }
3161
+ let toFull = null;
3162
+ let toStop = null;
3163
+ let turn = null;
3164
+ if (action && axis && topSpeed > .05) {
3165
+ const a = await fresh();
3166
+ if (a) {
3167
+ toFull = timeToFullSpeed(a, action, axis, topSpeed);
3168
+ collect(a);
3169
+ a.session.dispose();
3170
+ }
3171
+ const b = await fresh();
3172
+ if (b) {
3173
+ toStop = timeToStop(b, action, axis, topSpeed);
3174
+ collect(b);
3175
+ b.session.dispose();
3176
+ }
3177
+ const c = await fresh();
3178
+ if (c) {
3179
+ turn = turnaround(c, action, axis, topSpeed, dir);
3180
+ collect(c);
3181
+ c.session.dispose();
3182
+ }
3183
+ }
3184
+ const NO_SHAPE = {
3185
+ apex: null,
3186
+ toApexMs: null,
3187
+ airtimeMs: null
3188
+ };
3189
+ let shape = NO_SHAPE;
3190
+ let held = NO_SHAPE;
3191
+ let coyote = null;
3192
+ let buffer = null;
3193
+ if (jump) {
3194
+ const d = await fresh();
3195
+ if (d) {
3196
+ shape = jumpShape(d, jump);
3197
+ collect(d);
3198
+ d.session.dispose();
3199
+ }
3200
+ const h = await fresh();
3201
+ if (h) {
3202
+ held = jumpShape(h, jump, true);
3203
+ collect(h);
3204
+ h.session.dispose();
3205
+ }
3206
+ coyote = await measureCoyote(json, opts, jump);
3207
+ buffer = await measureBuffer(json, opts, jump);
3208
+ }
3209
+ return {
3210
+ player: playerPath,
3211
+ moveAction: action,
3212
+ jumpAction: jump,
3213
+ topSpeed,
3214
+ peakSpeed: peak,
3215
+ timeToFullSpeedMs: toFull,
3216
+ timeToStopMs: toStop,
3217
+ turnaroundMs: turn,
3218
+ jumpApex: shape.apex,
3219
+ heldJumpApex: held.apex,
3220
+ heldAirtimeMs: held.airtimeMs,
3221
+ timeToApexMs: shape.toApexMs,
3222
+ airtimeMs: shape.airtimeMs,
3223
+ coyoteMs: coyote,
3224
+ jumpsInMidair: coyote === null && jump !== null,
3225
+ declaredCoyoteMs: declaredCoyote(json),
3226
+ jumpBufferMs: buffer,
3227
+ inputToMotionFrames: latency,
3228
+ moveAxis: axis,
3229
+ moveTried: tried,
3230
+ errorMessages: [...said.errors],
3231
+ warnings: [...said.warnings]
3232
+ };
3233
+ }
3234
+ const ms = (v) => v === null ? "—" : `${v} ms`;
3235
+ /** Numbers, not grades. What "good" means here is a genre decision. */
3236
+ function feelText(report) {
3237
+ if (!report.player) return "feel: no player found — this needs a body with a CharacterController, a node in group \"player\", or one named Player.";
3238
+ const lines = [];
3239
+ lines.push(`feel of ${report.player}`);
3240
+ lines.push("");
3241
+ for (const line of (report.errorMessages ?? []).slice(0, 3)) lines.push(` ✗ ${line}`);
3242
+ for (const line of (report.warnings ?? []).slice(0, 3)) lines.push(` ! ${line}`);
3243
+ if ((report.errorMessages?.length ?? 0) + (report.warnings?.length ?? 0) > 0) lines.push("");
3244
+ if (!report.moveAction) lines.push(" pushed nothing — this scene declares no vector2 action");
3245
+ else if (!report.moveAxis) {
3246
+ const names = report.moveTried.map((n) => `'${n}'`).join(", ");
3247
+ lines.push(` pushed ${names} every way (±x, ±y) — the player did not move`);
3248
+ lines.push("");
3249
+ lines.push(` could not move ${report.player}: no motion in 300 frames with ${names} pushed ±x and ±y. Either the movement is not wired to a vector action, or it is driven by buttons. Nothing below was measured.`);
3250
+ } else lines.push(` pushed '${report.moveAction}' [${report.moveAxis.join(", ")}]`);
3251
+ if (report.moveAxis) {
3252
+ lines.push(` top speed ${report.topSpeed} u/s`);
3253
+ lines.push(` to full speed ${ms(report.timeToFullSpeedMs)}`);
3254
+ if (report.peakSpeed !== null) lines.push(` peak ${report.peakSpeed} u/s ← SOMETHING ELSE moved it`);
3255
+ lines.push(` to a stop ${ms(report.timeToStopMs)}`);
3256
+ lines.push(` turnaround ${ms(report.turnaroundMs)}`);
3257
+ lines.push(` input → motion ${report.inputToMotionFrames === null ? "—" : `${report.inputToMotionFrames} frame(s)`}`);
3258
+ }
3259
+ if (report.jumpAction) {
3260
+ lines.push("");
3261
+ lines.push(` jump apex ${report.jumpApex === null ? "— (nothing left the ground)" : `${report.jumpApex} u (tapped)`}`);
3262
+ const cut = report.heldJumpApex !== null && report.jumpApex !== null && Math.abs(report.heldJumpApex - report.jumpApex) > .02;
3263
+ if (cut) lines.push(` held apex ${report.heldJumpApex} u ← size your level against THIS`);
3264
+ lines.push(` time to apex ${ms(report.timeToApexMs)}`);
3265
+ lines.push(` airtime ${ms(report.airtimeMs)}${cut && report.heldAirtimeMs !== null ? ` (${ms(report.heldAirtimeMs)} held)` : ""}`);
3266
+ lines.push(` coyote window ${report.jumpsInMidair ? "not measurable — this character jumps in MID-AIR" : report.coyoteMs === null ? "always (no window)" : ms(report.coyoteMs)}`);
3267
+ lines.push(` jump buffer ${report.jumpBufferMs === null ? "always (no window)" : ms(report.jumpBufferMs)}${report.jumpsInMidair ? " (suspect — see above)" : ""}`);
3268
+ if (report.jumpsInMidair) {
3269
+ lines.push("");
3270
+ lines.push(" note: a jump still fires a full second after leaving the ground, so");
3271
+ lines.push(" \"how long after leaving the ground\" has no answer here. A double");
3272
+ lines.push(" jump does this, and so does any behaviour that pushes the body up");
3273
+ lines.push(" while `jump` is held — a glide, a jetpack, an updraft.");
3274
+ if (report.declaredCoyoteMs !== null) lines.push(` The controller DECLARES coyoteSeconds ${ms(report.declaredCoyoteMs)}; this probe cannot confirm it.`);
3275
+ }
3276
+ if (report.coyoteMs === 0) {
3277
+ lines.push("");
3278
+ lines.push(" note: coyote window is 0 — a jump that only fires while the ground check");
3279
+ lines.push(" is true reads to players as unresponsive controls. Try coyoteSeconds: 0.12.");
3280
+ }
3281
+ if (report.jumpBufferMs === 0) {
3282
+ lines.push(" note: jump buffer is 0 — a press a few frames before landing is thrown away.");
3283
+ lines.push(" Try jumpBufferSeconds: 0.15.");
3284
+ }
3285
+ }
3286
+ if (report.peakSpeed !== null) {
3287
+ lines.push("");
3288
+ lines.push(` note: one frame hit ${report.peakSpeed} u/s while the character SUSTAINS ${report.topSpeed}. Something other than its own movement moved it during the`);
3289
+ lines.push(" measurement — a knockback from an enemy the probe walked into, a launcher,");
3290
+ lines.push(" a conveyor. `top speed` is the 90th percentile so the transient does not");
3291
+ lines.push(" own the number, but measure feel somewhere nothing can hit you.");
3292
+ }
3293
+ return lines.join("\n");
3294
+ }
3295
+ /** The action name a `CharacterController2D/3D` in this tree says it reads. */
3296
+ function controllerProp(root, prop) {
3297
+ const stack = [root];
3298
+ while (stack.length > 0) {
3299
+ const node = stack.pop();
3300
+ const type = node.constructor.typeName ?? "";
3301
+ if (type === "CharacterController2D" || type === "CharacterController3D") {
3302
+ const value = node[prop];
3303
+ return typeof value === "string" && value !== "" ? value : null;
3304
+ }
3305
+ for (const child of node.children) stack.push(child);
3306
+ }
3307
+ return null;
3308
+ }
3309
+ //#endregion
3310
+ //#region src/test/multiplay.ts
3311
+ /** Run a whole match headlessly and report what the clients ended up sharing. */
3312
+ async function playMultiplayer(opts) {
3313
+ const count = opts.players ?? 2;
3314
+ const seconds = opts.seconds ?? 5;
3315
+ const stepMs = opts.stepMs ?? 1e3 / 60;
3316
+ const accounts = Array.from({ length: count }, (_, i) => `p${i + 1}`);
3317
+ const { server, players, dispose } = await createSplitScreen({
3318
+ scene: opts.scene,
3319
+ ...opts.server ? { server: opts.server } : {},
3320
+ ...opts.scenes ? { scenes: opts.scenes } : {},
3321
+ ...opts.seed !== void 0 ? { seed: opts.seed } : {},
3322
+ ...opts.load ? { load: opts.load } : {},
3323
+ accounts,
3324
+ setup: () => {}
3325
+ });
3326
+ await opts.start?.(players);
3327
+ await settle();
3328
+ const frames = Math.max(1, Math.round(seconds * 1e3 / stepMs));
3329
+ let now = 0;
3330
+ for (let frame = 0; frame < frames; frame++) {
3331
+ now += stepMs;
3332
+ for (const player of players) {
3333
+ await opts.drive?.(player, frame, now / 1e3);
3334
+ player.engine.tick(now);
3335
+ }
3336
+ if (frame % 6 === 0) await settle();
3337
+ }
3338
+ await settle();
3339
+ for (let frame = 0; frame < 60; frame++) {
3340
+ now += stepMs;
3341
+ for (const player of players) player.engine.tick(now);
3342
+ if (frame % 6 === 0) await settle();
3343
+ }
3344
+ await settle();
3345
+ const live = takeAgreementSnapshots(players);
3346
+ const matchDetail = players.map((p) => ({
3347
+ account: p.account,
3348
+ room: p.manager.roomId,
3349
+ spawners: spawnerCounts(p.engine),
3350
+ spawned: Object.fromEntries(spawnerCounts(p.engine).map((sp) => [sp.name, sp.children])),
3351
+ sawAccounts: Object.keys(p.manager.latestUserStates ?? {}).filter((a) => a !== p.account),
3352
+ errors: p.engine.stats().errors
3353
+ }));
3354
+ let lateSnapshots = [];
3355
+ let lateJoinChecked = false;
3356
+ let lateJoinRefused = false;
3357
+ if ((opts.lateJoin ?? true) && players.length > 0) {
3358
+ const joiner = await Promise.race([joinLate(server, players[0], opts), new Promise((resolve) => {
3359
+ setTimeout(() => resolve(null), LATE_JOIN_TIMEOUT_MS).unref?.();
3360
+ })]);
3361
+ if (joiner === null && (opts.lateJoin ?? true)) lateJoinRefused = true;
3362
+ if (joiner) {
3363
+ for (let frame = 0; frame < 60; frame++) {
3364
+ now += stepMs;
3365
+ for (const player of [...players, joiner]) player.engine.tick(now);
3366
+ if (frame % 6 === 0) await settle();
3367
+ }
3368
+ await settle();
3369
+ lateSnapshots = takeAgreementSnapshots(players, [joiner]);
3370
+ lateJoinChecked = true;
3371
+ }
3372
+ }
3373
+ const collections = takeCollectionSnapshots(players);
3374
+ const report = summarize(players, seconds, frames, matchDetail, {
3375
+ disagreements: [...agreementProblems(live, lateSnapshots), ...collectionProblems(collections)],
3376
+ keysCompared: live.reduce((n, s) => n + Object.keys(s.truth).length * Object.keys(s.seen).length, 0) + collections.reduce((n, c) => n + c.received.length, 0),
3377
+ lateJoinChecked,
3378
+ ...lateJoinRefused ? { lateJoinRefused } : {}
3379
+ });
3380
+ dispose();
3381
+ return report;
3382
+ }
3383
+ /**
3384
+ * One more client, against the SAME server and the SAME room.
3385
+ *
3386
+ * `createSplitScreen` builds all its panels up front, so this is the ten lines
3387
+ * of it that matter — and the room id has to come from panel 0 or the joiner
3388
+ * lands in its own auto-assigned room and reports everything as missing.
3389
+ */
3390
+ async function joinLate(server, first, opts) {
3391
+ try {
3392
+ const { Engine } = await import("./register-DuVdzxbq.js").then((n) => n.y);
3393
+ const { loadScene } = await import("./save-slots-Bvuh2p_r.js").then((n) => n.g);
3394
+ const { jsonClone } = await import("./json-CfTjpvW8.js").then((n) => n.i);
3395
+ const engine = new Engine(opts.seed !== void 0 ? { seed: opts.seed + 999 } : {});
3396
+ engine.setScene(loadScene(jsonClone(opts.scene), opts.load));
3397
+ const manager = await NetworkManager.create(engine, {
3398
+ transport: server.createClient("late"),
3399
+ room: first.manager.roomId
3400
+ });
3401
+ for (const [name, json] of Object.entries(opts.scenes ?? {})) manager.registerScene(name, json);
3402
+ return {
3403
+ account: "late",
3404
+ engine,
3405
+ manager
3406
+ };
3407
+ } catch {
3408
+ return null;
3409
+ }
3410
+ }
3411
+ /** How long a late joiner waits before the match is declared wedged. */
3412
+ const LATE_JOIN_TIMEOUT_MS = 15e3;
3413
+ /** Give the server's promise queue a turn. */
3414
+ function settle() {
3415
+ return new Promise((resolve) => setTimeout(resolve, 0));
3416
+ }
3417
+ function summarize(players, seconds, frames, matchDetail, agreement) {
3418
+ const rooms = players.map((p) => p.manager.roomId);
3419
+ const detail = matchDetail.map((d, i) => ({
3420
+ ...d,
3421
+ errors: players[i]?.engine.stats().errors ?? d.errors
3422
+ }));
3423
+ const problems = [
3424
+ ...agreement.lateJoinRefused ? ["the server never answered a late joiner within 15s — it is wedged. LocalGameServer runs one call at a time, so look for an await that never settles or a nested `$lock` on the same key ($lock is NOT reentrant)."] : [],
3425
+ ...multiplayProblems(detail, rooms),
3426
+ ...agreement.disagreements.map((d) => d.summary)
3427
+ ];
3428
+ return {
3429
+ ok: problems.length === 0,
3430
+ disagreements: agreement.disagreements,
3431
+ keysCompared: agreement.keysCompared,
3432
+ lateJoinChecked: agreement.lateJoinChecked,
3433
+ seconds,
3434
+ frames,
3435
+ rooms,
3436
+ players: detail,
3437
+ roomState: players[0]?.manager.latestRoomState ?? {},
3438
+ problems
3439
+ };
3440
+ }
3441
+ /**
3442
+ * What is wrong with a finished match, in the order it matters.
3443
+ *
3444
+ * Pure, and exported, because every one of these is a SILENT failure — the test
3445
+ * for "the clients ended up in different rooms" must not depend on being able
3446
+ * to reproduce different rooms.
3447
+ */
3448
+ function multiplayProblems(detail, rooms) {
3449
+ const problems = [];
3450
+ if (new Set(rooms).size > 1) problems.push(`the clients joined ${new Set(rooms).size} different rooms (${rooms.join(", ")}) — nobody can see anybody. A scene's \`multiplayer.room\` of "auto" is SERVER-ASSIGNED; name a room, or let the harness pin one.`);
3451
+ else if (detail.length > 1) {
3452
+ const blind = detail.filter((d) => d.sawAccounts.length === 0);
3453
+ if (blind.length > 0) problems.push(`${blind.map((d) => d.account).join(", ")} never saw another account's state — an owner node replicates only what its \`network.sync\` lists, and only when it changes.`);
3454
+ for (const d of detail) {
3455
+ if (d.sawAccounts.length === 0) continue;
3456
+ for (const spawner of d.spawners) {
3457
+ if (spawner.source !== "users" || spawner.children > 0) continue;
3458
+ problems.push(`${d.account}: ${spawner.name} saw ${d.sawAccounts.length} other account(s) and materialised NONE of them — check its \`scene\` key is registered with \`manager.registerScene\`.`);
3459
+ }
3460
+ }
3461
+ }
3462
+ for (const d of detail) if (d.errors > 0) problems.push(`${d.account}: ${d.errors} frame error(s) — read engine.log.`);
3463
+ return problems;
3464
+ }
3465
+ function spawnerCounts(engine) {
3466
+ const out = [];
3467
+ const root = engine.scene?.tree.root;
3468
+ if (!root) return out;
3469
+ const walk = (node) => {
3470
+ if (node instanceof NetworkSpawner) out.push({
3471
+ name: node.name,
3472
+ source: node.source,
3473
+ children: node.children.length
3474
+ });
3475
+ for (const child of node.children) walk(child);
3476
+ };
3477
+ walk(root);
3478
+ return out;
3479
+ }
3480
+ /** The report as the one paragraph a reader needs. */
3481
+ function multiplayText(report) {
3482
+ const lines = [`multiplayer: ${report.players.length} clients, ${report.seconds}s, room ${[...new Set(report.rooms)].join(" + ")}`];
3483
+ lines.push(report.keysCompared === 0 ? " agrees: nothing to compare — no owner node declared any sync keys" : ` agrees: ${report.keysCompared} replicated value(s) compared across clients${report.lateJoinChecked ? " + a late joiner" : ""}, over one in-memory server (not a live wire)`);
3484
+ for (const p of report.players) {
3485
+ const spawned = Object.entries(p.spawned).map(([name, n]) => `${name}=${n}`).join(" ");
3486
+ lines.push(` ${p.account}: sees [${p.sawAccounts.join(", ") || "—"}]` + (spawned ? ` spawned ${spawned}` : "") + (p.errors ? ` · ${p.errors} error(s)` : ""));
3487
+ }
3488
+ for (const problem of report.problems) lines.push(` ✗ ${problem}`);
3489
+ if (report.ok) lines.push(" ✓ every client in one room, seeing the others");
3490
+ return lines.join("\n");
3491
+ }
3492
+ //#endregion
3493
+ //#region src/test/verify-ladder.ts
3494
+ /**
3495
+ * The verification ladder, as one answer.
3496
+ *
3497
+ * The rungs already exist — `incanto-check` says the scene loads,
3498
+ * `incanto-playtest` says it can be finished, `incanto-frame` says something
3499
+ * was drawn. They are documented together and used apart, because using them
3500
+ * together means knowing three things nobody writes down:
3501
+ *
3502
+ * the ORDER (a scene that does not load cannot be played or drawn, so the
3503
+ * first red rung is the only one worth reading)
3504
+ * that an unmeasured rung is not a failing one — "no dev server" means the
3505
+ * question was never asked, and answering it "fail" sends an agent editing
3506
+ * a scene that is fine
3507
+ * what to do NEXT, which is one sentence and never the whole report
3508
+ *
3509
+ * Pure: the rungs are run by the CLI, this decides what they add up to.
3510
+ */
3511
+ const RUNTIME_NOISE = /^\((node|bun):\d+\)|^\s*\(Use `node --trace|ExperimentalWarning|DeprecationWarning|Warning: .*deprecat/i;
3512
+ /**
3513
+ * …and a STACK FRAME is not a message either.
3514
+ *
3515
+ * The same failure one class along. `incanto-frame` could not reach the page,
3516
+ * undici threw, and the `draws` rung reported its summary as
3517
+ *
3518
+ * ? draws — node:internal/deps/undici/undici:13502
3519
+ *
3520
+ * which is the first line of the crash: a file and a line number, no sentence.
3521
+ * `TypeError: fetch failed` was three lines below it, under the caret and the
3522
+ * offending source line.
3523
+ */
3524
+ const STACK_FURNITURE = /^\s*at\s|^\s*\^+\s*$|^[\w./-]*:\d+$|^\s*\.\.\.\s*\d+|[;{]\s*$/;
3525
+ /** `TypeError: fetch failed` — the line a person would read out loud. */
3526
+ const LOOKS_LIKE_A_MESSAGE = /^[A-Z][\w$]*(Error|Exception)\b/;
3527
+ /**
3528
+ * What a spawned tool actually SAID, given its stderr.
3529
+ *
3530
+ * Used by every rung in `incanto-verify`; it lives here rather than in the bin
3531
+ * so it can be tested without a built `dist` — the bin loads the library at
3532
+ * runtime, so a regex only the bin knows is a regex nothing checks.
3533
+ */
3534
+ function firstLine(text) {
3535
+ const lines = (text ?? "").split("\n").map((l) => l.trim()).filter(Boolean);
3536
+ const named = lines.find((l) => LOOKS_LIKE_A_MESSAGE.test(l));
3537
+ if (named) return named;
3538
+ return lines.find((l) => !RUNTIME_NOISE.test(l) && !STACK_FURNITURE.test(l)) ?? lines.find((l) => !RUNTIME_NOISE.test(l)) ?? lines[0] ?? null;
3539
+ }
3540
+ const MARK = {
3541
+ pass: "✓",
3542
+ fail: "✗",
3543
+ unmeasured: "?",
3544
+ skipped: "·"
3545
+ };
3546
+ /**
3547
+ * The `says` rung, from `incanto-logs`' report — the one rung whose whole job is
3548
+ * "what is the running game SAYING".
3549
+ *
3550
+ * It read `ok` and printed "no errors, no failed assets", which threw away
3551
+ * every WARNING the page had reported. The engine warns about exactly the
3552
+ * things that leave a game running and wrong — a camera whose `follow` resolves
3553
+ * to a node with no position, a `DayNight` with no sky to move, a `Buoyancy`
3554
+ * with no water, a body with no collider — and the headline command showed a
3555
+ * tick over all of it.
3556
+ *
3557
+ * Warnings are NOT a failure (an author may have chosen to live with one), so
3558
+ * this keeps `pass` and carries them the way `loads` has always carried
3559
+ * `incanto-check`'s: as notes under the rung, printed with a `!`.
3560
+ *
3561
+ * Pure, so it can be tested without a browser — the bin owns fetching the
3562
+ * report, this owns what it means.
3563
+ */
3564
+ function saysRung(report) {
3565
+ if (!report) return {
3566
+ name: "says",
3567
+ status: "unmeasured",
3568
+ summary: "no page to ask"
3569
+ };
3570
+ const warnings = (report.grouped ?? []).filter((g) => g.level === "warn");
3571
+ if (!report.ok) {
3572
+ const asset = report.assetErrors?.[0];
3573
+ return {
3574
+ name: "says",
3575
+ status: "fail",
3576
+ summary: asset ? `asset ${asset.ref} never loaded: ${asset.error}` : report.worst?.message ?? `${report.stats?.errors ?? 0} errors were swallowed`,
3577
+ fix: "read it in full: `incanto-logs`"
3578
+ };
3579
+ }
3580
+ if (warnings.length === 0) return {
3581
+ name: "says",
3582
+ status: "pass",
3583
+ summary: "no errors, no failed assets"
3584
+ };
3585
+ const distinct = warnings.length;
3586
+ return {
3587
+ name: "says",
3588
+ status: "pass",
3589
+ summary: `no errors — ${distinct} warning${distinct === 1 ? "" : "s"}, and the game is running`,
3590
+ notes: warnings.slice(0, 3).map((g) => `${g.message}${g.count > 1 ? ` ×${g.count}` : ""}`),
3591
+ ...warnings.length > 3 ? { fix: "the rest are in `incanto-logs`" } : {}
3592
+ };
3593
+ }
3594
+ /**
3595
+ * What to say when the bot could not WALK — which is not the same as could not
3596
+ * play.
3597
+ *
3598
+ * This branch was "nobody to move", said about games the bot had played for
3599
+ * eight runs. The MOUSE half was fixed when a flip puzzle whose every tile the
3600
+ * bot clicked was told to add a character controller; the KEYBOARD half was
3601
+ * found the same way, one genre along: a sokoban declares `move`, `undo` and
3602
+ * `restart`, a behaviour reads them every frame and moves plain nodes, and the
3603
+ * bot pressed them 989 times across eight runs — while the rung said
3604
+ *
3605
+ * ? plays — nothing here is drivable — 8 runs had nobody to move and
3606
+ * nothing to click
3607
+ *
3608
+ * Both halves of that sentence were false. A whole class — sokoban, snake,
3609
+ * tetris, roguelikes, typing games, anything on a grid — has no character to
3610
+ * add, and the advice was to add one anyway.
3611
+ *
3612
+ * PRESSED and READ, not merely declared: `inertActions` is the report's own
3613
+ * list of actions nothing asks about, and a scene where every one of them is
3614
+ * inert really does have nothing driving it.
3615
+ *
3616
+ * Pure, so the wording can be tested without running a game.
3617
+ */
3618
+ function unmeasuredPlays(report) {
3619
+ const runs = report.runs ?? [];
3620
+ const total = runs.length;
3621
+ const clicks = runs.reduce((n, r) => n + (r.clicks ?? 0), 0);
3622
+ const keyed = runs.reduce((n, r) => n + (r.actionPresses ?? 0), 0);
3623
+ const live = (report.actions ?? []).filter((a) => !(report.inertActions ?? []).includes(a));
3624
+ const ending = report.declaresWin ? "and no run reached the win" : "and no win to reach";
3625
+ if (clicks > 0) return {
3626
+ summary: `played with the MOUSE — ${clicks} click(s) across ${total} runs, ${ending}`,
3627
+ fix: "a mouse game is played, not walked — emit `won`/`lost` and this rung can pass"
3628
+ };
3629
+ if (keyed > 0 && live.length > 0) return {
3630
+ summary: `played with the KEYBOARD — ${keyed} press(es) of ${live.length} declared action(s) (${live.slice(0, 4).join(", ")}) across ${total} runs, ${ending}`,
3631
+ fix: "a game played by KEYS is played, not walked — emit `won`/`lost` and this rung can pass. (A random presser will not solve a puzzle: drive the solution in a scripted `runScript` harness, and let this rung tell you about crashes.)"
3632
+ };
3633
+ if (report.hasPlayer === false) return {
3634
+ summary: `nothing here is drivable — ${total} runs had nobody to move and nothing to click`,
3635
+ fix: "give the player a character controller, the `player` group, or the name Player — or `Clickable` on what the cursor drives"
3636
+ };
3637
+ return {
3638
+ summary: `${total} runs went nowhere, and your behaviours were not loaded — that is most of what moves a character`,
3639
+ fix: `name them: \`incanto-verify ${report.scene ?? "src/game.scene.json"} --behaviors src/behaviors.ts\``
3640
+ };
3641
+ }
3642
+ /**
3643
+ * What the run HEARD and SHOWED, as one rung.
3644
+ *
3645
+ * Pure, like `saysRung` and `unmeasuredPlays`: the bin owns running the
3646
+ * playtest, this owns what its feedback log means. Extracted when the
3647
+ * all-silent branch was found printing a COUNT with nothing beside it —
3648
+ * `racing-3d` reports 20 emitters and its scene declares none of them (they are
3649
+ * a pool of `new Particles3D()` built in `onReady`), so an author counted
3650
+ * twenty in a message and found zero in their JSON.
3651
+ */
3652
+ function feelsRung(fb, opts = {}) {
3653
+ if (!fb) return {
3654
+ name: "feels",
3655
+ status: "skipped",
3656
+ summary: "not run — the playtest did not report"
3657
+ };
3658
+ const declaredPaths = [...fb.declaredAudio ?? [], ...fb.declaredEffects ?? []];
3659
+ const declared = declaredPaths.length;
3660
+ const fired = new Set([...fb.heard ?? [], ...fb.shown ?? []]);
3661
+ if (declared === 0) return {
3662
+ name: "feels",
3663
+ status: "skipped",
3664
+ summary: "this game declares no sound and no effects"
3665
+ };
3666
+ const some = (list) => `${list.slice(0, 3).join(", ")}${list.length > 3 ? ` +${list.length - 3}` : ""}`;
3667
+ if (fired.size === 0) {
3668
+ if (opts.played === false) return {
3669
+ name: "feels",
3670
+ status: "unmeasured",
3671
+ summary: `${declared} sound/effect node(s) in the tree and none fired — but no run got the game moving, so nothing here was measured — ${some(declaredPaths)}`,
3672
+ fix: "drive it in a scripted run (`runScript` from `incanto/test`): a sustained throttle, a real fight, the actual verb. A random bot that never gets going triggers nothing, and that is a fact about the bot."
3673
+ };
3674
+ return {
3675
+ name: "feels",
3676
+ status: "fail",
3677
+ summary: `${declared} sound/effect node(s) in the tree, and not one fired in any run — ${some(declaredPaths)}`,
3678
+ fix: "connect them — a signal to `play` on an AudioPlayer, or `replay()` on a one-shot; `engine.audio.recent()` and `engine.effects.recent()` say what did fire"
3679
+ };
3680
+ }
3681
+ const silent = declaredPaths.filter((path) => !fired.has(path));
3682
+ if (silent.length === 0) return {
3683
+ name: "feels",
3684
+ status: "pass",
3685
+ summary: `every one of the ${declared} sound/effect nodes fired`
3686
+ };
3687
+ return {
3688
+ name: "feels",
3689
+ status: "unmeasured",
3690
+ summary: `${declared - silent.length} of ${declared} fired — silent: ${some(silent)}`,
3691
+ fix: "a silent emitter is either a broken wire or a moment random play never reached — the two look identical from here. Reach it in a scripted run (`runScript` from `incanto/test`), and this rung can tell you which."
3692
+ };
3693
+ }
3694
+ function ladderVerdict(rungs, ctx = {}) {
3695
+ const failed = rungs.find((r) => r.status === "fail");
3696
+ const unmeasured = rungs.filter((r) => r.status === "unmeasured");
3697
+ const next = failed ? failed.fix ?? failed.summary : unmeasured[0]?.fix ?? unmeasured[0]?.summary ?? null;
3698
+ const ambiguous = rungs.length === 0 && (ctx.candidates?.length ?? 0) > 1;
3699
+ return {
3700
+ ok: !failed,
3701
+ rungs,
3702
+ unmeasured: unmeasured.map((r) => r.name),
3703
+ next: ambiguous ? "name the one you mean: `incanto-verify <scene.json>`" : next ?? null,
3704
+ candidates: ctx.candidates ?? [],
3705
+ measured: rungs.length > 0
3706
+ };
3707
+ }
3708
+ /** The ladder as a person or an agent reads it: every rung, then the one action. */
3709
+ function ladderText(v) {
3710
+ if (v.rungs.length === 0) {
3711
+ if (v.candidates.length > 1) return [
3712
+ `${v.candidates.length} scenes here, and guessing between them would verify the wrong one:`,
3713
+ ...v.candidates.map((c) => ` ${c}`),
3714
+ "",
3715
+ `next: ${v.next}`
3716
+ ].join("\n");
3717
+ return "nothing to verify — no scene was given and none was found";
3718
+ }
3719
+ const lines = [];
3720
+ for (const r of v.rungs) {
3721
+ lines.push(`${MARK[r.status]} ${r.name} — ${r.summary}`);
3722
+ for (const note of r.notes ?? []) lines.push(` ! ${note}`);
3723
+ }
3724
+ lines.push("");
3725
+ if (!v.ok) lines.push("NOT verified.");
3726
+ else if (v.unmeasured.length > 0) lines.push(`passes what was measured — ${v.unmeasured.join(", ")} not measured.`);
3727
+ else lines.push("verified: it loads, it plays, and it draws.");
3728
+ if (v.next) lines.push(`next: ${v.next}`);
3729
+ return lines.join("\n");
3730
+ }
3731
+ //#endregion
3732
+ //#region src/test/index.ts
3733
+ /**
3734
+ * incanto/test — the browserless verification harness.
3735
+ *
3736
+ * The agent loop is "author → VERIFY → fix", and production environments
3737
+ * (e.g. the agent8 VM) have no browser. This module makes the scene state
3738
+ * itself the screenshot:
3739
+ *
3740
+ * - `validateScene(json)` — every hard load error, without a browser
3741
+ * - `runScript(json, ...)` — scripted play by ACTION intent at a fixed
3742
+ * timestep, with inline assertions and periodic snapshots
3743
+ * - `captureScene(scene)` / `describeCapture(capture)` — a structural,
3744
+ * grep-able "text screenshot" of the live tree
3745
+ *
3746
+ * Deterministic by construction: fixed timestep, seeded `engine.rng`,
3747
+ * no requestAnimationFrame.
3748
+ */
3749
+ /** Node types whose whole job is to sit on the ground. Characters, water,
3750
+ * clouds, birds and lights are all legitimately off it. */
3751
+ const GROUNDED_TYPES = ["MeshInstance3D", "InstancedMesh3D"];
3752
+ /** Under one of these, the BODY owns where the thing stands — a character's
3753
+ * skin hangs at whatever offset its rig wants, and is not a floating prop. */
3754
+ const BODY_OWNED = new Set([
3755
+ "Area3D",
3756
+ "CharacterBody3D",
3757
+ "RigidBody3D",
3758
+ "StaticBody3D"
3759
+ ]);
3760
+ /**
3761
+ * Every prop that is floating or buried.
3762
+ *
3763
+ * THE most repeated mistake in scene authoring, by humans and agents alike, is
3764
+ * a hand-computed Y: correct when it is written and wrong the moment anything
3765
+ * reshapes the ground under it — a river carving its bed, a new seed, a
3766
+ * generator that mirrors a carve slightly differently. This is the check that
3767
+ * catches it, and `snapToGround` is the fix that stops it happening.
3768
+ *
3769
+ * Anything explicitly marked `snapToGround: false` is skipped: that is the
3770
+ * author saying "yes, this hangs in the air", which a bridge deck, a lantern
3771
+ * and a cloud all legitimately do.
3772
+ *
3773
+ * ```ts
3774
+ * const off = findFloatingProps(gameJson);
3775
+ * ok(`nothing floats (${off.length})`, off.length === 0);
3776
+ * ```
3777
+ */
3778
+ function findFloatingProps(json, opts = {}) {
3779
+ registerAllNodes();
3780
+ const tolerance = opts.tolerance ?? .1;
3781
+ const types = new Set(opts.types ?? GROUNDED_TYPES);
3782
+ let scene;
3783
+ try {
3784
+ scene = loadScene(json, {
3785
+ stubMissingBehaviors: true,
3786
+ engine: new Engine({ scheduler: () => () => {} })
3787
+ });
3788
+ } catch (error) {
3789
+ throw new IncantoError("BAD_FORMAT", `findFloatingProps: the scene does not load, so nothing could be measured. ${error?.message ?? String(error)}`);
3790
+ }
3791
+ let ground = null;
3792
+ const findGround = (node) => {
3793
+ if (!ground && node.constructor.typeName === "Terrain3D" && typeof node.heightAt === "function") ground = node;
3794
+ for (const child of node.children) findGround(child);
3795
+ };
3796
+ findGround(scene.root);
3797
+ if (!ground) throw new IncantoError("NODE_NOT_FOUND", "findFloatingProps: no Terrain3D in this scene, so there is no ground to measure against. This check reads terrain height; a level built from mesh floors needs a physics raycast instead.");
3798
+ const out = [];
3799
+ const walk = (node, path, ox, oy, oz, yaw, bodyOwned) => {
3800
+ const n = node;
3801
+ let wx = ox;
3802
+ let wy = oy;
3803
+ let wz = oz;
3804
+ let ownYaw = yaw;
3805
+ if (Array.isArray(n.position)) {
3806
+ const c = Math.cos(yaw);
3807
+ const s = Math.sin(yaw);
3808
+ const lx = n.position[0] ?? 0;
3809
+ const lz = n.position[2] ?? 0;
3810
+ wx = ox + lx * c + lz * s;
3811
+ wz = oz - lx * s + lz * c;
3812
+ wy = oy + (n.position[1] ?? 0);
3813
+ ownYaw = yaw + (n.rotation?.[1] ?? 0) * Math.PI / 180;
3814
+ }
3815
+ const type = node.constructor.typeName ?? "";
3816
+ const soil = ground;
3817
+ if (types.has(type) && n.visible !== false && n.snapToGround !== false && !bodyOwned) {
3818
+ const rows = node.transforms;
3819
+ if (Array.isArray(rows) && rows.length > 0) {
3820
+ let worst = null;
3821
+ for (const row of rows) {
3822
+ const scale = row[4] ?? 1;
3823
+ const half = (n.size?.[1] ?? 0) * scale / 2;
3824
+ const gap = wy + (row[1] ?? 0) - half - soil.heightAt(wx + (row[0] ?? 0), wz + (row[2] ?? 0));
3825
+ if (Math.abs(gap) > tolerance && (!worst || Math.abs(gap) > Math.abs(worst.gap))) worst = {
3826
+ path,
3827
+ gap: Math.round(gap * 100) / 100
3828
+ };
3829
+ }
3830
+ if (worst) out.push(worst);
3831
+ } else {
3832
+ const half = (n.size?.[1] ?? 0) / 2;
3833
+ const gap = wy - half - soil.heightAt(wx, wz);
3834
+ if (Math.abs(gap) > tolerance) out.push({
3835
+ path,
3836
+ gap: Math.round(gap * 100) / 100
3837
+ });
3838
+ }
3839
+ }
3840
+ const childBodyOwned = bodyOwned || BODY_OWNED.has(type) || n.snapToGround === false;
3841
+ for (const child of node.children) walk(child, `${path}/${child.name}`, wx, wy, wz, ownYaw, childBodyOwned);
3842
+ };
3843
+ walk(scene.root, `/${scene.root.name}`, 0, 0, 0, 0, false);
3844
+ return out;
3845
+ }
3846
+ function registerAllNodes() {
3847
+ registerNodes2D();
3848
+ registerNodes3D();
3849
+ registerNodesNet();
3850
+ registerGameplayBehaviors();
3851
+ }
3852
+ /** Snapshot the live tree: every node, every schema prop's current value. */
3853
+ function captureScene(scene) {
3854
+ const nodes = [];
3855
+ const walk = (node) => {
3856
+ const ctor = node.constructor;
3857
+ const schema = mergeStaticProps(ctor);
3858
+ const props = {};
3859
+ for (const key of Object.keys(schema)) props[key] = jsonClone(node[key]);
3860
+ const capture = {
3861
+ path: node.getPath(),
3862
+ type: ctor.typeName,
3863
+ props
3864
+ };
3865
+ const painted = node._paintedText?.();
3866
+ if (typeof painted === "string") capture.text = painted;
3867
+ if (node.uid) capture.uid = node.uid;
3868
+ if (node.groups.size > 0) capture.groups = [...node.groups];
3869
+ if (Object.keys(node.tags).length > 0) capture.tags = jsonClone(node.tags);
3870
+ if (typeof node.script?.name === "string") capture.script = node.script.name;
3871
+ nodes.push(capture);
3872
+ for (const child of node.children) walk(child);
3873
+ };
3874
+ walk(scene.root);
3875
+ const out = {
3876
+ name: scene.name,
3877
+ nodes
3878
+ };
3879
+ if (scene.dimension) out.dimension = scene.dimension;
3880
+ return out;
3881
+ }
3882
+ /**
3883
+ * Render a capture as one grep-able line per node, showing only NON-DEFAULT
3884
+ * props — the same delta discipline as scene JSON, so the interesting state
3885
+ * stands out.
3886
+ */
3887
+ function describeCapture(capture) {
3888
+ const lines = [`scene ${capture.name}${capture.dimension ? ` (${capture.dimension})` : ""}`];
3889
+ for (const node of capture.nodes) {
3890
+ const schema = defaultsFor(node.type);
3891
+ const deltas = Object.entries(node.props).filter(([key, value]) => !schema || !jsonEquals(value, schema[key]?.default ?? null)).map(([key, value]) => `${key}=${JSON.stringify(value)}`);
3892
+ const extras = [
3893
+ node.text !== void 0 ? `paints=${JSON.stringify(node.text)}` : "",
3894
+ node.script ? `script=${node.script}` : "",
3895
+ node.groups ? `groups=${node.groups.join(",")}` : "",
3896
+ ...deltas
3897
+ ].filter(Boolean);
3898
+ lines.push(`${node.path} ${node.type}${extras.length ? ` ${extras.join(" ")}` : ""}`);
3899
+ }
3900
+ return lines.join("\n");
3901
+ }
3902
+ function defaultsFor(typeName) {
3903
+ try {
3904
+ return getNodeSchema(typeName);
3905
+ } catch {
3906
+ return null;
3907
+ }
3908
+ }
3909
+ /**
3910
+ * Run every hard load-time check headlessly. Unregistered behaviors are
3911
+ * stubbed by default (structure-only validation, no TypeScript needed) —
3912
+ * pass the real classes via `behaviors` to validate script props too.
3913
+ */
3914
+ /**
3915
+ * `environment.preset` is resolved deep inside Renderer3D, which no headless
3916
+ * tool reaches — so a typo there passed `incanto-check` and hard-failed at boot.
3917
+ * It cannot live in the core loader (core must never import 3d), but it belongs
3918
+ * exactly here: the test entry already registers the 3D nodes.
3919
+ */
3920
+ function checkEnvironment(json) {
3921
+ const env = json?.environment;
3922
+ if (!env || typeof env !== "object") return;
3923
+ if (env.preset === void 0) return;
3924
+ if (typeof env.preset !== "string") throw new IncantoError("BAD_FORMAT", `Scene "environment.preset" must be a string, got ${JSON.stringify(env.preset)}.`);
3925
+ try {
3926
+ resolveEnvironmentHdri(env);
3927
+ } catch (error) {
3928
+ throw new IncantoError("BAD_FORMAT", error instanceof Error ? error.message : String(error));
3929
+ }
3930
+ }
3931
+ function validateScene(json, opts = {}) {
3932
+ registerAllNodes();
3933
+ for (const [name, ctor] of Object.entries(opts.behaviors ?? {})) registerBehavior(name, ctor, { replace: true });
3934
+ const engine = new Engine({ scheduler: () => () => {} });
3935
+ try {
3936
+ checkEnvironment(json);
3937
+ loadScene(json, {
3938
+ engine,
3939
+ resolveScene: opts.resolveScene,
3940
+ ...opts.declaredAssets ? { declaredAssets: opts.declaredAssets } : {},
3941
+ stubMissingBehaviors: !opts.strictBehaviors
3942
+ }).root.free();
3943
+ return { ok: true };
3944
+ } catch (e) {
3945
+ if (e instanceof IncantoError) return {
3946
+ ok: false,
3947
+ error: e
3948
+ };
3949
+ throw e;
3950
+ }
3951
+ }
3952
+ /**
3953
+ * What an assert's return value MEANS: a failure message, or null for a pass.
3954
+ *
3955
+ * `false` and a throw were the only failures this API had, so an assert that
3956
+ * returned a MESSAGE — the natural way to write one that explains itself —
3957
+ * passed by not being `false`. Measured on a tower defense built from the
3958
+ * published package: four asserts of the form `(ctx) => problem ?? null`, a
3959
+ * keep that had fallen with 0 lives, and `VERIFY OK`.
3960
+ *
3961
+ * A number or an object is a failure too, naming the contract: a check nobody
3962
+ * can read is not a check, and `0` and `{ ok: false }` both used to pass.
3963
+ */
3964
+ function assertVerdict(value) {
3965
+ if (value === false) return "assert returned false";
3966
+ if (typeof value === "string") return value === "" ? null : value;
3967
+ if (value === true || value === void 0 || value === null) return null;
3968
+ return `assert returned ${typeof value} (${JSON.stringify(value)}) — this API reads only \`false\`, a non-empty string, or a throw as a failure, so this check cannot fail. Return a boolean or a message.`;
3969
+ }
3970
+ /**
3971
+ * Load a scene and play it headlessly at a fixed timestep, driving input by
3972
+ * ACTION intent. This is the e2e loop without a browser: script → simulate →
3973
+ * snapshot → assert.
3974
+ */
3975
+ async function runScript(json, opts) {
3976
+ registerAllNodes();
3977
+ for (const [name, ctor] of Object.entries(opts.behaviors ?? {})) registerBehavior(name, ctor, { replace: true });
3978
+ const engine = new Engine({
3979
+ seed: opts.seed,
3980
+ fixedHz: opts.fixedHz,
3981
+ scheduler: () => () => {}
3982
+ });
3983
+ const scene = loadScene(structuredClone(json), {
3984
+ engine,
3985
+ resolveScene: opts.resolveScene
3986
+ });
3987
+ engine.setScene(scene);
3988
+ if (opts.locale) engine.locale.locale = opts.locale;
3989
+ const physics = opts.physics ?? "auto";
3990
+ if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
3991
+ const { enablePhysics2D } = await import("./physics-2d-FhrXUc6B.js").then((n) => n.r);
3992
+ await enablePhysics2D(engine);
3993
+ } else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
3994
+ const { enablePhysics3D } = await import("./physics-3d-CFEGxBXZ.js").then((n) => n.r);
3995
+ await enablePhysics3D(engine);
3996
+ }
3997
+ const failures = [];
3998
+ const snapshots = [];
3999
+ const live = () => engine.scene ?? scene;
4000
+ const ctx = {
4001
+ engine,
4002
+ get scene() {
4003
+ return live();
4004
+ },
4005
+ timeMs: 0,
4006
+ getNode: (path) => live().root.getNode(path),
4007
+ capture: () => captureScene(live())
4008
+ };
4009
+ /** Is `hit` the node we aimed at, or something of its own? */
4010
+ const isSelfOrAncestor = (wanted, hit) => {
4011
+ for (let at = hit; at; at = at.parent) if (at === wanted) return true;
4012
+ return false;
4013
+ };
4014
+ /** Frames on which a queued mouse release is due — see `ScriptStep.click`. */
4015
+ let releaseAt = -1;
4016
+ /** The button `click` pressed, so the queued release lets go of the same one. */
4017
+ let releaseButton = 0;
4018
+ /**
4019
+ * Only ours, never theirs: a game that answers picks itself must keep
4020
+ * answering them, or the harness is testing the harness. Aiming comes with
4021
+ * it — a behaviour reading `engine.pointerWorld()` must get an answer for the
4022
+ * same cursor the click is at.
4023
+ */
4024
+ const armPointer = () => {
4025
+ engine.picker ??= geometricPicker(live);
4026
+ if (!engine.toWorld) {
4027
+ const view = geometricView(live);
4028
+ engine.toWorld = view.toWorld;
4029
+ engine.toScreen ??= view.toScreen;
4030
+ engine.screenRay ??= view.screenRay;
4031
+ }
4032
+ };
4033
+ /**
4034
+ * A HUD widget the click names, or null — a click on the HUD is not a click
4035
+ * in the world.
4036
+ *
4037
+ * `click: 'Hud/Buy'` used to fail with `resolves to no node — nothing to
4038
+ * point at`, about a node the same run's `describe()` prints. A widget is not
4039
+ * a `Node2D`, so it has no world position, and `pointAtNode` answers null the
4040
+ * same way it does for a path that matches nothing.
4041
+ *
4042
+ * That message was the small half. The large half is that an entire family of
4043
+ * games — a menu, a shop, a settings screen, an idle game, a card game — is
4044
+ * played by pressing `UiButton`s, and the documented verb for pressing things
4045
+ * could not press one. `UiButton.press()` has always been public and says
4046
+ * "same path as a click (tests, gamepad menus)"; nothing routed a click to it.
4047
+ */
4048
+ const widgetAt = (target) => {
4049
+ if (typeof target !== "string") return null;
4050
+ const node = live().root.getNodeOrNull(target);
4051
+ return node instanceof HudWidgetBase ? node : null;
4052
+ };
4053
+ /**
4054
+ * Can a player see it? A widget under a hidden panel is not on screen, and a
4055
+ * click a player could not make is a harness testing itself.
4056
+ */
4057
+ const widgetIsShown = (widget) => {
4058
+ for (let at = widget; at; at = at.parent) {
4059
+ if (at instanceof HudWidgetBase && !at.visible) return false;
4060
+ if (at instanceof HudLayer) return at.visible;
4061
+ }
4062
+ return false;
4063
+ };
4064
+ /** Resolve a step's cursor target and move there; false = it resolved to nothing. */
4065
+ const moveTo = (target, step, what) => {
4066
+ const resolved = typeof target === "function" ? target(ctx) : target;
4067
+ if (resolved === null) return null;
4068
+ const where = pointOf(resolved);
4069
+ if (!where) {
4070
+ failures.push({
4071
+ atMs: step.atMs,
4072
+ label: step.label,
4073
+ message: `${what} target '${String(resolved)}' resolves to no node — nothing to point at`
4074
+ });
4075
+ return false;
4076
+ }
4077
+ armPointer();
4078
+ engine.input.setPointerPosition(where[0], where[1]);
4079
+ return true;
4080
+ };
4081
+ const applyStep = (step, tick) => {
4082
+ if (step.press) engine.input.pressAction(step.press);
4083
+ if (step.release) engine.input.releaseAction(step.release);
4084
+ if (step.vector) engine.input.setActionVector(...step.vector);
4085
+ if (step.key) engine.input.handleKey(step.key[0], step.key[1]);
4086
+ if (step.at !== void 0) moveTo(step.at, step, "at");
4087
+ if (step.mouse !== void 0) {
4088
+ armPointer();
4089
+ engine.input.handleMouseButton(step.button ?? 0, step.mouse === "down");
4090
+ }
4091
+ const widget = step.click === void 0 ? null : widgetAt(step.click);
4092
+ if (widget) if (!widgetIsShown(widget)) failures.push({
4093
+ atMs: step.atMs,
4094
+ label: step.label,
4095
+ message: `click target '${String(step.click)}' is a HUD widget nobody can see — it or something above it has \`visible: false\`, so a player could not press it`
4096
+ });
4097
+ else widget._click();
4098
+ else if (step.click !== void 0 && moveTo(step.click, step, "click") === true) {
4099
+ const named = typeof step.click === "string" ? step.click : null;
4100
+ if (named !== null) {
4101
+ const at = engine.input.pointerPosition();
4102
+ const under = at ? engine.pickAt(at.x, at.y) : null;
4103
+ const wanted = live().root.getNodeOrNull(named);
4104
+ if (wanted && (!under || !isSelfOrAncestor(wanted, under))) failures.push({
4105
+ atMs: step.atMs,
4106
+ label: step.label,
4107
+ message: `clicked '${named}' and the picker found ${under ? `'${under.getPath()}'` : "nothing"} there. A node is pickable when something can measure it: a \`size\` prop, a collider, or a spritesheet asset with \`frameWidth\`/\`frameHeight\`. A \`Sprite2D\` takes its size from the decoded texture, which headless never exists — give it a collider, or click the node that has the shape.`
4108
+ });
4109
+ }
4110
+ releaseButton = step.button ?? 0;
4111
+ engine.input.handleMouseButton(releaseButton, true);
4112
+ releaseAt = tick + 1;
4113
+ }
4114
+ step.do?.(ctx);
4115
+ if (step.assert) try {
4116
+ const verdict = assertVerdict(step.assert(ctx));
4117
+ if (verdict) failures.push({
4118
+ atMs: step.atMs,
4119
+ label: step.label,
4120
+ message: verdict
4121
+ });
4122
+ } catch (e) {
4123
+ failures.push({
4124
+ atMs: step.atMs,
4125
+ label: step.label,
4126
+ message: e instanceof Error ? e.message : String(e)
4127
+ });
4128
+ }
4129
+ };
4130
+ const stepMs = 1e3 / (opts.fixedHz ?? 60);
4131
+ const totalTicks = Math.round(opts.durationMs / stepMs);
4132
+ const steps = [...opts.steps ?? []].sort((a, b) => a.atMs - b.atMs);
4133
+ let stepIndex = 0;
4134
+ let nextSnapshot = opts.snapshotEveryMs ?? Number.POSITIVE_INFINITY;
4135
+ for (let i = 1; i <= totalTicks; i++) {
4136
+ const t = i * stepMs;
4137
+ ctx.timeMs = t;
4138
+ while (stepIndex < steps.length && steps[stepIndex].atMs <= t) {
4139
+ applyStep(steps[stepIndex], i);
4140
+ stepIndex += 1;
4141
+ }
4142
+ if (releaseAt === i) {
4143
+ engine.input.handleMouseButton(releaseButton, false);
4144
+ releaseAt = -1;
4145
+ }
4146
+ engine.step();
4147
+ if (t >= nextSnapshot - 1e-6) {
4148
+ snapshots.push({
4149
+ atMs: Math.round(t),
4150
+ capture: captureScene(live())
4151
+ });
4152
+ nextSnapshot += opts.snapshotEveryMs;
4153
+ }
4154
+ }
4155
+ /** Where a `click` step points: a node, or a literal point. */
4156
+ function pointOf(target) {
4157
+ return Array.isArray(target) ? target : pointAtNode(live(), target);
4158
+ }
4159
+ for (; stepIndex < steps.length; stepIndex++) {
4160
+ const missed = steps[stepIndex];
4161
+ failures.push({
4162
+ atMs: missed.atMs,
4163
+ label: missed.label,
4164
+ message: `step never executed — atMs ${missed.atMs} is beyond the ${opts.durationMs ?? 0}ms run`
4165
+ });
4166
+ }
4167
+ const finalCapture = captureScene(live());
4168
+ const logs = [...engine.log.entries()];
4169
+ const simulatedMs = Math.round(engine.time * 1e3);
4170
+ const swallowed = engine.stats().errors;
4171
+ if (swallowed > 0) failures.push({
4172
+ atMs: opts.durationMs,
4173
+ label: "engine",
4174
+ message: `${swallowed} error(s) were swallowed to keep the loop alive — the logs above name the node, the behavior and the phase`
4175
+ });
4176
+ engine.dispose();
4177
+ const ok = failures.length === 0;
4178
+ return {
4179
+ ok,
4180
+ failures,
4181
+ snapshots,
4182
+ logs,
4183
+ finalCapture,
4184
+ describe: () => {
4185
+ const engineWarnings = logs.filter((l) => l.level === "warn" || l.level === "error");
4186
+ const budget = Math.abs(simulatedMs - opts.durationMs) > Math.max(50, opts.durationMs * .02) ? ` (of ${opts.durationMs}ms of steps — the game clock ran slower, or stopped)` : "";
4187
+ const head = `run ${ok ? "OK" : "FAILED"} — ${simulatedMs}ms simulated${budget}, ${failures.length} failure(s), ${logs.length} log(s)`;
4188
+ const fails = failures.map((f) => ` ✗ at ${f.atMs}ms${f.label ? ` [${f.label}]` : ""}: ${f.message}`);
4189
+ const noisy = engineWarnings.map((l) => ` ! ${l.level}: ${l.parts.map((p) => typeof p === "string" ? p : String(p)).join(" ")}`);
4190
+ return [
4191
+ head,
4192
+ ...fails,
4193
+ ...noisy,
4194
+ describeCapture(finalCapture)
4195
+ ].join("\n");
4196
+ }
4197
+ };
4198
+ }
4199
+ /**
4200
+ * The headless play loop behind `incanto-play`: load → feed inputs by intent
4201
+ * (`session.engine.input.pressAction(...)`) → `step(ms)` → `capture()` the
4202
+ * state AS A SCENE FILE. What a screenshot is to humans, the capture is to
4203
+ * agents: complete, structured, and in the exact format they already read.
4204
+ */
4205
+ async function createPlaySession(json, opts = {}) {
4206
+ registerAllNodes();
4207
+ for (const [name, ctor] of Object.entries(opts.behaviors ?? {})) registerBehavior(name, ctor, { replace: true });
4208
+ const engine = new Engine({
4209
+ seed: opts.seed,
4210
+ fixedHz: opts.fixedHz,
4211
+ scheduler: () => () => {}
4212
+ });
4213
+ const scene = loadScene(structuredClone(json), {
4214
+ resolveScene: opts.resolveScene,
4215
+ stubMissingBehaviors: opts.stubMissingBehaviors,
4216
+ engine
4217
+ });
4218
+ engine.setScene(scene);
4219
+ const physics = opts.physics ?? "auto";
4220
+ if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
4221
+ const { enablePhysics2D } = await import("./physics-2d-FhrXUc6B.js").then((n) => n.r);
4222
+ await enablePhysics2D(engine);
4223
+ } else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
4224
+ const { enablePhysics3D } = await import("./physics-3d-CFEGxBXZ.js").then((n) => n.r);
4225
+ await enablePhysics3D(engine);
4226
+ }
4227
+ const stepMs = 1e3 / (opts.fixedHz ?? 60);
4228
+ let timeMs = 0;
4229
+ let logCursor = 0;
4230
+ return {
4231
+ engine,
4232
+ get scene() {
4233
+ return engine.scene ?? scene;
4234
+ },
4235
+ get timeMs() {
4236
+ return timeMs;
4237
+ },
4238
+ step(ms) {
4239
+ const ticks = Math.max(1, Math.round(ms / stepMs));
4240
+ for (let i = 0; i < ticks; i++) engine.step();
4241
+ timeMs += ticks * stepMs;
4242
+ },
4243
+ capture() {
4244
+ return (engine.scene ?? scene).toJSON();
4245
+ },
4246
+ describe() {
4247
+ return describeCapture(captureScene(engine.scene ?? scene));
4248
+ },
4249
+ framing() {
4250
+ return framingText(describeFraming(engine.scene ?? scene));
4251
+ },
4252
+ drainLogs() {
4253
+ const all = engine.log.entries();
4254
+ const fresh = all.filter((e) => e.seq > logCursor);
4255
+ if (all.length > 0) logCursor = all[all.length - 1].seq;
4256
+ return [...fresh];
4257
+ },
4258
+ dispose() {
4259
+ engine.dispose();
4260
+ }
4261
+ };
4262
+ }
4263
+ //#endregion
4264
+ export { geometricPicker as A, facingReport as C, playtest as D, findPlayer as E, sceneAssetUrls as F, pointAtNode as M, describeFraming as N, playtestFailed as O, framingText as P, collectionProblems as S, failingReplays as T, feelReport as _, registerAllNodes as a, takeCollectionSnapshots as b, feelsRung as c, ladderVerdict as d, saysRung as f, playMultiplayer as g, multiplayText as h, findFloatingProps as i, geometricView as j, playtestText as k, firstLine as l, multiplayProblems as m, createPlaySession as n, runScript as o, unmeasuredPlays as p, describeCapture as r, validateScene as s, captureScene as t, ladderText as u, feelText as v, facingText as w, agreementProblems as x, takeAgreementSnapshots as y };