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