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
@@ -1,2383 +0,0 @@
1
- import { f as resolveViewport, n as loadScene, z as registerBehavior } from "./loader-DZE7B07H.js";
2
- import { v as Engine } from "./register-mmfugpaQ.js";
3
- import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
- import { n as jsonEquals, t as jsonClone } from "./json-CwwhxQgb.js";
5
- import { i as getNodeSchema, s as mergeStaticProps } from "./registry-CF70EArN.js";
6
- import { n as startRecording } from "./replay-BEHLDcQV.js";
7
- import { n as registerGameplayBehaviors } from "./gameplay-D1KixWK8.js";
8
- import { t as registerNodes2D } from "./register-DbVArsns.js";
9
- import { n as registerNodes3D, t as resolveEnvironmentHdri } from "./environment-presets-BO-7XoFV.js";
10
- 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-CauMwu6y.js";
11
- import { Box3, Euler, Matrix4, PerspectiveCamera, Quaternion, Vector3 } from "three";
12
- //#region src/test/framing.ts
13
- /**
14
- * What the camera is actually looking at — without a GPU.
15
- *
16
- * Nearly every decision in a 3D game is visual: is the camera framing the
17
- * island, is anything lit, is the gem buried inside the platform. An agent
18
- * building a game could answer none of them. `captureScene()` lists nodes and
19
- * their props, which tells you a gem is at `[4, 1, -2]` and nothing whatsoever
20
- * about whether you can SEE it — and the engine ships no pixel output at all,
21
- * so "look at it" was a human's job.
22
- *
23
- * This is the geometry half of the answer, and it needs no renderer, no canvas
24
- * and no browser: the camera's own matrices, the world transforms composed from
25
- * the tree, and the colliders already declared in the JSON.
26
- *
27
- * What it measures: the node's ORIGIN, widened to its declared `size` (a
28
- * MeshInstance3D's box, a Terrain3D's ground) or its collider when it has one.
29
- * A 200 m island whose origin is off to the left still counts as on screen,
30
- * because its box is. For a node that declares neither, read `offscreen` as
31
- * "its origin is off-screen" — a loaded GLB's real silhouette needs the model,
32
- * and the model needs a GPU.
33
- */
34
- const DEG = Math.PI / 180;
35
- const CAMERA_TYPES = new Set(["Camera3D"]);
36
- const LIGHT_TYPES = new Set([
37
- "DirectionalLight3D",
38
- "OmniLight3D",
39
- "SpotLight3D"
40
- ]);
41
- /** Screen-space subtrees: they ignore the camera, so framing has nothing to say. */
42
- const SCREEN_SPACE = new Set(["UILayer", "HudLayer"]);
43
- /** Depth a 2D collider is given on the axis it does not have. */
44
- const FLAT_DEPTH = 1e3;
45
- /** How far two colliders must actually interpenetrate to be a finding. */
46
- const TOUCH_EPS_2D = 1;
47
- const TOUCH_EPS_3D = .01;
48
- /** What a 2D scene shows when it declares neither a viewport nor a canvas. */
49
- const DEFAULT_VIEW_2D = [960, 540];
50
- /**
51
- * World transform for every node, composed from the tree.
52
- *
53
- * NOT read off `_object3D`: that is the renderer's copy, and headless there may
54
- * be no renderer to have written it. The props are the truth here.
55
- */
56
- function place(scene) {
57
- const out = [];
58
- const walk = (node, parent) => {
59
- const type = node.constructor.typeName ?? "Node";
60
- const n = node;
61
- const local = new Matrix4();
62
- if (Array.isArray(n.position)) {
63
- const r = n.rotation;
64
- 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");
65
- 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));
66
- }
67
- const matrix = new Matrix4().multiplyMatrices(parent, local);
68
- out.push({
69
- node,
70
- type,
71
- matrix,
72
- position: new Vector3().setFromMatrixPosition(matrix)
73
- });
74
- if (SCREEN_SPACE.has(type)) return;
75
- for (const child of node.children) walk(child, matrix);
76
- };
77
- walk(scene.root, new Matrix4());
78
- return out;
79
- }
80
- /**
81
- * A node draws something if its class builds its own three object — OR hangs
82
- * one off the inherited container.
83
- *
84
- * The second half is not a nicety. `Particles3D` builds its `Points` in
85
- * `_syncObject3D`, like every 2D drawable does, so a create-hook test alone
86
- * reported every explosion, every trail of embers and every bit of magic in a
87
- * 3D scene as drawing nothing. `draws2D` below already carries this warning for
88
- * exactly the same reason; 3D had one node shaped like the 2D ones and missed
89
- * it.
90
- */
91
- function draws(node) {
92
- const proto = Object.getPrototypeOf(node);
93
- let base = proto;
94
- while (base && base.constructor?.typeName !== "Node3D") {
95
- base = Object.getPrototypeOf(base);
96
- if (!base) return false;
97
- }
98
- return HOOKS_3D.some((hook) => proto[hook] !== base[hook]);
99
- }
100
- /** Either hook can be where a node's geometry comes from. */
101
- const HOOKS_3D = ["_createObject3D", "_syncObject3D"];
102
- /**
103
- * A 2D node draws something if its class puts pixels on its backing object.
104
- *
105
- * NOT `_createObject2D` — the 3D test's counterpart, and wrong here. 2D
106
- * drawables all inherit the plain container and hang a quad off it in
107
- * `_syncObject2D`, so the create hook is identical for a sprite and for a bare
108
- * `Node2D`, and testing it reports a scene full of sprites as drawing nothing.
109
- */
110
- function draws2D(node) {
111
- const proto = Object.getPrototypeOf(node);
112
- let base = proto;
113
- while (base && base.constructor?.typeName !== "Node2D") {
114
- base = Object.getPrototypeOf(base);
115
- if (!base) return false;
116
- }
117
- return proto._syncObject2D !== base._syncObject2D;
118
- }
119
- /**
120
- * A 2D node's half-size in pixels, and where its box hangs off the origin.
121
- *
122
- * Most 2D nodes are centred on their origin, but a `TileMap2D` hangs cell (0,0)
123
- * by its TOP-LEFT corner — a whole level reported as a point at its corner is
124
- * how you conclude the map is off screen when you are standing on it.
125
- */
126
- function box2D(node) {
127
- if ((node.constructor.typeName ?? "") === "TileMap2D") {
128
- const map = node;
129
- const rows = Array.isArray(map.cells) ? map.cells : [];
130
- const cols = rows.reduce((n, r) => Math.max(n, String(r).length), 0);
131
- const ts = map.tileSize ?? 32;
132
- if (rows.length === 0 || cols === 0) return null;
133
- const half = [cols * ts / 2, rows.length * ts / 2];
134
- return {
135
- half,
136
- centre: half
137
- };
138
- }
139
- const size = node.size;
140
- if (Array.isArray(size) && typeof size[0] === "number" && typeof size[1] === "number") return {
141
- half: [Math.abs(size[0]) / 2, Math.abs(size[1]) / 2],
142
- centre: [0, 0]
143
- };
144
- const half = halfExtents(node);
145
- return half ? {
146
- half: [half.x, half.y],
147
- centre: [0, 0]
148
- } : null;
149
- }
150
- /**
151
- * How big the node is on screen, from whatever it declares.
152
- *
153
- * `size` first (a MeshInstance3D's box, a Terrain3D's ground), then a collider.
154
- * This is the FRAMING question — "does this thing fill the view" — and a mesh's
155
- * own size answers it better than the body wrapped around it.
156
- */
157
- function extentOf(node) {
158
- const size = node.size;
159
- if (Array.isArray(size) && size.length >= 2 && typeof size[0] === "number") {
160
- const x = size[0] / 2;
161
- const y = typeof size[1] === "number" && size.length >= 3 ? size[1] / 2 : x;
162
- const z = (size[size.length - 1] ?? size[0]) / 2;
163
- return new Vector3(Math.abs(x), Math.abs(y), Math.abs(z));
164
- }
165
- return halfExtents(node);
166
- }
167
- /**
168
- * A COLLIDER's half-extents, when the node declares one.
169
- *
170
- * Colliders live on bodies, not meshes — which is exactly right for the
171
- * overlap report: "the gem is inside the platform" is a question about the
172
- * physical shapes, and two meshes touching (a floor meeting a wall) is not a
173
- * finding.
174
- */
175
- function halfExtents(node) {
176
- const collider = node.collider;
177
- if (!collider || typeof collider !== "object") return null;
178
- const shape = collider.shape;
179
- if (shape === "box") {
180
- const size = collider.size ?? [
181
- 1,
182
- 1,
183
- 1
184
- ];
185
- return new Vector3((size[0] ?? 1) / 2, (size[1] ?? 1) / 2, (size[2] ?? 1) / 2);
186
- }
187
- if (shape === "sphere") {
188
- const r = collider.radius ?? .5;
189
- return new Vector3(r, r, r);
190
- }
191
- if (shape === "capsule" || shape === "cylinder") {
192
- const r = collider.radius ?? .5;
193
- return new Vector3(r, (collider.height ?? 1) / 2 + r, r);
194
- }
195
- if (shape === "rect") {
196
- const size = collider.size ?? [1, 1];
197
- return new Vector3((size[0] ?? 1) / 2, (size[1] ?? 1) / 2, .5);
198
- }
199
- if (shape === "circle") {
200
- const r = collider.radius ?? .5;
201
- return new Vector3(r, r, .5);
202
- }
203
- return null;
204
- }
205
- /**
206
- * The same question in 2D: an orthographic window of design pixels, centred on
207
- * the Camera2D and CLAMPED by its limits.
208
- *
209
- * The clamp is not a detail — it is the whole point of `limits`, and a report
210
- * that skips it describes a view the renderer never draws. A following camera
211
- * sits at the player's y, which in a 544 px world is hundreds of pixels past
212
- * anything a 540 px window can show.
213
- *
214
- * With no camera at all a 2D scene still draws: the documented default view is
215
- * `(0,0)`–`(w,h)`, so "no camera" is not "nothing is framed" the way it is in 3D.
216
- */
217
- function describeFraming2D(scene, placed, opts) {
218
- const design = resolveViewport(scene.viewport)?.design;
219
- const [vw, vh] = opts.viewport ?? design ?? DEFAULT_VIEW_2D;
220
- const cameras = placed.filter((p) => p.type === "Camera2D");
221
- const chosen = cameras.find((p) => p.node.current === true) ?? cameras[0] ?? null;
222
- let camera = null;
223
- let view = {
224
- minX: 0,
225
- minY: 0,
226
- maxX: vw,
227
- maxY: vh
228
- };
229
- if (chosen) {
230
- const cam = chosen.node;
231
- const zoom = Math.max(.01, cam.zoom ?? 1);
232
- const halfW = vw / (2 * zoom);
233
- const halfH = vh / (2 * zoom);
234
- let cx = chosen.position.x;
235
- let cy = chosen.position.y;
236
- const limits = cam.limits ?? [];
237
- if (limits.length === 4) {
238
- const [minX, minY, maxX, maxY] = limits;
239
- cx = clampCentred(cx, minX + halfW, maxX - halfW);
240
- cy = clampCentred(cy, minY + halfH, maxY - halfH);
241
- }
242
- view = {
243
- minX: cx - halfW,
244
- minY: cy - halfH,
245
- maxX: cx + halfW,
246
- maxY: cy + halfH
247
- };
248
- camera = {
249
- path: chosen.node.getPath(),
250
- at: [
251
- round(cx),
252
- round(cy),
253
- 0
254
- ],
255
- view: [round(vw / zoom), round(vh / zoom)],
256
- zoom
257
- };
258
- }
259
- const wanted = opts.types ? new Set(opts.types) : null;
260
- const centre = [(view.minX + view.maxX) / 2, (view.minY + view.maxY) / 2];
261
- const entries = [];
262
- for (const p of placed) {
263
- if (p.type === "Camera2D") continue;
264
- if (!(wanted ? wanted.has(p.type) : draws2D(p.node))) continue;
265
- const b = box2D(p.node);
266
- const cx = p.position.x + (b?.centre[0] ?? 0);
267
- const cy = p.position.y + (b?.centre[1] ?? 0);
268
- const hx = b?.half[0] ?? 0;
269
- const hy = b?.half[1] ?? 0;
270
- const onScreen = cx + hx >= view.minX && cx - hx <= view.maxX && cy + hy >= view.minY && cy - hy <= view.maxY;
271
- entries.push({
272
- path: p.node.getPath(),
273
- type: p.type,
274
- at: [
275
- round(p.position.x),
276
- round(p.position.y),
277
- 0
278
- ],
279
- where: onScreen ? "onScreen" : "offscreen",
280
- screen: [round((cx - centre[0]) / ((view.maxX - view.minX) / 2)), round((cy - centre[1]) / ((view.maxY - view.minY) / 2))],
281
- distance: round(Math.hypot(cx - centre[0], cy - centre[1]))
282
- });
283
- }
284
- return {
285
- dimension: "2d",
286
- camera,
287
- entries,
288
- lights: [],
289
- overlaps: findOverlaps(placed, TOUCH_EPS_2D)
290
- };
291
- }
292
- function clampCentred(v, lo, hi) {
293
- if (lo > hi) return (lo + hi) / 2;
294
- return Math.min(hi, Math.max(lo, v));
295
- }
296
- function describeFraming(scene, opts = {}) {
297
- const placed = place(scene);
298
- if (scene.dimension === "2d") return describeFraming2D(scene, placed, opts);
299
- const cameras = placed.filter((p) => CAMERA_TYPES.has(p.type));
300
- const chosen = cameras.find((p) => p.node.current === true) ?? cameras[0] ?? null;
301
- const lights = [];
302
- for (const p of placed) if (LIGHT_TYPES.has(p.type)) lights.push(`${p.node.getPath()} (${p.type})`);
303
- const env = scene.environment;
304
- if (env) {
305
- if (env.preset !== void 0) lights.push(`environment.preset = ${JSON.stringify(env.preset)}`);
306
- if (env.sky !== void 0) lights.push("environment.sky");
307
- if (env.hdri !== void 0) lights.push("environment.hdri");
308
- const ambient = env.ambient;
309
- if (typeof ambient?.intensity === "number" && ambient.intensity > 0) lights.push(`environment.ambient.intensity = ${ambient.intensity}`);
310
- }
311
- const wanted = opts.types ? new Set(opts.types) : null;
312
- const subjects = placed.filter((p) => !CAMERA_TYPES.has(p.type) && !LIGHT_TYPES.has(p.type) && (wanted ? wanted.has(p.type) : draws(p.node)));
313
- const entries = [];
314
- let camera = null;
315
- if (chosen) {
316
- const node = chosen.node;
317
- const cam = new PerspectiveCamera(node.fov ?? 60, opts.aspect ?? 16 / 9, node.near ?? .1, node.far ?? 1e3);
318
- cam.matrixWorld.copy(chosen.matrix);
319
- cam.matrixWorldInverse.copy(chosen.matrix).invert();
320
- cam.updateProjectionMatrix();
321
- const forward = new Vector3(0, 0, -1).applyQuaternion(new Quaternion().setFromRotationMatrix(chosen.matrix));
322
- camera = {
323
- path: chosen.node.getPath(),
324
- at: chosen.position.toArray(),
325
- forward: [
326
- round(forward.x),
327
- round(forward.y),
328
- round(forward.z)
329
- ],
330
- fovDeg: node.fov ?? 60
331
- };
332
- for (const p of subjects) {
333
- const view = p.position.clone().applyMatrix4(cam.matrixWorldInverse);
334
- const distance = p.position.distanceTo(chosen.position);
335
- if (view.z > -(node.near ?? .1)) {
336
- entries.push({
337
- path: p.node.getPath(),
338
- type: p.type,
339
- at: p.position.toArray(),
340
- where: "behind",
341
- distance: round(distance)
342
- });
343
- continue;
344
- }
345
- const ndc = p.position.clone().project(cam);
346
- let onScreen = Math.abs(ndc.x) <= 1 && Math.abs(ndc.y) <= 1;
347
- if (!onScreen) {
348
- const half = extentOf(p.node);
349
- if (half) onScreen = cornersOnScreen(new Box3().setFromCenterAndSize(p.position, half.clone().multiplyScalar(2)), cam);
350
- }
351
- entries.push({
352
- path: p.node.getPath(),
353
- type: p.type,
354
- at: p.position.toArray(),
355
- where: onScreen ? "onScreen" : "offscreen",
356
- screen: [round(ndc.x), round(ndc.y)],
357
- distance: round(distance)
358
- });
359
- }
360
- }
361
- return {
362
- dimension: "3d",
363
- camera,
364
- entries,
365
- lights,
366
- overlaps: findOverlaps(placed, TOUCH_EPS_3D)
367
- };
368
- }
369
- function cornersOnScreen(box, cam) {
370
- const min = box.min;
371
- const max = box.max;
372
- for (const x of [min.x, max.x]) for (const y of [min.y, max.y]) for (const z of [min.z, max.z]) {
373
- const ndc = new Vector3(x, y, z).project(cam);
374
- if (Math.abs(ndc.x) <= 1 && Math.abs(ndc.y) <= 1 && ndc.z <= 1) return true;
375
- }
376
- return false;
377
- }
378
- /**
379
- * Declared colliders that intersect.
380
- *
381
- * The colliders, not the meshes: they are in the JSON, so this needs no GPU and
382
- * no loaded model — and a prop buried inside a platform is the thing you were
383
- * trying to see anyway.
384
- */
385
- function findOverlaps(placed, epsilon) {
386
- const shapes = [];
387
- for (const p of placed) {
388
- const shape = shapeOf(p.node, p.position);
389
- if (!shape) continue;
390
- shapes.push({
391
- path: p.node.getPath(),
392
- internal: p.node.name.startsWith("__"),
393
- shape
394
- });
395
- }
396
- const out = [];
397
- for (let i = 0; i < shapes.length; i++) for (let j = i + 1; j < shapes.length; j++) {
398
- const a = shapes[i];
399
- const b = shapes[j];
400
- if (!a || !b) continue;
401
- if (a.path.startsWith(`${b.path}/`) || b.path.startsWith(`${a.path}/`)) continue;
402
- if (a.internal && b.internal) continue;
403
- if (penetrates(a.shape, b.shape, epsilon)) out.push({
404
- a: a.path,
405
- b: b.path
406
- });
407
- }
408
- return out;
409
- }
410
- function shapeOf(node, centre) {
411
- const collider = node.collider;
412
- const kind = collider?.shape;
413
- if (kind === "sphere" || kind === "circle") return {
414
- kind: "ball",
415
- centre,
416
- radius: collider?.radius ?? .5
417
- };
418
- const half = halfExtents(node);
419
- if (!half) return null;
420
- return {
421
- kind: "box",
422
- centre,
423
- half: kind === "rect" ? new Vector3(half.x, half.y, FLAT_DEPTH) : half
424
- };
425
- }
426
- /** True when the two shapes share more than `eps` of space, in every axis. */
427
- function penetrates(a, b, eps) {
428
- if (a.kind === "ball" && b.kind === "ball") return a.centre.distanceTo(b.centre) < a.radius + b.radius - eps;
429
- if (a.kind === "ball" || b.kind === "ball") {
430
- const ball = a.kind === "ball" ? a : b;
431
- const box = a.kind === "ball" ? b : a;
432
- 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;
433
- }
434
- 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;
435
- }
436
- function round(n) {
437
- return Math.round(n * 1e3) / 1e3;
438
- }
439
- /** The report as something to read in a terminal. */
440
- function framingText(report) {
441
- const lines = [];
442
- const is2d = report.dimension === "2d";
443
- const unit = is2d ? "px" : "m";
444
- const c = report.camera;
445
- if (c && is2d) {
446
- const [vw = 0, vh = 0] = c.view ?? [];
447
- const zoom = c.zoom === 1 ? "" : ` zoom ${c.zoom}`;
448
- lines.push(`camera ${c.path} centred [${c.at[0]}, ${c.at[1]}] showing ${vw}×${vh}px${zoom}`);
449
- } else if (c) lines.push(`camera ${c.path} at [${c.at.map(round).join(", ")}] looking [${(c.forward ?? []).join(", ")}] fov ${c.fovDeg}`);
450
- else if (is2d) lines.push("camera: NONE — the view is (0,0)–(design), which may be intentional");
451
- else lines.push("camera: NONE — this scene frames nothing (no Camera3D)");
452
- if (!is2d) lines.push(report.lights.length ? `lit by: ${report.lights.join(", ")}` : "lit by: NOTHING — this scene renders black");
453
- const on = report.entries.filter((e) => e.where === "onScreen");
454
- const off = report.entries.filter((e) => e.where === "offscreen");
455
- const behind = report.entries.filter((e) => e.where === "behind");
456
- lines.push(is2d ? `${on.length} in view, ${off.length} outside it` : `${on.length} on screen, ${off.length} off screen, ${behind.length} behind the camera`);
457
- for (const e of report.entries) {
458
- const at = `[${(is2d ? e.at.slice(0, 2) : e.at).map(round).join(", ")}]`;
459
- const screen = e.screen ? ` screen [${e.screen.join(", ")}]` : "";
460
- lines.push(` ${e.where.padEnd(9)} ${e.path} (${e.type}) ${at}${screen} ${e.distance}${unit}`);
461
- }
462
- for (const o of report.overlaps) lines.push(` overlap ${o.a} ∩ ${o.b}`);
463
- return lines.join("\n");
464
- }
465
- //#endregion
466
- //#region src/test/playtest.ts
467
- /**
468
- * Out of the world, in pixels — about two screens under the spawn.
469
- *
470
- * Generous on purpose: a player who has actually left the level is falling
471
- * forever, so a far line costs a few frames and never mistakes a long drop
472
- * down a shaft for one.
473
- */
474
- const FALL_2D_PX = 1e3;
475
- /** "Reached it", in pixels — one 32 px tile, next to a ~34 px character. */
476
- const REACH_2D_PX = 32;
477
- /**
478
- * How hard this scene pulls, in its own units — 0 only for a scene that says so.
479
- *
480
- * Gravity is what makes "left the world downward" mean anything, and a
481
- * top-down game has none. But ABSENT is not ZERO: the physics adapters default
482
- * an undeclared scene to [0, 980] px/s² in 2D and [0, -9.81, 0] m/s² in 3D, so
483
- * a platformer that never wrote a physics header still falls, and reading
484
- * absent as zero would have stopped reporting the pit it fell into.
485
- */
486
- function gravityMagnitude(physics) {
487
- const g = physics?.gravity;
488
- if (g === void 0) return 1;
489
- if (!Array.isArray(g)) return 1;
490
- return g.reduce((sum, v) => sum + (typeof v === "number" ? Math.abs(v) : 0), 0);
491
- }
492
- /** Furthest the player ever got from its spawn, below which it went nowhere. */
493
- const STUCK_3D_M = 3;
494
- const STUCK_2D_PX = 96;
495
- const CONTROLLERS = new Set(["CharacterController2D", "CharacterController3D"]);
496
- /**
497
- * Who the driver is playing as.
498
- *
499
- * In order: the body carrying a character controller, then group `player`, then
500
- * a node literally called Player. A scene that satisfies none of them is not
501
- * one this can drive, and says so rather than guessing.
502
- */
503
- function findPlayer(root) {
504
- let found = null;
505
- const walk = (node) => {
506
- if (found) return;
507
- for (const child of node.children) if (CONTROLLERS.has(child.constructor.typeName ?? "")) {
508
- found = node;
509
- return;
510
- }
511
- for (const child of node.children) walk(child);
512
- };
513
- walk(root);
514
- if (found) return found;
515
- let byGroup = null;
516
- const groups = (node) => {
517
- if (byGroup) return;
518
- if (node.isInGroup("player")) byGroup = node;
519
- for (const child of node.children) groups(child);
520
- };
521
- groups(root);
522
- if (byGroup) return byGroup;
523
- return root.getNodeOrNull("%Player");
524
- }
525
- function positionOf$1(node) {
526
- const p = node.position;
527
- if (!Array.isArray(p)) return [
528
- 0,
529
- 0,
530
- 0
531
- ];
532
- return [
533
- p[0] ?? 0,
534
- p[1] ?? 0,
535
- p[2] ?? 0
536
- ];
537
- }
538
- /** World position by walking the parent chain — no renderer, no matrices. */
539
- function worldOf(node) {
540
- let x = 0;
541
- let y = 0;
542
- let z = 0;
543
- for (let n = node; n; n = n.parent) {
544
- const [px, py, pz] = positionOf$1(n);
545
- x += px;
546
- y += py;
547
- z += pz;
548
- }
549
- return [
550
- x,
551
- y,
552
- z
553
- ];
554
- }
555
- /** Trigger volumes are destinations even without a script — that IS their job. */
556
- const TRIGGERS = new Set(["Area2D", "Area3D"]);
557
- /**
558
- * Is this node somewhere the player is meant to GO?
559
- *
560
- * Author intent, not geometry. Keying off the tree alone reported 150 unreached
561
- * nodes on the village scene and 138 of them were roof beams and fence posts —
562
- * a report nobody would read twice. A node is a destination when the author
563
- * marked it as one: it runs a behavior, it carries a group, or it is a trigger
564
- * volume. Decoration is silent.
565
- */
566
- function isTarget(node) {
567
- const type = node.constructor.typeName ?? "";
568
- if (type.startsWith("Ui")) return false;
569
- if (TRIGGERS.has(type)) return true;
570
- if (node.groups.size > 0) return true;
571
- return node.script?.name !== void 0;
572
- }
573
- /**
574
- * What the SCENE says winning and losing are.
575
- *
576
- * Nothing here is a heuristic about game design — `GameFlow` already models
577
- * `'won'`/`'gameover'`, `ScoreKeeper` already emits `won`/`lost`, and `Health`
578
- * already emits `died`. The scene declared all of it; this only listens.
579
- */
580
- function makeOracle(engine, player) {
581
- let outcome = null;
582
- let damage = 0;
583
- const offs = [];
584
- let declaresEnd = false;
585
- /** First verdict wins — a run ends at its first conclusion, not its last. */
586
- const settle = (verdict) => {
587
- if (outcome === null) outcome = verdict;
588
- };
589
- const walk = (node) => {
590
- if (node.behavior) for (const signal of node.declaredSignalNames()) {
591
- if (signal === "won" || signal === "lost" || signal === "flowChanged") declaresEnd = true;
592
- if (signal === "won") offs.push(node.on(signal, () => settle("won")));
593
- if (signal === "lost") offs.push(node.on(signal, () => settle("lost")));
594
- if (signal === "died" && node === player) offs.push(node.on(signal, () => settle("lost")));
595
- if (signal === "damaged" && node === player) offs.push(node.on(signal, () => {
596
- damage += 1;
597
- }));
598
- if (signal === "flowChanged") offs.push(node.on(signal, (state) => {
599
- if (state === "won") settle("won");
600
- if (state === "gameover") settle("lost");
601
- }));
602
- }
603
- for (const child of node.children) walk(child);
604
- };
605
- const root = engine.scene?.root;
606
- if (root) walk(root);
607
- return {
608
- outcome: () => outcome,
609
- declaresEnd: () => declaresEnd,
610
- damage: () => damage,
611
- dispose: () => {
612
- for (const off of offs) off();
613
- }
614
- };
615
- }
616
- /**
617
- * A monkey with a memory.
618
- *
619
- * Not an attempt to play well — an attempt to play DIFFERENTLY, which is what
620
- * makes automated testers find what humans do not. It holds a direction for a
621
- * while (a per-frame coin flip goes nowhere), presses buttons at intervals, and
622
- * biases its next heading toward the part of the world it has visited least.
623
- * That last part is the whole difference between "wanders near spawn forever"
624
- * and "eventually finds the door".
625
- */
626
- var Driver = class {
627
- engine;
628
- rand;
629
- heading = 0;
630
- headingLeft = 0;
631
- visits = /* @__PURE__ */ new Map();
632
- buttons;
633
- /**
634
- * Buttons the bot pressed and will never press again, because pressing them
635
- * STOPPED THE CLOCK.
636
- *
637
- * It drove every declared button action, and `incanto-your-first-game.md`
638
- * tells you to declare `pause`. Measured on one losable scene: 0/10 losses
639
- * with `pause` declared, 10/10 without it, same seed, same everything else.
640
- * A frozen game cannot hurt you, so the run also reported the game as having
641
- * no way to lose.
642
- *
643
- * This is OBSERVED, not guessed from the name — a game is free to call it
644
- * `menu`, `escape`, `halt` or nothing at all, and the bot notices the same
645
- * way a player would: the world stopped moving.
646
- */
647
- frozeTheGame = /* @__PURE__ */ new Set();
648
- /** What was pressed most recently, so a freeze can be attributed to it. */
649
- lastPressed = null;
650
- moveAction;
651
- held = /* @__PURE__ */ new Map();
652
- /**
653
- * Everything in the scene a click can reach.
654
- *
655
- * The bot drove declared ACTIONS and nothing else, so on a mouse game it could
656
- * only ever watch. Measured across one round: a turn-based tactics game, a
657
- * tower defense and a duel all reported the identical
658
- * `? plays — 8 runs played without reaching a win`, and nobody filed it
659
- * because the message is gracious about it. A whole class — tactics, RTS,
660
- * tower defense, point-and-click, board games — was structurally unmeasurable
661
- * by the rung that exists to answer "does this game work".
662
- *
663
- * Headless there is no raycast, so a picker is installed that answers from the
664
- * TREE — the exact recipe `incanto-physics-and-input.md` gives for testing a
665
- * mouse game — and the click goes through the real `Clickable`, the real
666
- * `mouseJustPressed` edge, and the real connection. Nothing is faked but the
667
- * cursor's arrival.
668
- */
669
- clickables;
670
- clickCooldown = 0;
671
- clicking = null;
672
- /** press -> release -> let go. `clicked` fires on RELEASE, so the picker must
673
- * still be answering with the same node on the frame the release is read. */
674
- clickPhase = "idle";
675
- constructor(engine, rand) {
676
- this.engine = engine;
677
- this.rand = rand;
678
- const declared = engine.input.declaredActions();
679
- this.moveAction = declared.find((a) => a.type === "vector2")?.name ?? null;
680
- this.buttons = declared.filter((a) => a.type === "button").map((a) => a.name);
681
- this.clickables = [];
682
- const root = engine.scene?.root;
683
- if (root) {
684
- const walk = (node) => {
685
- if (node.behavior?.constructor.name === "Clickable") this.clickables.push(node);
686
- for (const child of node.children) walk(child);
687
- };
688
- walk(root);
689
- if (this.clickables.length > 0 && !engine.picker) engine.picker = () => this.clicking;
690
- }
691
- }
692
- /** How many nodes this bot can click. Reported, so a ✓ says what it drove. */
693
- get clickableCount() {
694
- return this.clickables.length;
695
- }
696
- /** Grid cell key — coarse on purpose, this is novelty not navigation. */
697
- cell(p) {
698
- return `${Math.round(p[0] / 4)},${Math.round(p[2] / 4)}`;
699
- }
700
- /** Actions this run avoided after they stopped the clock. */
701
- paused() {
702
- return [...this.frozeTheGame];
703
- }
704
- step(dt, at) {
705
- if (this.engine.timeScale === 0) {
706
- if (this.lastPressed) this.frozeTheGame.add(this.lastPressed);
707
- for (const button of this.buttons) {
708
- this.engine.input.releaseAction(button);
709
- this.held.set(button, -1);
710
- }
711
- this.engine.timeScale = 1;
712
- this.lastPressed = null;
713
- }
714
- this.visits.set(this.cell(at), (this.visits.get(this.cell(at)) ?? 0) + 1);
715
- this.headingLeft -= dt;
716
- if (this.headingLeft <= 0) {
717
- this.heading = this.pickHeading(at);
718
- this.headingLeft = .4 + this.rand() * 1.2;
719
- }
720
- if (this.moveAction) this.engine.input.setActionVector(this.moveAction, Math.cos(this.heading), Math.sin(this.heading));
721
- for (const button of this.buttons) {
722
- if (this.frozeTheGame.has(button)) continue;
723
- const left = (this.held.get(button) ?? 0) - dt;
724
- if (left > 0) {
725
- this.held.set(button, left);
726
- continue;
727
- }
728
- if (left > -.05 && left <= 0) {
729
- this.engine.input.releaseAction(button);
730
- this.held.set(button, -1);
731
- continue;
732
- }
733
- const chance = button === "jump" ? .06 : .02;
734
- if (this.rand() < chance) {
735
- this.engine.input.pressAction(button);
736
- this.lastPressed = button;
737
- this.held.set(button, .08 + this.rand() * .12);
738
- }
739
- }
740
- this.maybeClick(dt);
741
- }
742
- /**
743
- * Press and release over one Clickable, occasionally.
744
- *
745
- * `clicked` fires on RELEASE over the same node the press started on, so this
746
- * has to hold the target across two frames — which is also what makes it a
747
- * real click rather than a synthetic signal.
748
- */
749
- maybeClick(dt) {
750
- if (this.clickables.length === 0) return;
751
- this.engine.input.setPointerPosition(1, 1);
752
- if (this.clickPhase === "pressed") {
753
- this.engine.input.handleMouseButton(0, false);
754
- this.clickPhase = "released";
755
- return;
756
- }
757
- if (this.clickPhase === "released") {
758
- this.clicking = null;
759
- this.clickPhase = "idle";
760
- return;
761
- }
762
- this.clickCooldown -= dt;
763
- if (this.clickCooldown > 0) return;
764
- this.clickCooldown = .25 + this.rand() * .6;
765
- const target = this.clickables[Math.floor(this.rand() * this.clickables.length)];
766
- if (!target || target.tree === null) return;
767
- this.clicking = target;
768
- this.clickPhase = "pressed";
769
- this.engine.input.handleMouseButton(0, true);
770
- }
771
- /** Prefer the least-visited neighbouring cell; ties break randomly. */
772
- pickHeading(at) {
773
- let best = Number.POSITIVE_INFINITY;
774
- let bestAngle = this.rand() * Math.PI * 2;
775
- for (let i = 0; i < 8; i++) {
776
- const angle = i / 8 * Math.PI * 2 + this.rand() * .3;
777
- const probe = [
778
- at[0] + Math.cos(angle) * 4,
779
- at[1],
780
- at[2] + Math.sin(angle) * 4
781
- ];
782
- const seen = (this.visits.get(this.cell(probe)) ?? 0) + this.rand();
783
- if (seen < best) {
784
- best = seen;
785
- bestAngle = angle;
786
- }
787
- }
788
- return bestAngle;
789
- }
790
- release() {
791
- for (const button of this.buttons) this.engine.input.releaseAction(button);
792
- if (this.moveAction) this.engine.input.setActionVector(this.moveAction, 0, 0);
793
- }
794
- };
795
- async function runOnce(json, seed, opts) {
796
- const session = await createPlaySession(json, {
797
- seed,
798
- ...opts.behaviors ? { behaviors: opts.behaviors } : {},
799
- stubMissingBehaviors: opts.stubMissingBehaviors ?? true,
800
- ...opts.resolveScene ? { resolveScene: opts.resolveScene } : {}
801
- });
802
- const { engine, scene } = session;
803
- const player = findPlayer(scene.root);
804
- const oracle = makeOracle(engine, player);
805
- const targets = [];
806
- const signals = [];
807
- const fired = /* @__PURE__ */ new Set();
808
- const offs = [];
809
- const wired = /* @__PURE__ */ new Set();
810
- for (const conn of scene.source.connections ?? []) {
811
- if (typeof conn.from !== "string" || typeof conn.signal !== "string") continue;
812
- const node = scene.root.getNodeOrNull(conn.from);
813
- if (node) wired.add(`${node.getPath()}.${conn.signal}`);
814
- }
815
- const walkAll = (node) => {
816
- const isDestination = node !== player && isTarget(node);
817
- if (isDestination) targets.push(node.getPath());
818
- for (const signal of node.declaredSignalNames()) {
819
- const key = `${node.getPath()}.${signal}`;
820
- if (wired.has(key) || isDestination) signals.push(key);
821
- offs.push(node.on(signal, () => void fired.add(key)));
822
- }
823
- for (const child of node.children) walkAll(child);
824
- };
825
- walkAll(scene.root);
826
- const hazards = countHazardsIn(json);
827
- const targetNodes = [];
828
- const collect = (node) => {
829
- if (node !== player && isTarget(node)) targetNodes.push([node.getPath(), node]);
830
- for (const child of node.children) collect(child);
831
- };
832
- collect(scene.root);
833
- const start = player ? worldOf(player) : [
834
- 0,
835
- 0,
836
- 0
837
- ];
838
- const down = scene.dimension === "2d" ? 1 : -1;
839
- const pull = gravityMagnitude(scene.physics);
840
- const floor = opts.fallBelow ?? (pull > 0 ? start[1] + down * (down > 0 ? FALL_2D_PX : 50) : null);
841
- const radius = opts.reachRadius ?? (down > 0 ? REACH_2D_PX : 2);
842
- const recorder = startRecording(engine);
843
- const rand = () => engine.rng.next();
844
- const driver = new Driver(engine, rand);
845
- const reached = /* @__PURE__ */ new Set();
846
- const stepMs = 1e3 / 60;
847
- const deadline = (opts.seconds ?? 60) * 1e3;
848
- let outcome = null;
849
- let swappedAtMs = null;
850
- const offSwap = engine.sceneChanged.connect(() => {
851
- swappedAtMs ??= session.timeMs;
852
- });
853
- let at = start;
854
- let wentAsFarAs = 0;
855
- 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));
856
- while (session.timeMs < deadline) {
857
- at = player ? worldOf(player) : start;
858
- wentAsFarAs = Math.max(wentAsFarAs, travelled(at, start));
859
- driver.step(stepMs / 1e3, at);
860
- session.step(stepMs);
861
- if (player) for (const [path, node] of targetNodes) {
862
- if (reached.has(path)) continue;
863
- const p = worldOf(node);
864
- if (Math.hypot(at[0] - p[0], at[1] - p[1], at[2] - p[2]) <= radius) reached.add(path);
865
- }
866
- const verdict = oracle.outcome();
867
- if (verdict) {
868
- outcome = verdict;
869
- break;
870
- }
871
- if (engine.stats().errors > 0) {
872
- outcome = "error";
873
- break;
874
- }
875
- if (player && floor !== null && (at[1] - floor) * down > 0) {
876
- outcome = "fell";
877
- break;
878
- }
879
- if (swappedAtMs !== null) {
880
- outcome = "swapped";
881
- break;
882
- }
883
- }
884
- driver.release();
885
- offSwap();
886
- const WENT_NOWHERE = scene.dimension === "2d" ? STUCK_2D_PX : STUCK_3D_M;
887
- outcome ??= wentAsFarAs > WENT_NOWHERE ? "unfinished" : "stuck";
888
- const heard = engine.audio.recent().map((e) => e.from);
889
- const shown = engine.effects.recent().map((e) => e.from);
890
- const bad = outcome !== "won" && outcome !== "unfinished";
891
- const run = {
892
- seed,
893
- outcome,
894
- timeMs: session.timeMs,
895
- reached: [...reached],
896
- fired: [...fired],
897
- damageTaken: oracle.damage(),
898
- errors: engine.stats().errors,
899
- replay: bad ? recorder.stop() : null,
900
- endedAt: at,
901
- heard: [...new Set(heard)],
902
- shown: [...new Set(shown)]
903
- };
904
- if (!bad) recorder.stop();
905
- for (const off of offs) off();
906
- oracle.dispose();
907
- session.dispose();
908
- return {
909
- run,
910
- targets,
911
- signals,
912
- declaresEnd: oracle.declaresEnd(),
913
- hazards,
914
- hasPlayer: player !== null,
915
- froze: driver.paused()
916
- };
917
- }
918
- /**
919
- * How many nodes the scene FILE wires to hurt the player.
920
- *
921
- * Read from the JSON, not the tree: a `Spawner` detaches its prefab at ready, so
922
- * a wave game's hazards exist only in the file until the first wave lands.
923
- */
924
- function countHazardsIn(json) {
925
- let n = 0;
926
- const walk = (node) => {
927
- if (typeof node !== "object" || node === null) return;
928
- const nd = node;
929
- if (nd.script?.name === "DamageOnContact") n += 1;
930
- if (Array.isArray(nd.children)) for (const child of nd.children) walk(child);
931
- };
932
- walk(json?.root);
933
- return n;
934
- }
935
- /** Play the scene `runs` times and report what happened. */
936
- async function playtest(json, opts = {}) {
937
- const count = Math.max(1, opts.runs ?? 20);
938
- const base = opts.seed ?? 1;
939
- const runs = [];
940
- let targets = [];
941
- let signals = [];
942
- let declaresWin = false;
943
- let hasPlayer = false;
944
- let hazards = 0;
945
- const froze = /* @__PURE__ */ new Set();
946
- for (let i = 0; i < count; i++) {
947
- const one = await runOnce(json, base + i, opts);
948
- runs.push(one.run);
949
- targets = one.targets;
950
- signals = one.signals;
951
- declaresWin = declaresWin || one.declaresEnd;
952
- hasPlayer = hasPlayer || one.hasPlayer;
953
- hazards = one.hazards;
954
- for (const action of one.froze) froze.add(action);
955
- }
956
- const probe = await createPlaySession(json, {
957
- seed: base,
958
- stubMissingBehaviors: opts.stubMissingBehaviors ?? true,
959
- ...opts.resolveScene ? { resolveScene: opts.resolveScene } : {}
960
- });
961
- const actions = probe.engine.input.declaredActions().map((a) => a.name);
962
- const feedbackNodes = {
963
- audio: [],
964
- effects: []
965
- };
966
- const walkFeedback = (node) => {
967
- const type = node.constructor.typeName ?? "";
968
- const path = node.getPath();
969
- if (type === "AudioPlayer") feedbackNodes.audio.push(path);
970
- if (type === "Particles2D" || type === "Particles3D") feedbackNodes.effects.push(path);
971
- for (const child of node.children) walkFeedback(child);
972
- };
973
- const probeRoot = probe.scene.tree.root;
974
- if (probeRoot) walkFeedback(probeRoot);
975
- probe.dispose();
976
- return {
977
- runs,
978
- targets,
979
- signals: [...new Set(signals)],
980
- actions,
981
- inertActions: [],
982
- declaresWin,
983
- hasPlayer,
984
- hazards,
985
- froze: [...froze],
986
- seconds: opts.seconds ?? 60,
987
- feedback: {
988
- declaredAudio: feedbackNodes.audio,
989
- declaredEffects: feedbackNodes.effects,
990
- heard: [...new Set(runs.flatMap((r) => r.heard))],
991
- shown: [...new Set(runs.flatMap((r) => r.shown))]
992
- }
993
- };
994
- }
995
- function pct(n, total) {
996
- return `${n}/${total}`;
997
- }
998
- function median(values) {
999
- if (values.length === 0) return 0;
1000
- const s = [...values].sort((a, b) => a - b);
1001
- return s[Math.floor(s.length / 2)];
1002
- }
1003
- /**
1004
- * The report, as prose an agent reads and a person skims.
1005
- *
1006
- * Deliberately says what is WRONG and stays quiet about what is fine — a wall
1007
- * of green is a wall nobody reads.
1008
- */
1009
- function playtestText(report) {
1010
- const { runs, targets, signals, hazards } = report;
1011
- const total = runs.length;
1012
- const lines = [];
1013
- lines.push(`${total} runs × up to ${report.seconds}s`);
1014
- lines.push("");
1015
- const won = runs.filter((r) => r.outcome === "won");
1016
- if (won.length > 0) lines.push(` ✓ reached "won" in ${pct(won.length, total)} median ${Math.round(median(won.map((r) => r.timeMs)) / 1e3)}s`);
1017
- 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`);
1018
- if (!report.hasPlayer) {
1019
- lines.push(" · nothing here is drivable — no character controller, no node in the `player` group, no node named Player. The runs went nowhere because there was nobody to move, not because anything is wedged.");
1020
- return lines.join("\n");
1021
- }
1022
- for (const bad of [
1023
- "error",
1024
- "fell",
1025
- "lost",
1026
- "stuck",
1027
- "unfinished",
1028
- "swapped"
1029
- ]) {
1030
- const hits = runs.filter((r) => r.outcome === bad);
1031
- if (hits.length === 0) continue;
1032
- const where = bad === "fell" ? ` (last at y=${hits[0].endedAt[1].toFixed(1)})` : "";
1033
- const mark = bad === "unfinished" || bad === "swapped" ? "·" : "✗";
1034
- 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)}`;
1035
- lines.push(bad === "unfinished" || bad === "swapped" ? ` ${mark} ${pct(hits.length, total)} ${what}` : ` ${mark} ${what}${where}`);
1036
- }
1037
- const everReached = new Set(runs.flatMap((r) => r.reached));
1038
- const missed = targets.filter((path) => !everReached.has(path));
1039
- const anySwapped = runs.some((r) => r.outcome === "swapped");
1040
- 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.`);
1041
- else if (missed.length > 0) {
1042
- lines.push("");
1043
- lines.push(` never reached (${missed.length} of ${targets.length}):`);
1044
- for (const path of missed.slice(0, 12)) lines.push(` ${path}`);
1045
- if (missed.length > 12) lines.push(` …and ${missed.length - 12} more`);
1046
- }
1047
- const everFired = new Set(runs.flatMap((r) => r.fired));
1048
- const silent = signals.filter((s) => !everFired.has(s));
1049
- if (silent.length > 0) {
1050
- lines.push("");
1051
- lines.push(` never fired (${silent.length} of ${signals.length}):`);
1052
- for (const s of silent.slice(0, 12)) lines.push(` ${s}`);
1053
- if (silent.length > 12) lines.push(` …and ${silent.length - 12} more`);
1054
- }
1055
- const damage = runs.reduce((n, r) => n + r.damageTaken, 0);
1056
- const lost = runs.filter((r) => r.outcome === "lost").length;
1057
- if (report.froze.length > 0) {
1058
- lines.push("");
1059
- 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.`);
1060
- }
1061
- lines.push("");
1062
- if (damage > 0) lines.push(` danger: ${damage} hits across ${total} runs (${(damage / total).toFixed(1)} per run)`);
1063
- 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.`);
1064
- 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.");
1065
- 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.`);
1066
- return lines.join("\n");
1067
- }
1068
- /** The failing runs worth saving, newest problem first. */
1069
- function failingReplays(report) {
1070
- const order = [
1071
- "error",
1072
- "fell",
1073
- "stuck",
1074
- "lost"
1075
- ];
1076
- return report.runs.filter((r) => r.replay !== null).sort((a, b) => order.indexOf(a.outcome) - order.indexOf(b.outcome)).map((r) => ({
1077
- seed: r.seed,
1078
- outcome: r.outcome,
1079
- replay: r.replay
1080
- }));
1081
- }
1082
- //#endregion
1083
- //#region src/test/facing.ts
1084
- const DEG2RAD = Math.PI / 180;
1085
- /** The forward vector of a yaw (degrees) for a given art convention. */
1086
- function forwardOf(yawDeg, axis) {
1087
- const yaw = yawDeg * DEG2RAD;
1088
- const base = [Math.sin(yaw), Math.cos(yaw)];
1089
- switch (axis) {
1090
- case "-z": return [-base[0], -base[1]];
1091
- case "+x": return [base[1], -base[0]];
1092
- case "-x": return [-base[1], base[0]];
1093
- default: return base;
1094
- }
1095
- }
1096
- /**
1097
- * The skin, and the yaw the art is DECLARED to be rotated by.
1098
- *
1099
- * `skinYawOffset` is how a model that does not face +Z says so, and it is the
1100
- * supported fix rather than a bug. Subtracting it is what stops this check
1101
- * crying wolf on every correctly-configured non-+Z model.
1102
- */
1103
- function findSkin(player, explicit) {
1104
- if (explicit) return {
1105
- skin: player.getNodeOrNull(explicit) ?? null,
1106
- declaredOffset: 0
1107
- };
1108
- for (const child of player.children) {
1109
- const ctl = child;
1110
- if (typeof ctl.skinPath === "string" && ctl.skinPath !== "") {
1111
- const skin = child.getNodeOrNull(ctl.skinPath);
1112
- if (skin) return {
1113
- skin,
1114
- declaredOffset: ctl.skinYawOffset ?? 0
1115
- };
1116
- }
1117
- }
1118
- return {
1119
- skin: player.getNodeOrNull("%Skin") ?? null,
1120
- declaredOffset: 0
1121
- };
1122
- }
1123
- /**
1124
- * Drive the player forward and compare where the skin points with where it went.
1125
- *
1126
- * The travel direction is measured, not assumed: whatever the input map means by
1127
- * "up", the character ends up going somewhere, and that somewhere is the truth
1128
- * the art has to agree with.
1129
- */
1130
- async function facingReport(json, opts = {}) {
1131
- const session = await createPlaySession(json, {
1132
- seed: opts.seed ?? 1,
1133
- ...opts.behaviors ? { behaviors: opts.behaviors } : {},
1134
- stubMissingBehaviors: opts.stubMissingBehaviors ?? true,
1135
- ...opts.resolveScene ? { resolveScene: opts.resolveScene } : {}
1136
- });
1137
- const player = findPlayer(session.scene.root);
1138
- if (!player) {
1139
- session.dispose();
1140
- return {
1141
- player: null,
1142
- skin: null,
1143
- dot: null,
1144
- backwards: false,
1145
- travelled: 0
1146
- };
1147
- }
1148
- const declared = session.engine.input.declaredActions();
1149
- const move = opts.moveAction ?? declared.find((a) => a.type === "vector2")?.name ?? null;
1150
- const posOf = () => {
1151
- const p = player.position;
1152
- return [p[0] ?? 0, p[2] ?? 0];
1153
- };
1154
- session.step(500);
1155
- const from = posOf();
1156
- if (move) session.engine.input.setActionVector(move, 1, 0);
1157
- session.step(700);
1158
- const to = posOf();
1159
- if (move) session.engine.input.setActionVector(move, 0, 0);
1160
- const dx = to[0] - from[0];
1161
- const dz = to[1] - from[1];
1162
- const travelled = Math.hypot(dx, dz);
1163
- const { skin, declaredOffset } = findSkin(player, opts.skinPath);
1164
- const skinPath = skin ? skin.getPath() : null;
1165
- if (!skin || travelled < .05) {
1166
- session.dispose();
1167
- return {
1168
- player: player.getPath(),
1169
- skin: skinPath,
1170
- dot: null,
1171
- backwards: false,
1172
- travelled
1173
- };
1174
- }
1175
- const [fx, fz] = forwardOf((skin.rotation?.[1] ?? 0) - declaredOffset, opts.forwardAxis ?? "+z");
1176
- const dot = (fx * dx + fz * dz) / travelled;
1177
- session.dispose();
1178
- return {
1179
- player: player.getPath(),
1180
- skin: skinPath,
1181
- dot: Math.round(dot * 1e3) / 1e3,
1182
- backwards: dot < -.5,
1183
- travelled: Math.round(travelled * 1e3) / 1e3
1184
- };
1185
- }
1186
- /** One line for a report, or the failure spelled out. */
1187
- function facingText(report) {
1188
- if (!report.player) return "facing: no player to drive";
1189
- if (report.skin === null) return `facing: ${report.player} has no skin to check`;
1190
- if (report.dot === null) return `facing: ${report.player} did not move — nothing to compare`;
1191
- 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.`;
1192
- return `facing: ${report.skin} faces its travel (dot ${report.dot.toFixed(2)})`;
1193
- }
1194
- //#endregion
1195
- //#region src/test/agreement.ts
1196
- /** Absolute slack, for values that are still converging out of a lerp. */
1197
- const ABS_TOL = .001;
1198
- /** Relative slack, so a large coordinate is not held to the absolute one. */
1199
- const REL_TOL = .005;
1200
- function closeEnough(a, b, absTol, relTol) {
1201
- return Math.abs(a - b) <= Math.max(absTol, relTol * Math.abs(a));
1202
- }
1203
- function numbers(v) {
1204
- if (typeof v === "number") return [v];
1205
- if (Array.isArray(v) && v.every((x) => typeof x === "number")) return v;
1206
- return null;
1207
- }
1208
- /**
1209
- * Every way the clients disagree, most structural first.
1210
- *
1211
- * @param live snapshots taken after the match has quiesced
1212
- * @param late the same, for a client that joined AFTER everything settled
1213
- */
1214
- function agreementProblems(live, late = [], opts = {}) {
1215
- const absTol = opts.absTol ?? ABS_TOL;
1216
- const relTol = opts.relTol ?? REL_TOL;
1217
- const out = [];
1218
- const compare = (snapshots, lateJoin) => {
1219
- const found = [];
1220
- for (const s of snapshots) for (const [observer, seen] of Object.entries(s.seen)) {
1221
- if (seen === null) {
1222
- found.push({
1223
- kind: "missing",
1224
- account: s.account,
1225
- observer,
1226
- key: "",
1227
- 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.`
1228
- });
1229
- continue;
1230
- }
1231
- for (const [key, truth] of Object.entries(s.truth)) {
1232
- const mine = seen[key];
1233
- if (mine === void 0) {
1234
- found.push({
1235
- kind: lateJoin ? "erased" : "absent",
1236
- account: s.account,
1237
- observer,
1238
- key,
1239
- truth,
1240
- 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.`
1241
- });
1242
- continue;
1243
- }
1244
- const a = numbers(truth);
1245
- const b = numbers(mine);
1246
- if (a && b) {
1247
- if (a.length !== b.length) {
1248
- found.push({
1249
- kind: "shape",
1250
- account: s.account,
1251
- observer,
1252
- key,
1253
- truth,
1254
- seen: mine,
1255
- 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.`
1256
- });
1257
- continue;
1258
- }
1259
- if (a.every((v, i) => closeEnough(v, b[i], absTol, relTol))) continue;
1260
- found.push({
1261
- kind: "mismatch",
1262
- account: s.account,
1263
- observer,
1264
- key,
1265
- truth,
1266
- seen: mine,
1267
- summary: `${observer} has ${s.account}'s \`${key}\` as ${JSON.stringify(mine)}, not ${JSON.stringify(truth)}.`
1268
- });
1269
- continue;
1270
- }
1271
- if (jsonEquals(truth, mine)) continue;
1272
- found.push({
1273
- kind: "mismatch",
1274
- account: s.account,
1275
- observer,
1276
- key,
1277
- truth,
1278
- seen: mine,
1279
- summary: `${observer} has ${s.account}'s \`${key}\` as ${JSON.stringify(mine)}, not ${JSON.stringify(truth)}.`
1280
- });
1281
- }
1282
- }
1283
- return found;
1284
- };
1285
- out.push(...compare(live, false));
1286
- if (late.length > 0) {
1287
- const alreadyAbsent = new Set(out.filter((d) => d.kind === "absent").map((d) => `${d.account}|${d.key}`));
1288
- for (const d of compare(late, true)) {
1289
- if (d.kind === "erased" && alreadyAbsent.has(`${d.account}|${d.key}`)) continue;
1290
- out.push(d);
1291
- }
1292
- }
1293
- const rank = {
1294
- missing: 0,
1295
- erased: 1,
1296
- absent: 2,
1297
- shape: 3,
1298
- mismatch: 4
1299
- };
1300
- return out.sort((x, y) => rank[x.kind] - rank[y.kind]);
1301
- }
1302
- //#endregion
1303
- //#region src/test/agreement-snapshot.ts
1304
- /** The last-dot split of the RECEIVE side, applied to a spawned instance. */
1305
- function readFromInstance(instance, key) {
1306
- const lastDot = key.lastIndexOf(".");
1307
- const target = lastDot === -1 ? instance : instance.getNodeOrNull(key.slice(0, lastDot));
1308
- if (!target) return void 0;
1309
- return target[lastDot === -1 ? key : key.slice(lastDot + 1)];
1310
- }
1311
- /** Every spawned instance in a client's tree, by the child name a spawner gives it. */
1312
- function instancesByName(engine) {
1313
- const out = /* @__PURE__ */ new Map();
1314
- const root = engine.scene?.tree.root;
1315
- if (!root) return out;
1316
- const walk = (node) => {
1317
- for (const child of node.children) {
1318
- if (!out.has(child.name)) out.set(child.name, child);
1319
- walk(child);
1320
- }
1321
- };
1322
- walk(root);
1323
- return out;
1324
- }
1325
- /**
1326
- * One snapshot per account: its own truth, and what each other client believes.
1327
- *
1328
- * @param clients every client in the match
1329
- * @param observers the clients whose beliefs to record (defaults to `clients`)
1330
- */
1331
- function takeAgreementSnapshots(clients, observers = clients) {
1332
- const snapshots = [];
1333
- for (const owner of clients) {
1334
- const root = owner.engine.scene?.tree.root;
1335
- if (!root) continue;
1336
- const node = findOwnerNode(root);
1337
- if (!node) continue;
1338
- const keys = Array.isArray(node.network?.sync) ? node.network.sync : [];
1339
- if (keys.length === 0) continue;
1340
- const truth = {};
1341
- for (const key of keys) {
1342
- const value = readSyncKey(node, key);
1343
- if (value !== void 0) truth[key] = value;
1344
- }
1345
- const seen = {};
1346
- for (const other of observers) {
1347
- if (other.account === owner.account) continue;
1348
- const instance = instancesByName(other.engine).get(sanitizeName(owner.account));
1349
- if (!instance) {
1350
- seen[other.account] = null;
1351
- continue;
1352
- }
1353
- const held = {};
1354
- for (const key of Object.keys(truth)) held[key] = readFromInstance(instance, key);
1355
- seen[other.account] = held;
1356
- }
1357
- snapshots.push({
1358
- account: owner.account,
1359
- truth,
1360
- seen
1361
- });
1362
- }
1363
- return snapshots;
1364
- }
1365
- //#endregion
1366
- //#region src/test/feel.ts
1367
- const STEP_MS = 1e3 / 60;
1368
- /** Drop height for the buffer probe — long enough to outlast a coyote window. */
1369
- const DROP = 12;
1370
- /** Metres a 3D probe is lifted/dropped from. */
1371
- const UNIT_LIFT = 40;
1372
- /** Pixels a 2D probe is lifted/dropped from — a 2D world is ~30x the numbers. */
1373
- const PX_LIFT = 1200;
1374
- function positionOf(node) {
1375
- const p = node.position;
1376
- return [
1377
- p?.[0] ?? 0,
1378
- p?.[1] ?? 0,
1379
- p?.[2] ?? 0
1380
- ];
1381
- }
1382
- /**
1383
- * A body's velocity, whichever name it keeps it under.
1384
- *
1385
- * `RigidBody2D/3D` call it `linearVelocity` (the solver owns it);
1386
- * `CharacterBody2D/3D` call it `velocity` (Godot semantics — the game owns it
1387
- * and calls moveAndSlide). Reading only the first reported **zero for every
1388
- * CharacterBody game**, so `incanto-feel` answered `topSpeed 0` and every
1389
- * derived window as null — a feel report that cannot see the character move.
1390
- * It went unnoticed because every test in feel.test.ts drives a RigidBody3D.
1391
- */
1392
- function velocityOf(node) {
1393
- const body = node;
1394
- const v = body.linearVelocity ?? body.velocity;
1395
- return [
1396
- v?.[0] ?? 0,
1397
- v?.[1] ?? 0,
1398
- v?.[2] ?? 0
1399
- ];
1400
- }
1401
- async function makeProbe(json, opts) {
1402
- const session = await createPlaySession(json, {
1403
- seed: opts.seed ?? 1,
1404
- ...opts.behaviors ? { behaviors: opts.behaviors } : {},
1405
- stubMissingBehaviors: opts.stubMissingBehaviors ?? true,
1406
- ...opts.resolveScene ? { resolveScene: opts.resolveScene } : {}
1407
- });
1408
- const player = findPlayer(session.scene.root);
1409
- const declared = session.engine.input.declaredActions();
1410
- const move = opts.moveAction ?? declared.find((a) => a.type === "vector2")?.name ?? null;
1411
- const jump = opts.jumpAction ?? (declared.some((a) => a.name === "jump" && a.type === "button") ? "jump" : null);
1412
- if (!player) {
1413
- session.dispose();
1414
- return {
1415
- probe: null,
1416
- move,
1417
- jump
1418
- };
1419
- }
1420
- const is2d = session.scene.dimension === "2d";
1421
- return {
1422
- probe: {
1423
- session,
1424
- engine: session.engine,
1425
- player,
1426
- up: is2d ? -1 : 1,
1427
- lift: is2d ? PX_LIFT : UNIT_LIFT,
1428
- step: (frames = 1) => session.step(frames * STEP_MS),
1429
- pos: () => positionOf(player),
1430
- vel: () => velocityOf(player),
1431
- speed: () => {
1432
- const v = velocityOf(player);
1433
- return Math.hypot(v[0], v[2]);
1434
- }
1435
- },
1436
- move,
1437
- jump
1438
- };
1439
- }
1440
- /** Let the character settle onto whatever it is standing on. */
1441
- function settle$1(probe) {
1442
- probe.step(60);
1443
- }
1444
- /** Hold one direction and learn what this character's plateau actually is. */
1445
- function measureAccel(probe, move) {
1446
- settle$1(probe);
1447
- const before = probe.pos();
1448
- probe.engine.input.setActionVector(move, 1, 0);
1449
- let latency = null;
1450
- let top = 0;
1451
- for (let f = 1; f <= 300; f++) {
1452
- probe.step();
1453
- if (latency === null) {
1454
- const p = probe.pos();
1455
- if (Math.hypot(p[0] - before[0], p[2] - before[2]) > .01) latency = f;
1456
- }
1457
- top = Math.max(top, probe.speed());
1458
- }
1459
- probe.engine.input.setActionVector(move, 0, 0);
1460
- return {
1461
- top,
1462
- latencyFrames: latency
1463
- };
1464
- }
1465
- /**
1466
- * Time from standstill to 95% of top speed.
1467
- *
1468
- * Two passes on purpose: the first learns what top speed IS (it depends on the
1469
- * scene's own props and surface), the second times the climb to 95% of it. One
1470
- * pass would have to guess the target.
1471
- */
1472
- function timeToFullSpeed(probe, move, top) {
1473
- settle$1(probe);
1474
- const target = top * .95;
1475
- probe.engine.input.setActionVector(move, 1, 0);
1476
- for (let f = 1; f <= 600; f++) {
1477
- probe.step();
1478
- if (probe.speed() >= target) {
1479
- probe.engine.input.setActionVector(move, 0, 0);
1480
- return Math.round(f * STEP_MS);
1481
- }
1482
- }
1483
- probe.engine.input.setActionVector(move, 0, 0);
1484
- return null;
1485
- }
1486
- function timeToStop(probe, move, top) {
1487
- settle$1(probe);
1488
- probe.engine.input.setActionVector(move, 1, 0);
1489
- for (let f = 0; f < 600 && probe.speed() < top * .9; f++) probe.step();
1490
- probe.engine.input.setActionVector(move, 0, 0);
1491
- for (let f = 1; f <= 600; f++) {
1492
- probe.step();
1493
- if (probe.speed() < top * .05) return Math.round(f * STEP_MS);
1494
- }
1495
- return null;
1496
- }
1497
- function turnaround(probe, move, top) {
1498
- settle$1(probe);
1499
- probe.engine.input.setActionVector(move, 1, 0);
1500
- for (let f = 0; f < 600 && probe.speed() < top * .9; f++) probe.step();
1501
- probe.engine.input.setActionVector(move, -1, 0);
1502
- for (let f = 1; f <= 600; f++) {
1503
- probe.step();
1504
- if (probe.vel()[0] < -top * .9) return Math.round(f * STEP_MS);
1505
- }
1506
- probe.engine.input.setActionVector(move, 0, 0);
1507
- return null;
1508
- }
1509
- /**
1510
- * How high one jump goes — TAPPED by default, HELD when asked.
1511
- *
1512
- * This pressed for exactly one frame and released, which is a tap. With
1513
- * `jumpCutMultiplier` — the variable-height jump the 3D character skill
1514
- * recommends at `0.45` — a tap is a fraction of the real jump, and this number
1515
- * is what the skill tells you to size your level against. Measured on one
1516
- * scene, changing only that prop:
1517
- *
1518
- * ```
1519
- * jumpCutMultiplier 1 apex 1.831 u time to apex 433 ms airtime 717 ms
1520
- * jumpCutMultiplier 0.45 apex 0.478 u time to apex 200 ms airtime 367 ms
1521
- * ```
1522
- *
1523
- * Under-reporting the jump by ~4x is how a level ends up with gaps the player
1524
- * clears effortlessly, or ledges they cannot understand missing. Both are
1525
- * reported now, and the text names which is which.
1526
- */
1527
- function jumpShape(probe, jump, hold = false) {
1528
- settle$1(probe);
1529
- const floor = probe.pos()[1];
1530
- probe.engine.input.pressAction(jump);
1531
- probe.step();
1532
- if (!hold) probe.engine.input.releaseAction(jump);
1533
- let apex = 0;
1534
- let toApex = 0;
1535
- let airtime = null;
1536
- for (let f = 2; f <= 600; f++) {
1537
- probe.step();
1538
- const h = (probe.pos()[1] - floor) * probe.up;
1539
- if (h > apex) {
1540
- apex = h;
1541
- toApex = f;
1542
- }
1543
- if (apex > .02 && h <= .02 && airtime === null) {
1544
- airtime = Math.round(f * STEP_MS);
1545
- break;
1546
- }
1547
- }
1548
- if (hold) probe.engine.input.releaseAction(jump);
1549
- const noise = probe.up > 0 ? .02 : 2;
1550
- if (apex <= noise) return {
1551
- apex: null,
1552
- toApexMs: null,
1553
- airtimeMs: null
1554
- };
1555
- return {
1556
- apex: Number(apex.toFixed(3)),
1557
- toApexMs: Math.round(toApex * STEP_MS),
1558
- airtimeMs: airtime
1559
- };
1560
- }
1561
- /**
1562
- * How long after leaving the ground a jump still fires — measured, not read.
1563
- *
1564
- * Binary search over the delay: lift the character clear of its support (the
1565
- * same thing walking off a ledge does), wait, press, and see whether it
1566
- * launched. A `coyoteSeconds` the controller never consults reports 0 here,
1567
- * which is the whole point — that was a real bug in this engine's 3D controller
1568
- * until 0.37 and the schema looked correct the entire time.
1569
- */
1570
- async function measureCoyote(json, opts, jump) {
1571
- const fired = async (delayFrames) => {
1572
- const { probe } = await makeProbe(json, opts);
1573
- if (!probe) return false;
1574
- settle$1(probe);
1575
- const p = probe.pos();
1576
- probe.player.position = [
1577
- p[0],
1578
- p[1] + probe.lift * probe.up,
1579
- p[2]
1580
- ];
1581
- probe.step(delayFrames);
1582
- const before = probe.vel()[1];
1583
- probe.engine.input.pressAction(jump);
1584
- probe.step();
1585
- const after = probe.vel()[1];
1586
- probe.session.dispose();
1587
- return (after - before) * probe.up > 1;
1588
- };
1589
- if (!await fired(1)) return 0;
1590
- let lo = 1;
1591
- let hi = 60;
1592
- if (await fired(hi)) return null;
1593
- while (hi - lo > 1) {
1594
- const mid = Math.floor((lo + hi) / 2);
1595
- if (await fired(mid)) lo = mid;
1596
- else hi = mid;
1597
- }
1598
- return Math.round(lo * STEP_MS);
1599
- }
1600
- /**
1601
- * How long BEFORE landing a press still survives.
1602
- *
1603
- * Same shape: drop the character, press at T frames before it would touch down,
1604
- * and check whether it left the ground again afterwards.
1605
- */
1606
- async function measureBuffer(json, opts, jump) {
1607
- const survived = async (earlyFrames) => {
1608
- const { probe } = await makeProbe(json, opts);
1609
- if (!probe) return false;
1610
- settle$1(probe);
1611
- const floor = probe.pos()[1];
1612
- const drop = (probe.up > 0 ? DROP : probe.lift / 3) * probe.up;
1613
- probe.player.position = [
1614
- probe.pos()[0],
1615
- floor + drop,
1616
- probe.pos()[2]
1617
- ];
1618
- let frames = 0;
1619
- while ((probe.pos()[1] - floor) * probe.up > .05 && frames < 200) {
1620
- probe.step();
1621
- frames += 1;
1622
- }
1623
- const fallFrames = frames;
1624
- probe.session.dispose();
1625
- const second = await makeProbe(json, opts);
1626
- if (!second.probe) return false;
1627
- const p2 = second.probe;
1628
- settle$1(p2);
1629
- p2.player.position = [
1630
- p2.pos()[0],
1631
- floor + drop,
1632
- p2.pos()[2]
1633
- ];
1634
- p2.step(Math.max(0, fallFrames - earlyFrames));
1635
- p2.engine.input.pressAction(jump);
1636
- p2.step();
1637
- p2.engine.input.releaseAction(jump);
1638
- let rose = false;
1639
- for (let f = 0; f < 40; f++) {
1640
- p2.step();
1641
- if (p2.vel()[1] * p2.up > 1) rose = true;
1642
- }
1643
- p2.session.dispose();
1644
- return rose;
1645
- };
1646
- if (!await survived(1)) return 0;
1647
- let lo = 1;
1648
- let hi = 30;
1649
- if (await survived(hi)) return null;
1650
- while (hi - lo > 1) {
1651
- const mid = Math.floor((lo + hi) / 2);
1652
- if (await survived(mid)) lo = mid;
1653
- else hi = mid;
1654
- }
1655
- return Math.round(lo * STEP_MS);
1656
- }
1657
- async function feelReport(json, opts = {}) {
1658
- const first = await makeProbe(json, opts);
1659
- const move = first.move;
1660
- const jump = first.jump;
1661
- if (!first.probe) return {
1662
- player: null,
1663
- moveAction: move,
1664
- jumpAction: jump,
1665
- topSpeed: 0,
1666
- timeToFullSpeedMs: null,
1667
- timeToStopMs: null,
1668
- turnaroundMs: null,
1669
- jumpApex: null,
1670
- heldJumpApex: null,
1671
- heldAirtimeMs: null,
1672
- timeToApexMs: null,
1673
- airtimeMs: null,
1674
- coyoteMs: null,
1675
- jumpBufferMs: null,
1676
- inputToMotionFrames: null
1677
- };
1678
- const playerPath = first.probe.player.getPath();
1679
- let topSpeed = 0;
1680
- let latency = null;
1681
- if (move) {
1682
- const ramp = measureAccel(first.probe, move);
1683
- topSpeed = Number(ramp.top.toFixed(2));
1684
- latency = ramp.latencyFrames;
1685
- }
1686
- first.probe.session.dispose();
1687
- const fresh = async () => (await makeProbe(json, opts)).probe;
1688
- let toFull = null;
1689
- let toStop = null;
1690
- let turn = null;
1691
- if (move && topSpeed > .05) {
1692
- const a = await fresh();
1693
- if (a) {
1694
- toFull = timeToFullSpeed(a, move, topSpeed);
1695
- a.session.dispose();
1696
- }
1697
- const b = await fresh();
1698
- if (b) {
1699
- toStop = timeToStop(b, move, topSpeed);
1700
- b.session.dispose();
1701
- }
1702
- const c = await fresh();
1703
- if (c) {
1704
- turn = turnaround(c, move, topSpeed);
1705
- c.session.dispose();
1706
- }
1707
- }
1708
- const NO_SHAPE = {
1709
- apex: null,
1710
- toApexMs: null,
1711
- airtimeMs: null
1712
- };
1713
- let shape = NO_SHAPE;
1714
- let held = NO_SHAPE;
1715
- let coyote = null;
1716
- let buffer = null;
1717
- if (jump) {
1718
- const d = await fresh();
1719
- if (d) {
1720
- shape = jumpShape(d, jump);
1721
- d.session.dispose();
1722
- }
1723
- const h = await fresh();
1724
- if (h) {
1725
- held = jumpShape(h, jump, true);
1726
- h.session.dispose();
1727
- }
1728
- coyote = await measureCoyote(json, opts, jump);
1729
- buffer = await measureBuffer(json, opts, jump);
1730
- }
1731
- return {
1732
- player: playerPath,
1733
- moveAction: move,
1734
- jumpAction: jump,
1735
- topSpeed,
1736
- timeToFullSpeedMs: toFull,
1737
- timeToStopMs: toStop,
1738
- turnaroundMs: turn,
1739
- jumpApex: shape.apex,
1740
- heldJumpApex: held.apex,
1741
- heldAirtimeMs: held.airtimeMs,
1742
- timeToApexMs: shape.toApexMs,
1743
- airtimeMs: shape.airtimeMs,
1744
- coyoteMs: coyote,
1745
- jumpBufferMs: buffer,
1746
- inputToMotionFrames: latency
1747
- };
1748
- }
1749
- const ms = (v) => v === null ? "—" : `${v} ms`;
1750
- /** Numbers, not grades. What "good" means here is a genre decision. */
1751
- function feelText(report) {
1752
- if (!report.player) return "feel: no player found — this needs a body with a CharacterController, a node in group \"player\", or one named Player.";
1753
- const lines = [];
1754
- lines.push(`feel of ${report.player}`);
1755
- lines.push("");
1756
- lines.push(` top speed ${report.topSpeed} u/s`);
1757
- lines.push(` to full speed ${ms(report.timeToFullSpeedMs)}`);
1758
- lines.push(` to a stop ${ms(report.timeToStopMs)}`);
1759
- lines.push(` turnaround ${ms(report.turnaroundMs)}`);
1760
- lines.push(` input → motion ${report.inputToMotionFrames === null ? "—" : `${report.inputToMotionFrames} frame(s)`}`);
1761
- if (report.jumpAction) {
1762
- lines.push("");
1763
- lines.push(` jump apex ${report.jumpApex === null ? "— (nothing left the ground)" : `${report.jumpApex} u (tapped)`}`);
1764
- const cut = report.heldJumpApex !== null && report.jumpApex !== null && Math.abs(report.heldJumpApex - report.jumpApex) > .02;
1765
- if (cut) lines.push(` held apex ${report.heldJumpApex} u ← size your level against THIS`);
1766
- lines.push(` time to apex ${ms(report.timeToApexMs)}`);
1767
- lines.push(` airtime ${ms(report.airtimeMs)}${cut && report.heldAirtimeMs !== null ? ` (${ms(report.heldAirtimeMs)} held)` : ""}`);
1768
- lines.push(` coyote window ${report.coyoteMs === null ? "always (no window)" : ms(report.coyoteMs)}`);
1769
- lines.push(` jump buffer ${report.jumpBufferMs === null ? "always (no window)" : ms(report.jumpBufferMs)}`);
1770
- if (report.coyoteMs === 0) {
1771
- lines.push("");
1772
- lines.push(" note: coyote window is 0 — a jump that only fires while the ground check");
1773
- lines.push(" is true reads to players as unresponsive controls. Try coyoteSeconds: 0.12.");
1774
- }
1775
- if (report.jumpBufferMs === 0) {
1776
- lines.push(" note: jump buffer is 0 — a press a few frames before landing is thrown away.");
1777
- lines.push(" Try jumpBufferSeconds: 0.15.");
1778
- }
1779
- }
1780
- return lines.join("\n");
1781
- }
1782
- //#endregion
1783
- //#region src/test/multiplay.ts
1784
- /** Run a whole match headlessly and report what the clients ended up sharing. */
1785
- async function playMultiplayer(opts) {
1786
- const count = opts.players ?? 2;
1787
- const seconds = opts.seconds ?? 5;
1788
- const stepMs = opts.stepMs ?? 1e3 / 60;
1789
- const accounts = Array.from({ length: count }, (_, i) => `p${i + 1}`);
1790
- const { server, players, dispose } = await createSplitScreen({
1791
- scene: opts.scene,
1792
- ...opts.server ? { server: opts.server } : {},
1793
- ...opts.scenes ? { scenes: opts.scenes } : {},
1794
- ...opts.seed !== void 0 ? { seed: opts.seed } : {},
1795
- ...opts.load ? { load: opts.load } : {},
1796
- accounts,
1797
- setup: () => {}
1798
- });
1799
- await opts.start?.(players);
1800
- await settle();
1801
- const frames = Math.max(1, Math.round(seconds * 1e3 / stepMs));
1802
- let now = 0;
1803
- for (let frame = 0; frame < frames; frame++) {
1804
- now += stepMs;
1805
- for (const player of players) {
1806
- await opts.drive?.(player, frame, now / 1e3);
1807
- player.engine.tick(now);
1808
- }
1809
- if (frame % 6 === 0) await settle();
1810
- }
1811
- await settle();
1812
- for (let frame = 0; frame < 60; frame++) {
1813
- now += stepMs;
1814
- for (const player of players) player.engine.tick(now);
1815
- if (frame % 6 === 0) await settle();
1816
- }
1817
- await settle();
1818
- const live = takeAgreementSnapshots(players);
1819
- const matchDetail = players.map((p) => ({
1820
- account: p.account,
1821
- room: p.manager.roomId,
1822
- spawned: spawnerCounts(p.engine),
1823
- sawAccounts: Object.keys(p.manager.latestUserStates ?? {}).filter((a) => a !== p.account),
1824
- errors: p.engine.stats().errors
1825
- }));
1826
- let lateSnapshots = [];
1827
- let lateJoinChecked = false;
1828
- if ((opts.lateJoin ?? true) && players.length > 0) {
1829
- const joiner = await joinLate(server, players[0], opts);
1830
- if (joiner) {
1831
- for (let frame = 0; frame < 60; frame++) {
1832
- now += stepMs;
1833
- for (const player of [...players, joiner]) player.engine.tick(now);
1834
- if (frame % 6 === 0) await settle();
1835
- }
1836
- await settle();
1837
- lateSnapshots = takeAgreementSnapshots(players, [joiner]);
1838
- lateJoinChecked = true;
1839
- }
1840
- }
1841
- const report = summarize(players, seconds, frames, matchDetail, {
1842
- disagreements: agreementProblems(live, lateSnapshots),
1843
- keysCompared: live.reduce((n, s) => n + Object.keys(s.truth).length * Object.keys(s.seen).length, 0),
1844
- lateJoinChecked
1845
- });
1846
- dispose();
1847
- return report;
1848
- }
1849
- /**
1850
- * One more client, against the SAME server and the SAME room.
1851
- *
1852
- * `createSplitScreen` builds all its panels up front, so this is the ten lines
1853
- * of it that matter — and the room id has to come from panel 0 or the joiner
1854
- * lands in its own auto-assigned room and reports everything as missing.
1855
- */
1856
- async function joinLate(server, first, opts) {
1857
- try {
1858
- const { Engine } = await import("./register-mmfugpaQ.js").then((n) => n.y);
1859
- const { loadScene } = await import("./loader-DZE7B07H.js").then((n) => n.r);
1860
- const { jsonClone } = await import("./json-CwwhxQgb.js").then((n) => n.i);
1861
- const engine = new Engine(opts.seed !== void 0 ? { seed: opts.seed + 999 } : {});
1862
- engine.setScene(loadScene(jsonClone(opts.scene), opts.load));
1863
- const manager = await NetworkManager.create(engine, {
1864
- transport: server.createClient("late"),
1865
- room: first.manager.roomId
1866
- });
1867
- for (const [name, json] of Object.entries(opts.scenes ?? {})) manager.registerScene(name, json);
1868
- return {
1869
- account: "late",
1870
- engine,
1871
- manager
1872
- };
1873
- } catch {
1874
- return null;
1875
- }
1876
- }
1877
- /** Give the server's promise queue a turn. */
1878
- function settle() {
1879
- return new Promise((resolve) => setTimeout(resolve, 0));
1880
- }
1881
- function summarize(players, seconds, frames, matchDetail, agreement) {
1882
- const rooms = players.map((p) => p.manager.roomId);
1883
- const detail = matchDetail.map((d, i) => ({
1884
- ...d,
1885
- errors: players[i]?.engine.stats().errors ?? d.errors
1886
- }));
1887
- const problems = [...multiplayProblems(detail, rooms), ...agreement.disagreements.map((d) => d.summary)];
1888
- return {
1889
- ok: problems.length === 0,
1890
- disagreements: agreement.disagreements,
1891
- keysCompared: agreement.keysCompared,
1892
- lateJoinChecked: agreement.lateJoinChecked,
1893
- seconds,
1894
- frames,
1895
- rooms,
1896
- players: detail,
1897
- roomState: players[0]?.manager.latestRoomState ?? {},
1898
- problems
1899
- };
1900
- }
1901
- /**
1902
- * What is wrong with a finished match, in the order it matters.
1903
- *
1904
- * Pure, and exported, because every one of these is a SILENT failure — the test
1905
- * for "the clients ended up in different rooms" must not depend on being able
1906
- * to reproduce different rooms.
1907
- */
1908
- function multiplayProblems(detail, rooms) {
1909
- const problems = [];
1910
- 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.`);
1911
- else if (detail.length > 1) {
1912
- const blind = detail.filter((d) => d.sawAccounts.length === 0);
1913
- 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.`);
1914
- const empty = detail.filter((d) => Object.keys(d.spawned).length > 0 && Object.values(d.spawned).every((n) => n === 0));
1915
- if (empty.length > 0 && blind.length === 0) problems.push(`${empty.map((d) => d.account).join(", ")} has a NetworkSpawner that materialised nothing — check its \`scene\` key is registered and its \`source\` names a real collection.`);
1916
- }
1917
- for (const d of detail) if (d.errors > 0) problems.push(`${d.account}: ${d.errors} frame error(s) — read engine.log.`);
1918
- return problems;
1919
- }
1920
- function spawnerCounts(engine) {
1921
- const out = {};
1922
- const root = engine.scene?.tree.root;
1923
- if (!root) return out;
1924
- const walk = (node) => {
1925
- if (node instanceof NetworkSpawner) out[node.name] = node.children.length;
1926
- for (const child of node.children) walk(child);
1927
- };
1928
- walk(root);
1929
- return out;
1930
- }
1931
- /** The report as the one paragraph a reader needs. */
1932
- function multiplayText(report) {
1933
- const lines = [`multiplayer: ${report.players.length} clients, ${report.seconds}s, room ${[...new Set(report.rooms)].join(" + ")}`];
1934
- 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)`);
1935
- for (const p of report.players) {
1936
- const spawned = Object.entries(p.spawned).map(([name, n]) => `${name}=${n}`).join(" ");
1937
- lines.push(` ${p.account}: sees [${p.sawAccounts.join(", ") || "—"}]` + (spawned ? ` spawned ${spawned}` : "") + (p.errors ? ` · ${p.errors} error(s)` : ""));
1938
- }
1939
- for (const problem of report.problems) lines.push(` ✗ ${problem}`);
1940
- if (report.ok) lines.push(" ✓ every client in one room, seeing the others");
1941
- return lines.join("\n");
1942
- }
1943
- //#endregion
1944
- //#region src/test/verify-ladder.ts
1945
- const MARK = {
1946
- pass: "✓",
1947
- fail: "✗",
1948
- unmeasured: "?",
1949
- skipped: "·"
1950
- };
1951
- function ladderVerdict(rungs, ctx = {}) {
1952
- const failed = rungs.find((r) => r.status === "fail");
1953
- const unmeasured = rungs.filter((r) => r.status === "unmeasured");
1954
- const next = failed ? failed.fix ?? failed.summary : unmeasured[0]?.fix ?? unmeasured[0]?.summary ?? null;
1955
- const ambiguous = rungs.length === 0 && (ctx.candidates?.length ?? 0) > 1;
1956
- return {
1957
- ok: !failed,
1958
- rungs,
1959
- unmeasured: unmeasured.map((r) => r.name),
1960
- next: ambiguous ? "name the one you mean: `incanto-verify <scene.json>`" : next ?? null,
1961
- candidates: ctx.candidates ?? []
1962
- };
1963
- }
1964
- /** The ladder as a person or an agent reads it: every rung, then the one action. */
1965
- function ladderText(v) {
1966
- if (v.rungs.length === 0) {
1967
- if (v.candidates.length > 1) return [
1968
- `${v.candidates.length} scenes here, and guessing between them would verify the wrong one:`,
1969
- ...v.candidates.map((c) => ` ${c}`),
1970
- "",
1971
- `next: ${v.next}`
1972
- ].join("\n");
1973
- return "nothing to verify — no scene was given and none was found";
1974
- }
1975
- const lines = [];
1976
- for (const r of v.rungs) {
1977
- lines.push(`${MARK[r.status]} ${r.name} — ${r.summary}`);
1978
- for (const note of r.notes ?? []) lines.push(` ! ${note}`);
1979
- }
1980
- lines.push("");
1981
- if (!v.ok) lines.push("NOT verified.");
1982
- else if (v.unmeasured.length > 0) lines.push(`passes what was measured — ${v.unmeasured.join(", ")} not measured.`);
1983
- else lines.push("verified: it loads, it plays, and it draws.");
1984
- if (v.next) lines.push(`next: ${v.next}`);
1985
- return lines.join("\n");
1986
- }
1987
- //#endregion
1988
- //#region src/test/index.ts
1989
- /**
1990
- * incanto/test — the browserless verification harness.
1991
- *
1992
- * The agent loop is "author → VERIFY → fix", and production environments
1993
- * (e.g. the agent8 VM) have no browser. This module makes the scene state
1994
- * itself the screenshot:
1995
- *
1996
- * - `validateScene(json)` — every hard load error, without a browser
1997
- * - `runScript(json, ...)` — scripted play by ACTION intent at a fixed
1998
- * timestep, with inline assertions and periodic snapshots
1999
- * - `captureScene(scene)` / `describeCapture(capture)` — a structural,
2000
- * grep-able "text screenshot" of the live tree
2001
- *
2002
- * Deterministic by construction: fixed timestep, seeded `engine.rng`,
2003
- * no requestAnimationFrame.
2004
- */
2005
- /** Node types whose whole job is to sit on the ground. Characters, water,
2006
- * clouds, birds and lights are all legitimately off it. */
2007
- const GROUNDED_TYPES = ["MeshInstance3D", "InstancedMesh3D"];
2008
- /** Under one of these, the BODY owns where the thing stands — a character's
2009
- * skin hangs at whatever offset its rig wants, and is not a floating prop. */
2010
- const BODY_OWNED = new Set([
2011
- "CharacterBody3D",
2012
- "RigidBody3D",
2013
- "StaticBody3D",
2014
- "AreaBody3D"
2015
- ]);
2016
- /**
2017
- * Every prop that is floating or buried.
2018
- *
2019
- * THE most repeated mistake in scene authoring, by humans and agents alike, is
2020
- * a hand-computed Y: correct when it is written and wrong the moment anything
2021
- * reshapes the ground under it — a river carving its bed, a new seed, a
2022
- * generator that mirrors a carve slightly differently. This is the check that
2023
- * catches it, and `snapToGround` is the fix that stops it happening.
2024
- *
2025
- * Anything explicitly marked `snapToGround: false` is skipped: that is the
2026
- * author saying "yes, this hangs in the air", which a bridge deck, a lantern
2027
- * and a cloud all legitimately do.
2028
- *
2029
- * ```ts
2030
- * const off = findFloatingProps(gameJson);
2031
- * ok(`nothing floats (${off.length})`, off.length === 0);
2032
- * ```
2033
- */
2034
- function findFloatingProps(json, opts = {}) {
2035
- registerAllNodes();
2036
- const tolerance = opts.tolerance ?? .1;
2037
- const types = new Set(opts.types ?? GROUNDED_TYPES);
2038
- let scene;
2039
- try {
2040
- scene = loadScene(json, { stubMissingBehaviors: true });
2041
- } catch (error) {
2042
- throw new IncantoError("BAD_FORMAT", `findFloatingProps: the scene does not load, so nothing could be measured. ${error?.message ?? String(error)}`);
2043
- }
2044
- let ground = null;
2045
- const findGround = (node) => {
2046
- if (!ground && node.constructor.typeName === "Terrain3D" && typeof node.heightAt === "function") ground = node;
2047
- for (const child of node.children) findGround(child);
2048
- };
2049
- findGround(scene.root);
2050
- 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.");
2051
- const out = [];
2052
- const walk = (node, path, ox, oy, oz, yaw, bodyOwned) => {
2053
- const n = node;
2054
- let wx = ox;
2055
- let wy = oy;
2056
- let wz = oz;
2057
- let ownYaw = yaw;
2058
- if (Array.isArray(n.position)) {
2059
- const c = Math.cos(yaw);
2060
- const s = Math.sin(yaw);
2061
- const lx = n.position[0] ?? 0;
2062
- const lz = n.position[2] ?? 0;
2063
- wx = ox + lx * c + lz * s;
2064
- wz = oz - lx * s + lz * c;
2065
- wy = oy + (n.position[1] ?? 0);
2066
- ownYaw = yaw + (n.rotation?.[1] ?? 0) * Math.PI / 180;
2067
- }
2068
- const type = node.constructor.typeName ?? "";
2069
- const soil = ground;
2070
- if (types.has(type) && n.visible !== false && n.snapToGround !== false && !bodyOwned) {
2071
- const rows = node.transforms;
2072
- if (Array.isArray(rows) && rows.length > 0) {
2073
- let worst = null;
2074
- for (const row of rows) {
2075
- const scale = row[4] ?? 1;
2076
- const half = (n.size?.[1] ?? 0) * scale / 2;
2077
- const gap = wy + (row[1] ?? 0) - half - soil.heightAt(wx + (row[0] ?? 0), wz + (row[2] ?? 0));
2078
- if (Math.abs(gap) > tolerance && (!worst || Math.abs(gap) > Math.abs(worst.gap))) worst = {
2079
- path,
2080
- gap: Math.round(gap * 100) / 100
2081
- };
2082
- }
2083
- if (worst) out.push(worst);
2084
- } else {
2085
- const half = (n.size?.[1] ?? 0) / 2;
2086
- const gap = wy - half - soil.heightAt(wx, wz);
2087
- if (Math.abs(gap) > tolerance) out.push({
2088
- path,
2089
- gap: Math.round(gap * 100) / 100
2090
- });
2091
- }
2092
- }
2093
- const childBodyOwned = bodyOwned || BODY_OWNED.has(type);
2094
- for (const child of node.children) walk(child, `${path}/${child.name}`, wx, wy, wz, ownYaw, childBodyOwned);
2095
- };
2096
- walk(scene.root, `/${scene.root.name}`, 0, 0, 0, 0, false);
2097
- return out;
2098
- }
2099
- function registerAllNodes() {
2100
- registerNodes2D();
2101
- registerNodes3D();
2102
- registerNodesNet();
2103
- registerGameplayBehaviors();
2104
- }
2105
- /** Snapshot the live tree: every node, every schema prop's current value. */
2106
- function captureScene(scene) {
2107
- const nodes = [];
2108
- const walk = (node) => {
2109
- const ctor = node.constructor;
2110
- const schema = mergeStaticProps(ctor);
2111
- const props = {};
2112
- for (const key of Object.keys(schema)) props[key] = jsonClone(node[key]);
2113
- const capture = {
2114
- path: node.getPath(),
2115
- type: ctor.typeName,
2116
- props
2117
- };
2118
- const painted = node._paintedText?.();
2119
- if (typeof painted === "string") capture.text = painted;
2120
- if (node.uid) capture.uid = node.uid;
2121
- if (node.groups.size > 0) capture.groups = [...node.groups];
2122
- if (Object.keys(node.tags).length > 0) capture.tags = jsonClone(node.tags);
2123
- if (typeof node.script?.name === "string") capture.script = node.script.name;
2124
- nodes.push(capture);
2125
- for (const child of node.children) walk(child);
2126
- };
2127
- walk(scene.root);
2128
- const out = {
2129
- name: scene.name,
2130
- nodes
2131
- };
2132
- if (scene.dimension) out.dimension = scene.dimension;
2133
- return out;
2134
- }
2135
- /**
2136
- * Render a capture as one grep-able line per node, showing only NON-DEFAULT
2137
- * props — the same delta discipline as scene JSON, so the interesting state
2138
- * stands out.
2139
- */
2140
- function describeCapture(capture) {
2141
- const lines = [`scene ${capture.name}${capture.dimension ? ` (${capture.dimension})` : ""}`];
2142
- for (const node of capture.nodes) {
2143
- const schema = defaultsFor(node.type);
2144
- const deltas = Object.entries(node.props).filter(([key, value]) => !schema || !jsonEquals(value, schema[key]?.default ?? null)).map(([key, value]) => `${key}=${JSON.stringify(value)}`);
2145
- const extras = [
2146
- node.text !== void 0 ? `paints=${JSON.stringify(node.text)}` : "",
2147
- node.script ? `script=${node.script}` : "",
2148
- node.groups ? `groups=${node.groups.join(",")}` : "",
2149
- ...deltas
2150
- ].filter(Boolean);
2151
- lines.push(`${node.path} ${node.type}${extras.length ? ` ${extras.join(" ")}` : ""}`);
2152
- }
2153
- return lines.join("\n");
2154
- }
2155
- function defaultsFor(typeName) {
2156
- try {
2157
- return getNodeSchema(typeName);
2158
- } catch {
2159
- return null;
2160
- }
2161
- }
2162
- /**
2163
- * Run every hard load-time check headlessly. Unregistered behaviors are
2164
- * stubbed by default (structure-only validation, no TypeScript needed) —
2165
- * pass the real classes via `behaviors` to validate script props too.
2166
- */
2167
- /**
2168
- * `environment.preset` is resolved deep inside Renderer3D, which no headless
2169
- * tool reaches — so a typo there passed `incanto-check` and hard-failed at boot.
2170
- * It cannot live in the core loader (core must never import 3d), but it belongs
2171
- * exactly here: the test entry already registers the 3D nodes.
2172
- */
2173
- function checkEnvironment(json) {
2174
- const env = json?.environment;
2175
- if (!env || typeof env !== "object") return;
2176
- if (env.preset === void 0) return;
2177
- if (typeof env.preset !== "string") throw new IncantoError("BAD_FORMAT", `Scene "environment.preset" must be a string, got ${JSON.stringify(env.preset)}.`);
2178
- try {
2179
- resolveEnvironmentHdri(env);
2180
- } catch (error) {
2181
- throw new IncantoError("BAD_FORMAT", error instanceof Error ? error.message : String(error));
2182
- }
2183
- }
2184
- function validateScene(json, opts = {}) {
2185
- registerAllNodes();
2186
- for (const [name, ctor] of Object.entries(opts.behaviors ?? {})) registerBehavior(name, ctor, { replace: true });
2187
- try {
2188
- checkEnvironment(json);
2189
- loadScene(json, {
2190
- resolveScene: opts.resolveScene,
2191
- ...opts.declaredAssets ? { declaredAssets: opts.declaredAssets } : {},
2192
- stubMissingBehaviors: !opts.strictBehaviors
2193
- }).root.free();
2194
- return { ok: true };
2195
- } catch (e) {
2196
- if (e instanceof IncantoError) return {
2197
- ok: false,
2198
- error: e
2199
- };
2200
- throw e;
2201
- }
2202
- }
2203
- /**
2204
- * Load a scene and play it headlessly at a fixed timestep, driving input by
2205
- * ACTION intent. This is the e2e loop without a browser: script → simulate →
2206
- * snapshot → assert.
2207
- */
2208
- async function runScript(json, opts) {
2209
- registerAllNodes();
2210
- for (const [name, ctor] of Object.entries(opts.behaviors ?? {})) registerBehavior(name, ctor, { replace: true });
2211
- const engine = new Engine({
2212
- seed: opts.seed,
2213
- fixedHz: opts.fixedHz,
2214
- scheduler: () => () => {}
2215
- });
2216
- const scene = loadScene(structuredClone(json), { resolveScene: opts.resolveScene });
2217
- engine.setScene(scene);
2218
- if (opts.locale) engine.locale.locale = opts.locale;
2219
- const physics = opts.physics ?? "auto";
2220
- if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
2221
- const { enablePhysics2D } = await import("./physics-2d-CF5yyjbj.js").then((n) => n.r);
2222
- await enablePhysics2D(engine);
2223
- } else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
2224
- const { enablePhysics3D } = await import("./physics-3d-DbYaEcZy.js").then((n) => n.r);
2225
- await enablePhysics3D(engine);
2226
- }
2227
- const failures = [];
2228
- const snapshots = [];
2229
- const live = () => engine.scene ?? scene;
2230
- const ctx = {
2231
- engine,
2232
- get scene() {
2233
- return live();
2234
- },
2235
- timeMs: 0,
2236
- getNode: (path) => live().root.getNode(path),
2237
- capture: () => captureScene(live())
2238
- };
2239
- const applyStep = (step) => {
2240
- if (step.press) engine.input.pressAction(step.press);
2241
- if (step.release) engine.input.releaseAction(step.release);
2242
- if (step.vector) engine.input.setActionVector(...step.vector);
2243
- if (step.key) engine.input.handleKey(step.key[0], step.key[1]);
2244
- step.do?.(ctx);
2245
- if (step.assert) try {
2246
- if (step.assert(ctx) === false) failures.push({
2247
- atMs: step.atMs,
2248
- label: step.label,
2249
- message: "assert returned false"
2250
- });
2251
- } catch (e) {
2252
- failures.push({
2253
- atMs: step.atMs,
2254
- label: step.label,
2255
- message: e instanceof Error ? e.message : String(e)
2256
- });
2257
- }
2258
- };
2259
- const stepMs = 1e3 / (opts.fixedHz ?? 60);
2260
- const totalTicks = Math.round(opts.durationMs / stepMs);
2261
- const steps = [...opts.steps ?? []].sort((a, b) => a.atMs - b.atMs);
2262
- let stepIndex = 0;
2263
- let nextSnapshot = opts.snapshotEveryMs ?? Number.POSITIVE_INFINITY;
2264
- for (let i = 1; i <= totalTicks; i++) {
2265
- const t = i * stepMs;
2266
- ctx.timeMs = t;
2267
- while (stepIndex < steps.length && steps[stepIndex].atMs <= t) {
2268
- applyStep(steps[stepIndex]);
2269
- stepIndex += 1;
2270
- }
2271
- engine.step();
2272
- if (t >= nextSnapshot - 1e-6) {
2273
- snapshots.push({
2274
- atMs: Math.round(t),
2275
- capture: captureScene(live())
2276
- });
2277
- nextSnapshot += opts.snapshotEveryMs;
2278
- }
2279
- }
2280
- for (; stepIndex < steps.length; stepIndex++) {
2281
- const missed = steps[stepIndex];
2282
- failures.push({
2283
- atMs: missed.atMs,
2284
- label: missed.label,
2285
- message: `step never executed — atMs ${missed.atMs} is beyond the ${opts.durationMs}ms run`
2286
- });
2287
- }
2288
- const finalCapture = captureScene(live());
2289
- const logs = [...engine.log.entries()];
2290
- const swallowed = engine.stats().errors;
2291
- if (swallowed > 0) failures.push({
2292
- atMs: opts.durationMs,
2293
- label: "engine",
2294
- message: `${swallowed} error(s) were swallowed to keep the loop alive — the logs above name the node, the behavior and the phase`
2295
- });
2296
- engine.dispose();
2297
- const ok = failures.length === 0;
2298
- return {
2299
- ok,
2300
- failures,
2301
- snapshots,
2302
- logs,
2303
- finalCapture,
2304
- describe: () => {
2305
- const engineWarnings = logs.filter((l) => l.level === "warn" || l.level === "error");
2306
- const head = `run ${ok ? "OK" : "FAILED"} — ${opts.durationMs}ms simulated, ${failures.length} failure(s), ${logs.length} log(s)`;
2307
- const fails = failures.map((f) => ` ✗ at ${f.atMs}ms${f.label ? ` [${f.label}]` : ""}: ${f.message}`);
2308
- const noisy = engineWarnings.map((l) => ` ! ${l.level}: ${l.parts.map((p) => typeof p === "string" ? p : String(p)).join(" ")}`);
2309
- return [
2310
- head,
2311
- ...fails,
2312
- ...noisy,
2313
- describeCapture(finalCapture)
2314
- ].join("\n");
2315
- }
2316
- };
2317
- }
2318
- /**
2319
- * The headless play loop behind `incanto-play`: load → feed inputs by intent
2320
- * (`session.engine.input.pressAction(...)`) → `step(ms)` → `capture()` the
2321
- * state AS A SCENE FILE. What a screenshot is to humans, the capture is to
2322
- * agents: complete, structured, and in the exact format they already read.
2323
- */
2324
- async function createPlaySession(json, opts = {}) {
2325
- registerAllNodes();
2326
- for (const [name, ctor] of Object.entries(opts.behaviors ?? {})) registerBehavior(name, ctor, { replace: true });
2327
- const engine = new Engine({
2328
- seed: opts.seed,
2329
- fixedHz: opts.fixedHz,
2330
- scheduler: () => () => {}
2331
- });
2332
- const scene = loadScene(structuredClone(json), {
2333
- resolveScene: opts.resolveScene,
2334
- stubMissingBehaviors: opts.stubMissingBehaviors,
2335
- engine
2336
- });
2337
- engine.setScene(scene);
2338
- const physics = opts.physics ?? "auto";
2339
- if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
2340
- const { enablePhysics2D } = await import("./physics-2d-CF5yyjbj.js").then((n) => n.r);
2341
- await enablePhysics2D(engine);
2342
- } else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
2343
- const { enablePhysics3D } = await import("./physics-3d-DbYaEcZy.js").then((n) => n.r);
2344
- await enablePhysics3D(engine);
2345
- }
2346
- const stepMs = 1e3 / (opts.fixedHz ?? 60);
2347
- let timeMs = 0;
2348
- let logCursor = 0;
2349
- return {
2350
- engine,
2351
- get scene() {
2352
- return engine.scene ?? scene;
2353
- },
2354
- get timeMs() {
2355
- return timeMs;
2356
- },
2357
- step(ms) {
2358
- const ticks = Math.max(1, Math.round(ms / stepMs));
2359
- for (let i = 0; i < ticks; i++) engine.step();
2360
- timeMs += ticks * stepMs;
2361
- },
2362
- capture() {
2363
- return (engine.scene ?? scene).toJSON();
2364
- },
2365
- describe() {
2366
- return describeCapture(captureScene(engine.scene ?? scene));
2367
- },
2368
- framing() {
2369
- return framingText(describeFraming(engine.scene ?? scene));
2370
- },
2371
- drainLogs() {
2372
- const all = engine.log.entries();
2373
- const fresh = all.filter((e) => e.seq > logCursor);
2374
- if (all.length > 0) logCursor = all[all.length - 1].seq;
2375
- return [...fresh];
2376
- },
2377
- dispose() {
2378
- engine.dispose();
2379
- }
2380
- };
2381
- }
2382
- //#endregion
2383
- export { describeFraming as C, playtestText as S, facingReport as _, registerAllNodes as a, findPlayer as b, ladderText as c, multiplayText as d, playMultiplayer as f, agreementProblems as g, takeAgreementSnapshots as h, findFloatingProps as i, ladderVerdict as l, feelText as m, createPlaySession as n, runScript as o, feelReport as p, describeCapture as r, validateScene as s, captureScene as t, multiplayProblems as u, facingText as v, framingText as w, playtest as x, failingReplays as y };