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