incanto 0.74.0 → 0.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/bin/incanto-multiplay.mjs +52 -4
  2. package/dist/2d.d.ts +83 -7
  3. package/dist/2d.js +4 -3
  4. package/dist/3d.d.ts +861 -167
  5. package/dist/3d.js +6 -6
  6. package/dist/{agent8-CHTT4unP.js → agent8-B-bh3n0X.js} +1 -1
  7. package/dist/{audio-player-BOrk4eQx.d.ts → audio-player-BRo2uvG6.d.ts} +23 -2
  8. package/dist/{behavior-J1cmVah0.d.ts → behavior-B_245qRy.d.ts} +15 -1
  9. package/dist/{create-game-ssvGvP7W.js → create-game-BMIPKrQW.js} +84 -8
  10. package/dist/{create-game-BAiA-FjP.js → create-game-Dai5vfVu.js} +80 -11
  11. package/dist/debug.d.ts +1 -1
  12. package/dist/{editor-switch-CFU9mCec.d.ts → editor-switch-Bzt0GzVp.d.ts} +13 -3
  13. package/dist/editor.js +1600 -1529
  14. package/dist/env.d.ts +1 -1
  15. package/dist/{environment-presets-BkqlWewf.js → environment-presets-e_9YCnGu.js} +2361 -104
  16. package/dist/{gameplay-Bxe1sMVT.js → gameplay-Bfff_beb.js} +10919 -6798
  17. package/dist/gameplay.d.ts +573 -31
  18. package/dist/gameplay.js +3 -2
  19. package/dist/index.d.ts +150 -6
  20. package/dist/index.js +6 -6
  21. package/dist/{loader-BAeWMYfF.d.ts → loader-CbkVdXL8.d.ts} +2 -2
  22. package/dist/net.d.ts +2 -2
  23. package/dist/net.js +2 -2
  24. package/dist/{physics-2d-C-SfvjzH.js → physics-2d-Tuf5LAyq.js} +250 -31
  25. package/dist/{physics-3d-rOzeg890.js → physics-3d-C5MOujfQ.js} +673 -51
  26. package/dist/{picking-BMV34Pjl.js → picking-DIwE48sU.js} +10 -7
  27. package/dist/{save-slots-CEUGeLuB.js → pose-ByFB_J3O.js} +217 -11
  28. package/dist/react.d.ts +2 -2
  29. package/dist/react.js +1 -1
  30. package/dist/{register-DRQyZKGU.js → register-7E3P1gz6.js} +191 -430
  31. package/dist/{register-CjociOtt.js → register-B768Qedj.js} +596 -2
  32. package/dist/{replay-B6BbgGM_.js → replay-DrqQ6dZj.js} +133 -18
  33. package/dist/{replay-wZK5Ok5c.d.ts → replay-W5nCw_cU.d.ts} +21 -2
  34. package/dist/{rng-Bb-IutXB.d.ts → rng-BsXZg3D6.d.ts} +7 -0
  35. package/dist/{split-screen-BxZ5rBe7.js → split-screen-B-dIcQsQ.js} +10 -5
  36. package/dist/{split-screen-D6sx8z3Z.d.ts → split-screen-CiLJf2-C.d.ts} +3 -3
  37. package/dist/{sprite-animation-Bz3DHD_4.js → sprite-animation-SQa5gIu2.js} +8 -6
  38. package/dist/{src-CqtvYtSN.js → src-D823V07k.js} +1 -1
  39. package/dist/{test-CEl0y2Sw.js → test-CVbxnXlv.js} +487 -82
  40. package/dist/test.d.ts +90 -12
  41. package/dist/test.js +2 -2
  42. package/dist/vite.js +34 -5
  43. package/editor/assets/{agent8-BlzturGi.js → agent8-DA-I5qhv.js} +1 -1
  44. package/editor/assets/{debug-BMUi8usj.js → debug-99tEPoGm.js} +1 -1
  45. package/editor/assets/index-Db74LZY-.js +11579 -0
  46. package/editor/index.html +1 -1
  47. package/package.json +1 -1
  48. package/schemas/scene.schema.json +1639 -280
  49. package/skills/incanto-3d-character.md +383 -20
  50. package/skills/incanto-audio.md +24 -2
  51. package/skills/incanto-building-2d-games.md +21 -2
  52. package/skills/incanto-building-3d-games.md +38 -3
  53. package/skills/incanto-editor.md +29 -8
  54. package/skills/incanto-environment.md +257 -14
  55. package/skills/incanto-game-feel.md +60 -1
  56. package/skills/incanto-gameplay-behaviors.md +668 -20
  57. package/skills/incanto-hud.md +116 -0
  58. package/skills/incanto-multiplayer.md +141 -2
  59. package/skills/incanto-node-reference.md +398 -16
  60. package/skills/incanto-performance.md +32 -0
  61. package/skills/incanto-physics-and-input.md +241 -13
  62. package/skills/incanto-playtesting.md +2 -2
  63. package/skills/incanto-save-slots.md +61 -2
  64. package/skills/incanto-scene-json-authoring.md +13 -0
  65. package/skills/incanto-verifying-your-game.md +86 -3
  66. package/templates-app/beacon-isle-3d/PROJECT/Status.md +12 -0
  67. package/templates-app/beacon-isle-3d/package.json +1 -1
  68. package/templates-app/beacon-isle-3d/src/game.scene.json +157 -3
  69. package/templates-app/molehill-2d/PROJECT/Status.md +7 -0
  70. package/templates-app/molehill-2d/package.json +1 -1
  71. package/templates-app/molehill-2d/src/game.scene.json +129 -2
  72. package/templates-app/platformer-2d/PROJECT/Status.md +7 -0
  73. package/templates-app/platformer-2d/docs/project-2d-rules.md +3 -2
  74. package/templates-app/platformer-2d/package.json +1 -1
  75. package/templates-app/platformer-2d/src/behaviors.ts +40 -26
  76. package/templates-app/platformer-2d/src/game.scene.json +144 -2
  77. package/templates-app/platformer-2d/verify.ts +18 -1
  78. package/templates-app/star-survivor/PROJECT/Status.md +7 -0
  79. package/templates-app/star-survivor/package.json +1 -1
  80. package/templates-app/star-survivor/src/behaviors.ts +9 -8
  81. package/templates-app/star-survivor/src/game.scene.json +142 -4
  82. package/templates-app/star-survivor/verify.ts +13 -0
  83. package/templates-app/tps-3d/PROJECT/Status.md +6 -0
  84. package/templates-app/tps-3d/package.json +1 -1
  85. package/templates-app/tps-3d/src/game.scene.json +71 -10
  86. package/templates-app/village-quest-3d/PROJECT/Status.md +7 -0
  87. package/templates-app/village-quest-3d/package.json +1 -1
  88. package/templates-app/village-quest-3d/src/behaviors.ts +8 -16
  89. package/templates-app/village-quest-3d/src/grove.scene.json +121 -5
  90. package/templates-app/village-quest-3d/src/village.scene.json +126 -2
  91. package/templates-app/village-quest-3d/verify.ts +52 -2
  92. package/editor/assets/index-CrUCQoaB.js +0 -11046
@@ -1,14 +1,14 @@
1
1
  import { n as jsonEquals, t as jsonClone } from "./json-CfTjpvW8.js";
2
- import { B as HudLayer, M as resetMemorySaves, O as resolveViewport, V as HudWidgetBase, c as isCloned, h as loadScene, ot as registerBehavior, tt as StubBehavior } from "./save-slots-CEUGeLuB.js";
3
- import { v as Engine } from "./register-CjociOtt.js";
2
+ import { G as HudLayer, K as HudWidgetBase, L as resetMemorySaves, N as resolveViewport, b as loadScene, dt as registerBehavior, f as isCloned, i as worldPosition, ot as StubBehavior } from "./pose-ByFB_J3O.js";
3
+ import { b as Engine } from "./register-B768Qedj.js";
4
4
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
5
5
  import { t as Rng } from "./rng-CDOMybym.js";
6
- import { a as LIGHT_NODE_TYPES, n as startRecording } from "./replay-B6BbgGM_.js";
6
+ import { a as LIGHT_NODE_TYPES, n as startRecording } from "./replay-DrqQ6dZj.js";
7
7
  import { c as getNodeSchema, d as mergeStaticProps, u as getNodeType } from "./touch-CioTZB-y.js";
8
- import { et as worldPosition, n as registerGameplayBehaviors } from "./gameplay-Bxe1sMVT.js";
9
- import { t as registerNodes2D } from "./register-DRQyZKGU.js";
10
- import { n as registerNodes3D, t as resolveEnvironmentHdri } from "./environment-presets-BkqlWewf.js";
11
- import { a as NetworkManager, c as readSyncKey, i as sanitizeName, n as registerNodesNet, r as NetworkSpawner, s as findOwnerNode, t as createSplitScreen } from "./split-screen-BxZ5rBe7.js";
8
+ import { n as registerGameplayBehaviors } from "./gameplay-Bfff_beb.js";
9
+ import { t as registerNodes2D } from "./register-7E3P1gz6.js";
10
+ import { n as registerNodes3D, t as resolveEnvironmentHdri } from "./environment-presets-e_9YCnGu.js";
11
+ import { a as NetworkManager, c as readSyncKey, i as sanitizeName, n as registerNodesNet, r as NetworkSpawner, s as findOwnerNode, t as createSplitScreen } from "./split-screen-B-dIcQsQ.js";
12
12
  import { Box3, Euler, Frustum, Matrix4, PerspectiveCamera, Quaternion, Ray, Vector3 } from "three";
13
13
  //#region src/core/asset-urls.ts
14
14
  /** Keys inside a `material` object that hold a texture URL. */
@@ -59,6 +59,32 @@ function sceneAssetUrls(json) {
59
59
  url: value
60
60
  });
61
61
  }
62
+ const seen = new Set(out.filter((u) => u.path === path).map((u) => u.url));
63
+ const deep = (value, where) => {
64
+ if (typeof value === "string") {
65
+ if (/^https?:\/\//i.test(value) && !seen.has(value)) {
66
+ seen.add(value);
67
+ out.push({
68
+ path,
69
+ prop: where,
70
+ kind: "",
71
+ url: value
72
+ });
73
+ }
74
+ return;
75
+ }
76
+ if (Array.isArray(value)) {
77
+ value.forEach((item, i) => {
78
+ deep(item, `${where}[${i}]`);
79
+ });
80
+ return;
81
+ }
82
+ if (value && typeof value === "object") for (const [key, inner] of Object.entries(value)) deep(inner, `${where}.${key}`);
83
+ };
84
+ for (const [key, value] of Object.entries(props)) {
85
+ if (typeof value === "string") continue;
86
+ deep(value, key);
87
+ }
62
88
  }
63
89
  if (Array.isArray(node.children)) for (const c of node.children) visit(c, path);
64
90
  };
@@ -1050,7 +1076,9 @@ function pointAtNode(scene, path, opts = {}) {
1050
1076
  if (!node) return null;
1051
1077
  const at = worldPosition(node);
1052
1078
  if (at.length < 2) return null;
1053
- const seen = describeFraming(scene, opts.viewport ? { viewport: opts.viewport } : {}).entries.find((e) => e.path === node.getPath());
1079
+ const entries = describeFraming(scene, opts.viewport ? { viewport: opts.viewport } : {}).entries;
1080
+ const here = node.getPath();
1081
+ const seen = entries.find((e) => e.path === here) ?? entries.find((e) => e.path.startsWith(`${here}/`));
1054
1082
  if (seen && seen.where === "offscreen") return null;
1055
1083
  if (scene.dimension === "2d") return [at[0] ?? 0, at[1] ?? 0];
1056
1084
  if (!seen?.screen) return null;
@@ -1186,6 +1214,71 @@ function worldOf(node) {
1186
1214
  /** Trigger volumes are destinations even without a script — that IS their job. */
1187
1215
  const TRIGGERS = new Set(["Area2D", "Area3D"]);
1188
1216
  /**
1217
+ * How big is this node, for the purpose of "did the player reach it"?
1218
+ *
1219
+ * Reach was measured against a node's ORIGIN, which is right for a coin and
1220
+ * wrong for anything with size: the origin of a 30×16 m quay is under its
1221
+ * middle, so a player who walked every plank of it was reported as never having
1222
+ * reached it. And a node earns its place on that list by carrying a GROUP,
1223
+ * which is exactly how every example here tags its ground.
1224
+ *
1225
+ * The half-diagonal of whatever the node declares — its collider first (that is
1226
+ * the thing you can stand on), then the mesh it draws — scaled by the node's
1227
+ * own scale. A node that declares no size gets 0 and behaves exactly as before.
1228
+ * Rotation is ignored on purpose: the diagonal is already the generous case.
1229
+ */
1230
+ function reachRadiusOf(node) {
1231
+ const num = (n) => typeof n === "number" && Number.isFinite(n) ? n : NaN;
1232
+ /** Half-extents plus how to turn them into ONE radius for this shape. */
1233
+ let half = null;
1234
+ let round = false;
1235
+ const collider = node.collider;
1236
+ if (collider && typeof collider === "object") {
1237
+ const shape = collider.shape;
1238
+ if (shape === "box" && Array.isArray(collider.size)) {
1239
+ const [x, y, z] = collider.size;
1240
+ half = [
1241
+ num(x) / 2,
1242
+ num(y) / 2,
1243
+ num(z) / 2
1244
+ ];
1245
+ } else if (shape === "sphere") {
1246
+ const r = num(collider.radius);
1247
+ half = [
1248
+ r,
1249
+ r,
1250
+ r
1251
+ ];
1252
+ round = true;
1253
+ } else if (shape === "capsule" || shape === "cylinder") {
1254
+ const r = num(collider.radius);
1255
+ half = [
1256
+ r,
1257
+ num(collider.height) / 2 + (shape === "capsule" ? r : 0),
1258
+ r
1259
+ ];
1260
+ }
1261
+ }
1262
+ if (!half && Array.isArray(node.size) && node.size.length >= 3) {
1263
+ const [x, y, z] = node.size;
1264
+ half = [
1265
+ num(x) / 2,
1266
+ num(y) / 2,
1267
+ num(z) / 2
1268
+ ];
1269
+ }
1270
+ if (!half) return 0;
1271
+ const s = Array.isArray(node.scale) ? node.scale : [];
1272
+ const at = (i) => Number.isFinite(num(s[i])) ? num(s[i]) : 1;
1273
+ const [hx, hy, hz] = [
1274
+ half[0] * at(0),
1275
+ half[1] * at(1),
1276
+ half[2] * at(2)
1277
+ ];
1278
+ const r = round ? Math.max(hx, hy, hz) : Math.hypot(hx, hy, hz);
1279
+ return Number.isFinite(r) ? r : 0;
1280
+ }
1281
+ /**
1189
1282
  * Is this node somewhere the player is meant to GO?
1190
1283
  *
1191
1284
  * Author intent, not geometry. Keying off the tree alone reported 150 unreached
@@ -1211,6 +1304,7 @@ function isTarget(node) {
1211
1304
  function makeOracle(engine, player) {
1212
1305
  let outcome = null;
1213
1306
  let damage = 0;
1307
+ let playerHealth = false;
1214
1308
  const offs = [];
1215
1309
  let declaresEnd = false;
1216
1310
  /** First verdict wins — a run ends at its first conclusion, not its last. */
@@ -1227,9 +1321,12 @@ function makeOracle(engine, player) {
1227
1321
  if (signal === "won") offs.push(node.on(signal, () => settle("won")));
1228
1322
  if (signal === "lost") offs.push(node.on(signal, () => settle("lost")));
1229
1323
  if (signal === "died" && isPlayerSide(node)) offs.push(node.on(signal, () => settle("lost")));
1230
- if (signal === "damaged" && isPlayerSide(node)) offs.push(node.on(signal, () => {
1231
- damage += 1;
1232
- }));
1324
+ if (signal === "damaged" && isPlayerSide(node)) {
1325
+ playerHealth = true;
1326
+ offs.push(node.on(signal, () => {
1327
+ damage += 1;
1328
+ }));
1329
+ }
1233
1330
  if (signal === "flowChanged") offs.push(node.on(signal, (state) => {
1234
1331
  if (state === "won") settle("won");
1235
1332
  if (state === "gameover") settle("lost");
@@ -1243,6 +1340,7 @@ function makeOracle(engine, player) {
1243
1340
  outcome: () => outcome,
1244
1341
  declaresEnd: () => declaresEnd,
1245
1342
  damage: () => damage,
1343
+ playerHasHealth: () => playerHealth,
1246
1344
  dispose: () => {
1247
1345
  for (const off of offs) off();
1248
1346
  }
@@ -1291,8 +1389,21 @@ var Driver = class {
1291
1389
  /** What was pressed most recently, so a freeze can be attributed to it. */
1292
1390
  lastPressed = null;
1293
1391
  moveAction;
1392
+ /**
1393
+ * The OTHER characters' move actions — a couch co-op game declares one action
1394
+ * set per player and tells each controller which to read (`moveAction`), and
1395
+ * this bot drove the first vector2 action it found. Measured on a two-player
1396
+ * scene: player one walked 17.3 m and player two walked 0.0 m for the whole
1397
+ * run, and the report said the game could never be won — about a game that is
1398
+ * won by two people walking.
1399
+ */
1400
+ extraMoves = [];
1294
1401
  /** The button the scene's controller calls its jump — `jump` unless it says otherwise. */
1295
1402
  jumpButton = "jump";
1403
+ /** Every controller's jump, so each player's jump gets the jump's odds. */
1404
+ jumpButtons = new Set(["jump"]);
1405
+ /** What the scene's controllers asked to read, in tree order. */
1406
+ controllerActions = [];
1296
1407
  /**
1297
1408
  * The action vocabulary this driver was bound to, so it can notice a swap.
1298
1409
  *
@@ -1407,16 +1518,24 @@ var Driver = class {
1407
1518
  }
1408
1519
  }
1409
1520
  if (root) {
1410
- const stack = [root];
1411
- while (stack.length > 0) {
1412
- const node = stack.pop();
1521
+ const walk = (node) => {
1413
1522
  if (CONTROLLERS.has(node.constructor.typeName ?? "")) {
1414
- const want = node.jumpAction;
1415
- if (typeof want === "string" && want !== "") this.jumpButton = want;
1416
- break;
1523
+ const jump = node.jumpAction;
1524
+ const move = node.moveAction;
1525
+ this.controllerActions.push({
1526
+ move: typeof move === "string" && move !== "" ? move : "move",
1527
+ jump: typeof jump === "string" && jump !== "" ? jump : "jump"
1528
+ });
1417
1529
  }
1418
- for (const child of node.children) stack.push(child);
1419
- }
1530
+ for (const child of node.children) walk(child);
1531
+ };
1532
+ walk(root);
1533
+ const first = this.controllerActions[0];
1534
+ if (first) this.jumpButton = first.jump;
1535
+ this.jumpButtons = new Set(this.controllerActions.map((c) => c.jump));
1536
+ if (this.jumpButtons.size === 0) this.jumpButtons = new Set([this.jumpButton]);
1537
+ this.vocabulary = "";
1538
+ this.rebind();
1420
1539
  }
1421
1540
  this.readsMouse = asks;
1422
1541
  const design = engine.scene?.viewport?.design;
@@ -1500,6 +1619,14 @@ var Driver = class {
1500
1619
  this.headingLeft = .4 + this.rand() * 1.2;
1501
1620
  }
1502
1621
  if (this.moveAction) this.engine.input.setActionVector(this.moveAction, Math.cos(this.heading), Math.sin(this.heading));
1622
+ for (const other of this.extraMoves) {
1623
+ other.left -= dt;
1624
+ if (other.left <= 0) {
1625
+ other.heading = this.rand() * Math.PI * 2;
1626
+ other.left = .5 + this.rand() * 1.5;
1627
+ }
1628
+ this.engine.input.setActionVector(other.action, Math.cos(other.heading), Math.sin(other.heading));
1629
+ }
1503
1630
  for (const button of this.buttons) {
1504
1631
  if (this.frozeTheGame.has(button) || this.avoid.has(button)) continue;
1505
1632
  const left = (this.held.get(button) ?? 0) - dt;
@@ -1512,7 +1639,7 @@ var Driver = class {
1512
1639
  this.held.set(button, -1);
1513
1640
  continue;
1514
1641
  }
1515
- const chance = button === this.jumpButton ? .06 : .02;
1642
+ const chance = this.jumpButtons.has(button) ? .06 : .02;
1516
1643
  if (this.rand() < chance) {
1517
1644
  this.engine.input.pressAction(button);
1518
1645
  this.actionPresses += 1;
@@ -1707,7 +1834,16 @@ var Driver = class {
1707
1834
  const signature = declared.map((a) => `${a.type}:${a.name}`).join(",");
1708
1835
  if (signature === this.vocabulary) return;
1709
1836
  this.vocabulary = signature;
1710
- this.moveAction = declared.find((a) => a.type === "vector2")?.name ?? null;
1837
+ const vectors = declared.filter((a) => a.type === "vector2").map((a) => a.name);
1838
+ const wanted = this.controllerActions[0]?.move;
1839
+ this.moveAction = (wanted && vectors.includes(wanted) ? wanted : vectors[0]) ?? null;
1840
+ const others = new Set(this.controllerActions.slice(1).map((c) => c.move).filter((name) => name !== this.moveAction && vectors.includes(name)));
1841
+ this.extraMoves = this.extraMoves.filter((m) => others.has(m.action));
1842
+ for (const action of others) if (!this.extraMoves.some((m) => m.action === action)) this.extraMoves.push({
1843
+ action,
1844
+ heading: this.rand() * Math.PI * 2,
1845
+ left: 0
1846
+ });
1711
1847
  this.buttons = declared.filter((a) => a.type === "button").map((a) => a.name);
1712
1848
  const still = new Set(this.buttons);
1713
1849
  for (const button of [...this.held.keys()]) if (!still.has(button)) this.held.delete(button);
@@ -1718,6 +1854,7 @@ var Driver = class {
1718
1854
  this.rebind();
1719
1855
  for (const button of this.buttons) this.engine.input.releaseAction(button);
1720
1856
  if (this.moveAction) this.engine.input.setActionVector(this.moveAction, 0, 0);
1857
+ for (const other of this.extraMoves) this.engine.input.setActionVector(other.action, 0, 0);
1721
1858
  }
1722
1859
  };
1723
1860
  async function runOnce(json, seed, opts, avoid = /* @__PURE__ */ new Set()) {
@@ -1776,7 +1913,11 @@ async function runOnce(json, seed, opts, avoid = /* @__PURE__ */ new Set()) {
1776
1913
  const targetNodes = [];
1777
1914
  const collect = (node) => {
1778
1915
  if (SCREEN_SPACE.has(node.constructor.typeName ?? "")) return;
1779
- if (node !== player && isTarget(node)) targetNodes.push([node.getPath(), node]);
1916
+ if (node !== player && isTarget(node)) targetNodes.push([
1917
+ node.getPath(),
1918
+ node,
1919
+ reachRadiusOf(node)
1920
+ ]);
1780
1921
  for (const child of node.children) collect(child);
1781
1922
  };
1782
1923
  collect(scene.root);
@@ -1834,10 +1975,10 @@ async function runOnce(json, seed, opts, avoid = /* @__PURE__ */ new Set()) {
1834
1975
  for (const path of engine.audio.sources()) heardSoFar.add(path);
1835
1976
  for (const path of engine.effects.sources()) shownSoFar.add(path);
1836
1977
  session.step(stepMs);
1837
- if (player) for (const [path, node] of targetNodes) {
1978
+ if (player) for (const [path, node, extent] of targetNodes) {
1838
1979
  if (reached.has(path)) continue;
1839
1980
  const p = worldOf(node);
1840
- if (Math.hypot(at[0] - p[0], at[1] - p[1], at[2] - p[2]) <= radius) reached.add(path);
1981
+ if (Math.hypot(at[0] - p[0], at[1] - p[1], at[2] - p[2]) <= radius + extent) reached.add(path);
1841
1982
  }
1842
1983
  const verdict = oracle.outcome();
1843
1984
  if (verdict) {
@@ -1904,6 +2045,7 @@ async function runOnce(json, seed, opts, avoid = /* @__PURE__ */ new Set()) {
1904
2045
  declaresEnd: oracle.declaresEnd(),
1905
2046
  hazards,
1906
2047
  hasPlayer: player !== null,
2048
+ playerHasHealth: oracle.playerHasHealth(),
1907
2049
  froze: driver.paused(),
1908
2050
  restartedBy,
1909
2051
  unread: unreadNow
@@ -1935,6 +2077,7 @@ async function playtest(json, opts = {}) {
1935
2077
  let signals = [];
1936
2078
  let declaresWin = false;
1937
2079
  let hasPlayer = false;
2080
+ let playerHasHealth = false;
1938
2081
  let hazards = 0;
1939
2082
  const froze = /* @__PURE__ */ new Set();
1940
2083
  const restarted = /* @__PURE__ */ new Set();
@@ -1951,6 +2094,7 @@ async function playtest(json, opts = {}) {
1951
2094
  signals = one.signals;
1952
2095
  declaresWin = declaresWin || one.declaresEnd;
1953
2096
  hasPlayer = hasPlayer || one.hasPlayer;
2097
+ playerHasHealth = playerHasHealth || one.playerHasHealth;
1954
2098
  hazards = one.hazards;
1955
2099
  for (const action of one.froze) froze.add(action);
1956
2100
  if (one.restartedBy) restarted.add(one.restartedBy);
@@ -2000,6 +2144,7 @@ async function playtest(json, opts = {}) {
2000
2144
  warnings: [...new Set(runs.flatMap((r) => r.warnings))],
2001
2145
  declaresWin,
2002
2146
  hasPlayer,
2147
+ playerHasHealth,
2003
2148
  hazards,
2004
2149
  froze: [...froze],
2005
2150
  restarted: [...restarted],
@@ -2183,7 +2328,8 @@ function playtestText(report) {
2183
2328
  lines.push("");
2184
2329
  if (damage > 0) lines.push(` danger: ${damage} hits across ${total} runs (${(damage / total).toFixed(1)} per run)`);
2185
2330
  else if (hazards === 0 && lost > 0) lines.push(` danger: nothing DEALT DAMAGE — no DamageOnContact, no hazard group — and yet ${lost} of ${total} runs were lost. The loss comes from something else: a fall, a timer, a trigger, or running out of lives.`);
2186
- else if (hazards === 0) lines.push(" danger: nothing in this scene can hurt the player no DamageOnContact, no hazard group. A game with no way to lose is a walkabout.");
2331
+ else if (hazards === 0 && report.playerHasHealth) lines.push(" danger: no DamageOnContact is wired and no run took a hit — but the player HAS a Health, so either nothing reached it, or the damage comes from a behavior of your own calling `Health.damage()`, which this cannot see. A scripted run is the check.");
2332
+ else if (hazards === 0) lines.push(" danger: nothing in this scene can hurt the player — no DamageOnContact, and no player-side Health to lose. A game with no way to lose is a walkabout.");
2187
2333
  else lines.push(` danger: ${hazards} hazard(s) are wired and none of them landed a hit in ${total} runs. They may be unreachable — or the bot may simply have outrun them, which a real player would not. Judge it with a scripted run.`);
2188
2334
  return lines.join("\n");
2189
2335
  }
@@ -2232,11 +2378,73 @@ function failingReplays(report) {
2232
2378
  }));
2233
2379
  }
2234
2380
  //#endregion
2381
+ //#region src/test/start-the-game.ts
2382
+ /** Enough for a title screen's worth of buttons, not enough to be a bot. */
2383
+ const MAX_PRESSES = 8;
2384
+ const START = /play|start|run|begin|resume|continue|new|go\b/i;
2385
+ /** What a probe is allowed to sit through before deciding the game is stuck. */
2386
+ const MAX_WAIT_FRAMES = 900;
2387
+ /**
2388
+ * @returns what state the world reached — see `StartResult`.
2389
+ */
2390
+ function startTheGame(ctx) {
2391
+ const started = ctx.engine.timeScale !== 0 ? true : pressStart(ctx);
2392
+ return {
2393
+ running: started,
2394
+ controllable: started && waitForControl(ctx)
2395
+ };
2396
+ }
2397
+ /**
2398
+ * Sit out an opening cutscene.
2399
+ *
2400
+ * A cutscene takes control by switching the controller off, not by stopping the
2401
+ * clock, so nothing above can see it. The wait is BOUNDED and it only ever
2402
+ * happens to a game that actually switched the controller off — a game that is
2403
+ * playable from frame one pays four frames, which every probe in `feel.ts`
2404
+ * already spends settling.
2405
+ */
2406
+ function waitForControl(ctx) {
2407
+ ctx.step(4);
2408
+ const controller = findController(ctx.root);
2409
+ if (!controller) return true;
2410
+ for (let waited = 0; waited < MAX_WAIT_FRAMES && controller.enabled === false; waited += 30) ctx.step(30);
2411
+ return controller.enabled !== false;
2412
+ }
2413
+ function findController(root) {
2414
+ const type = root.constructor.typeName ?? "";
2415
+ if (type === "CharacterController2D" || type === "CharacterController3D") return root;
2416
+ for (const child of root.children) {
2417
+ const found = findController(child);
2418
+ if (found) return found;
2419
+ }
2420
+ return null;
2421
+ }
2422
+ function pressStart(ctx) {
2423
+ const pressable = [];
2424
+ const walk = (node) => {
2425
+ const layer = node;
2426
+ if (typeof layer.focusables === "function") for (const widget of layer.focusables()) {
2427
+ const w = widget;
2428
+ if (typeof w.press === "function") pressable.push(w);
2429
+ }
2430
+ for (const child of node.children) walk(child);
2431
+ };
2432
+ walk(ctx.root);
2433
+ const label = (w) => typeof w.text === "string" ? w.text : "";
2434
+ const order = [...pressable.filter((w) => START.test(label(w))), ...pressable.filter((w) => !START.test(label(w)))];
2435
+ for (const widget of order.slice(0, MAX_PRESSES)) {
2436
+ widget.press();
2437
+ ctx.step(4);
2438
+ if (ctx.engine.timeScale !== 0) return true;
2439
+ }
2440
+ return ctx.engine.timeScale !== 0;
2441
+ }
2442
+ //#endregion
2235
2443
  //#region src/test/facing.ts
2236
- const DEG2RAD = Math.PI / 180;
2444
+ const DEG2RAD$1 = Math.PI / 180;
2237
2445
  /** The forward vector of a yaw (degrees) for a given art convention. */
2238
2446
  function forwardOf(yawDeg, axis) {
2239
- const yaw = yawDeg * DEG2RAD;
2447
+ const yaw = yawDeg * DEG2RAD$1;
2240
2448
  const base = [Math.sin(yaw), Math.cos(yaw)];
2241
2449
  switch (axis) {
2242
2450
  case "-z": return [-base[0], -base[1]];
@@ -2306,6 +2514,11 @@ async function facingReport(json, opts = {}) {
2306
2514
  const p = player.position;
2307
2515
  return [p[0] ?? 0, (is2d ? p[1] : p[2]) ?? 0];
2308
2516
  };
2517
+ const { running } = startTheGame({
2518
+ engine: session.engine,
2519
+ root: session.scene.root,
2520
+ step: (frames) => session.step(frames * 1e3 / 60)
2521
+ });
2309
2522
  session.step(500);
2310
2523
  const from = posOf();
2311
2524
  if (move) session.engine.input.setActionVector(move, 1, 0);
@@ -2317,7 +2530,9 @@ async function facingReport(json, opts = {}) {
2317
2530
  const travelled = Math.hypot(dx, dz);
2318
2531
  const { skin, declaredOffset } = findSkin(player, opts.skinPath);
2319
2532
  const skinPath = skin ? skin.getPath() : null;
2320
- if (!skin || travelled < (is2d ? 2 : .05)) {
2533
+ const stillness = is2d ? 2 : .05;
2534
+ const lockedOn = player.children.some((c) => c.lockTarget instanceof Object);
2535
+ if (skin && lockedOn) {
2321
2536
  session.dispose();
2322
2537
  return {
2323
2538
  player: player.getPath(),
@@ -2326,7 +2541,19 @@ async function facingReport(json, opts = {}) {
2326
2541
  backwards: false,
2327
2542
  travelled: Math.round(travelled * 1e3) / 1e3,
2328
2543
  basis: null,
2329
- noAnswer: skin ? "did-not-move" : "no-skin"
2544
+ noAnswer: "locked-on"
2545
+ };
2546
+ }
2547
+ if (!skin || travelled < stillness) {
2548
+ session.dispose();
2549
+ return {
2550
+ player: player.getPath(),
2551
+ skin: skinPath,
2552
+ dot: null,
2553
+ backwards: false,
2554
+ travelled: Math.round(travelled * 1e3) / 1e3,
2555
+ basis: null,
2556
+ noAnswer: running ? skin ? "did-not-move" : "no-skin" : "paused"
2330
2557
  };
2331
2558
  }
2332
2559
  const heading = is2d ? forward2D(skin, dx, dz, declaredOffset) : {
@@ -2381,7 +2608,7 @@ function forward2D(skin, dx, dy, declaredOffset) {
2381
2608
  };
2382
2609
  }
2383
2610
  if (typeof s.rotation === "number") {
2384
- const yaw = (s.rotation - declaredOffset) * DEG2RAD;
2611
+ const yaw = (s.rotation - declaredOffset) * DEG2RAD$1;
2385
2612
  return {
2386
2613
  forward: [Math.cos(yaw), Math.sin(yaw)],
2387
2614
  basis: "yaw"
@@ -2396,7 +2623,9 @@ function forward2D(skin, dx, dy, declaredOffset) {
2396
2623
  function facingText(report) {
2397
2624
  if (!report.player) return "facing: no player to drive";
2398
2625
  if (report.skin === null) return `facing: ${report.player} has no skin to check`;
2626
+ if (report.noAnswer === "locked-on") return `facing: ${report.skin} is LOCKED ON (the controller's \`lockTarget\`), so it faces its target rather than its travel — a strafe is the intended reading, not a defect.`;
2399
2627
  if (report.noAnswer === "no-heading") return `facing: ${report.skin} declares no heading (no flipX, no rotation) — nothing to compare its travel with`;
2628
+ if (report.noAnswer === "paused") return "facing: this game was PAUSED for the whole probe (`engine.timeScale` 0) and stayed that way after pressing every button on screen — a title screen holds the world until its START is pressed, so nothing moved and nothing was compared.";
2400
2629
  if (report.dot === null) return `facing: ${report.player} did not move — nothing to compare`;
2401
2630
  if (report.backwards) return `facing: ${report.skin} RUNS BACKWARDS (dot ${report.dot.toFixed(2)}) — the skin points away from the direction of travel. The rule is +Z forward, yaw = atan2(dx, dz), with NO +180; for art that faces another way set skinYawOffset rather than adding to the yaw.`;
2402
2631
  return `facing: ${report.skin} faces its travel (dot ${report.dot.toFixed(2)})`;
@@ -2410,6 +2639,56 @@ const REL_TOL = .005;
2410
2639
  function closeEnough(a, b, absTol, relTol) {
2411
2640
  return Math.abs(a - b) <= Math.max(absTol, relTol * Math.abs(a));
2412
2641
  }
2642
+ const RAD2DEG = 180 / Math.PI;
2643
+ /**
2644
+ * The slack a moving owner earns on one key: speed × window for a position,
2645
+ * spin × window (in degrees) for a rotation, nothing for anything else. Keyed
2646
+ * by the LAST path segment, so `Wheel0.rotation` is a rotation too.
2647
+ */
2648
+ function motionSlack(key, motion) {
2649
+ if (!motion) return 0;
2650
+ const prop = key.slice(key.lastIndexOf(".") + 1);
2651
+ if (prop === "position") return motion.speed * motion.windowSeconds;
2652
+ if (prop === "rotation") return motion.spin * motion.windowSeconds * RAD2DEG;
2653
+ return 0;
2654
+ }
2655
+ /** Degrees two copies of one orientation may differ by at rest (a float's worth). */
2656
+ const ROTATION_TOL_DEG = .01;
2657
+ const DEG2RAD = Math.PI / 180;
2658
+ const isRotationKey = (key) => key.slice(key.lastIndexOf(".") + 1) === "rotation";
2659
+ /** Euler XYZ degrees → unit quaternion [x, y, z, w] (three.js order). */
2660
+ function quatOf(e) {
2661
+ const c1 = Math.cos((e[0] ?? 0) * DEG2RAD / 2);
2662
+ const c2 = Math.cos((e[1] ?? 0) * DEG2RAD / 2);
2663
+ const c3 = Math.cos((e[2] ?? 0) * DEG2RAD / 2);
2664
+ const s1 = Math.sin((e[0] ?? 0) * DEG2RAD / 2);
2665
+ const s2 = Math.sin((e[1] ?? 0) * DEG2RAD / 2);
2666
+ const s3 = Math.sin((e[2] ?? 0) * DEG2RAD / 2);
2667
+ return [
2668
+ s1 * c2 * c3 + c1 * s2 * s3,
2669
+ c1 * s2 * c3 - s1 * c2 * s3,
2670
+ c1 * c2 * s3 + s1 * s2 * c3,
2671
+ c1 * c2 * c3 - s1 * s2 * s3
2672
+ ];
2673
+ }
2674
+ /**
2675
+ * The angle, in degrees, between two rotations written as the engine writes
2676
+ * them: one number (2D, degrees) or an Euler XYZ triple (3D, degrees). Any
2677
+ * other shape falls back to the plain component gap.
2678
+ */
2679
+ function orientationGapDeg(a, b) {
2680
+ if (a.length === 1 && b.length === 1) {
2681
+ const d = Math.abs(((a[0] ?? 0) - (b[0] ?? 0)) % 360);
2682
+ return Math.min(d, 360 - d);
2683
+ }
2684
+ if (a.length === 3 && b.length === 3) {
2685
+ const p = quatOf(a);
2686
+ const q = quatOf(b);
2687
+ const dot = Math.min(1, Math.abs(p[0] * q[0] + p[1] * q[1] + p[2] * q[2] + p[3] * q[3]));
2688
+ return 2 * Math.acos(dot) / DEG2RAD;
2689
+ }
2690
+ return Math.max(...a.map((v, i) => Math.abs(v - (b[i] ?? 0))));
2691
+ }
2413
2692
  function numbers(v) {
2414
2693
  if (typeof v === "number") return [v];
2415
2694
  if (Array.isArray(v) && v.every((x) => typeof x === "number")) return v;
@@ -2474,7 +2753,8 @@ function agreementProblems(live, late = [], opts = {}) {
2474
2753
  });
2475
2754
  continue;
2476
2755
  }
2477
- if (a.every((v, i) => closeEnough(v, b[i], absTol, relTol))) continue;
2756
+ const slack = motionSlack(key, s.motion);
2757
+ if (isRotationKey(key) ? orientationGapDeg(a, b) <= ROTATION_TOL_DEG + slack : a.every((v, i) => closeEnough(v, b[i], absTol + slack, relTol))) continue;
2478
2758
  found.push({
2479
2759
  kind: "mismatch",
2480
2760
  account: s.account,
@@ -2632,6 +2912,21 @@ function instancesByName(engine) {
2632
2912
  * @param clients every client in the match
2633
2913
  * @param observers the clients whose beliefs to record (defaults to `clients`)
2634
2914
  */
2915
+ /** The server's queue turns every six frames in the harness — that is latency too. */
2916
+ const HARNESS_TURN_SECONDS = .1;
2917
+ const DEFAULT_THROTTLE_MS = 50;
2918
+ const magnitude = (v) => Array.isArray(v) && v.every((n) => typeof n === "number") ? Math.hypot(...v) : 0;
2919
+ /** How fast the owner is moving right now, and how stale its copies may be. */
2920
+ function motionOf(node) {
2921
+ const n = node;
2922
+ const net = node.network ?? {};
2923
+ const throttleMs = typeof net.throttleMs === "number" ? Math.max(30, net.throttleMs) : DEFAULT_THROTTLE_MS;
2924
+ return {
2925
+ speed: magnitude(n.linearVelocity),
2926
+ spin: magnitude(n.angularVelocity),
2927
+ windowSeconds: throttleMs / 1e3 + HARNESS_TURN_SECONDS
2928
+ };
2929
+ }
2635
2930
  function takeAgreementSnapshots(clients, observers = clients) {
2636
2931
  const snapshots = [];
2637
2932
  for (const owner of clients) {
@@ -2668,7 +2963,8 @@ function takeAgreementSnapshots(clients, observers = clients) {
2668
2963
  account: owner.account,
2669
2964
  truth,
2670
2965
  seen,
2671
- ...unreadable.length > 0 ? { unreadable } : {}
2966
+ ...unreadable.length > 0 ? { unreadable } : {},
2967
+ motion: motionOf(node)
2672
2968
  });
2673
2969
  }
2674
2970
  return snapshots;
@@ -2754,7 +3050,7 @@ async function makeProbe(json, opts) {
2754
3050
  const declared = session.engine.input.declaredActions();
2755
3051
  const moveOptions = declared.filter((a) => a.type === "vector2").map((a) => a.name);
2756
3052
  const named = (prop, type) => {
2757
- const want = controllerProp(session.scene.root, prop);
3053
+ const want = controllerProp(session.scene.root, prop, player);
2758
3054
  return want && declared.some((a) => a.name === want && a.type === type) ? want : null;
2759
3055
  };
2760
3056
  const move = opts.moveAction ?? named("moveAction", "vector2") ?? moveOptions[0] ?? null;
@@ -2767,34 +3063,44 @@ async function makeProbe(json, opts) {
2767
3063
  move,
2768
3064
  jump,
2769
3065
  sprint,
2770
- moveOptions
3066
+ moveOptions,
3067
+ paused: false,
3068
+ controllerOff: false
2771
3069
  };
2772
3070
  }
2773
3071
  const is2d = session.scene.dimension === "2d";
2774
- return {
2775
- probe: {
2776
- session,
2777
- engine: session.engine,
2778
- player,
2779
- is2d,
2780
- up: is2d ? -1 : 1,
2781
- lift: is2d ? PX_LIFT : UNIT_LIFT,
2782
- step: (frames = 1) => session.step(frames * STEP_MS),
2783
- pos: () => positionOf(player),
2784
- vel: () => velocityOf(player),
2785
- speed: () => {
2786
- const v = velocityOf(player);
2787
- return Math.hypot(v[0], v[2]);
2788
- },
2789
- onFloor: () => {
2790
- const body = player;
2791
- return typeof body.isOnFloor === "function" ? body.isOnFloor() : null;
2792
- }
3072
+ const probe = {
3073
+ session,
3074
+ engine: session.engine,
3075
+ player,
3076
+ is2d,
3077
+ up: is2d ? -1 : 1,
3078
+ lift: is2d ? PX_LIFT : UNIT_LIFT,
3079
+ step: (frames = 1) => session.step(frames * STEP_MS),
3080
+ pos: () => positionOf(player),
3081
+ vel: () => velocityOf(player),
3082
+ speed: () => {
3083
+ const v = velocityOf(player);
3084
+ return Math.hypot(v[0], v[2]);
2793
3085
  },
3086
+ onFloor: () => {
3087
+ const body = player;
3088
+ return typeof body.isOnFloor === "function" ? body.isOnFloor() : null;
3089
+ }
3090
+ };
3091
+ const state = startTheGame({
3092
+ engine: session.engine,
3093
+ root: session.scene.root,
3094
+ step: (frames) => probe.step(frames)
3095
+ });
3096
+ return {
3097
+ probe,
2794
3098
  move,
2795
3099
  jump,
2796
3100
  sprint,
2797
- moveOptions
3101
+ moveOptions,
3102
+ paused: !state.running,
3103
+ controllerOff: state.running && !state.controllable
2798
3104
  };
2799
3105
  }
2800
3106
  /** Let the character settle onto whatever it is standing on. */
@@ -3044,20 +3350,20 @@ async function measureCoyote(json, opts, jump, jumps = 1) {
3044
3350
  return Math.round((lo + 1) * STEP_MS);
3045
3351
  }
3046
3352
  /**
3047
- * `coyoteSeconds` as written in the scene, in ms — for CONTRAST, never instead.
3353
+ * A controller's declared window as written in the scene, in ms — for CONTRAST.
3048
3354
  *
3049
3355
  * The whole point of this module is that a number is measured rather than read
3050
3356
  * off the schema. This one is read, and it earns its place by disagreeing: a
3051
3357
  * controller that declares 0.12 and measures "always" is telling you something
3052
3358
  * neither number says alone.
3053
3359
  */
3054
- function declaredCoyote(json) {
3360
+ function declaredControllerMs(json, prop) {
3055
3361
  let found = null;
3056
3362
  const walk = (node) => {
3057
3363
  if (!node || typeof node !== "object") return;
3058
3364
  const n = node;
3059
3365
  if (typeof n.type === "string" && /^CharacterController[23]D$/.test(n.type)) {
3060
- const value = n.props?.coyoteSeconds;
3366
+ const value = n.props?.[prop];
3061
3367
  if (typeof value === "number" && found === null) found = Math.round(value * 1e3);
3062
3368
  }
3063
3369
  for (const child of n.children ?? []) walk(child);
@@ -3170,7 +3476,11 @@ async function feelReport(json, opts = {}) {
3170
3476
  airtimeMs: null,
3171
3477
  coyoteMs: null,
3172
3478
  jumpsInMidair: false,
3479
+ neverJumped: false,
3480
+ controllerOff: false,
3173
3481
  declaredCoyoteMs: null,
3482
+ declaredJumpBufferMs: null,
3483
+ timeStood: false,
3174
3484
  declaredMaxJumps: 1,
3175
3485
  doubleJump: null,
3176
3486
  jumpBufferMs: null,
@@ -3336,8 +3646,12 @@ async function feelReport(json, opts = {}) {
3336
3646
  airtimeMs: shape.airtimeMs,
3337
3647
  coyoteMs: coyote,
3338
3648
  jumpsInMidair: coyote === null && jump !== null,
3339
- declaredCoyoteMs: declaredCoyote(json),
3649
+ declaredCoyoteMs: declaredControllerMs(json, "coyoteSeconds"),
3650
+ declaredJumpBufferMs: declaredControllerMs(json, "jumpBufferSeconds"),
3340
3651
  jumpBufferMs: buffer,
3652
+ timeStood: first.paused && topSpeed <= .05 && shape.apex === null,
3653
+ controllerOff: first.controllerOff && topSpeed <= .05,
3654
+ neverJumped: jump !== null && shape.apex === null && held.apex === null && walking.apex === null && sprinting.apex === null,
3341
3655
  inputToMotionFrames: latency,
3342
3656
  declaredMaxJumps: jumps,
3343
3657
  doubleJump,
@@ -3362,7 +3676,14 @@ function feelText(report) {
3362
3676
  const names = report.moveTried.map((n) => `'${n}'`).join(", ");
3363
3677
  lines.push(` pushed ${names} every way (±x, ±y) — the player did not move`);
3364
3678
  lines.push("");
3365
- lines.push(` could not move ${report.player}: no motion in 300 frames with ${names} pushed ±x and ±y. Either the movement is not wired to a vector action, or it is driven by buttons. Nothing below was measured.`);
3679
+ if (report.controllerOff) {
3680
+ lines.push(" the player's CONTROLLER was switched off for the whole measurement, and stayed off through fifteen seconds of waiting. A cutscene does this — the clock runs, the world");
3681
+ lines.push(" moves, and only the character is held. Nothing below was measured. Measure the scene the player PLAYS, or end the cutscene sooner than fifteen seconds.");
3682
+ } else if (report.timeStood) {
3683
+ lines.push(" the clock never ran: this game was PAUSED for the whole measurement (`engine.timeScale` 0), and it stayed that way after pressing every button on");
3684
+ lines.push(" screen. A title screen holds the world like this until its START is pressed. NOTHING below was measured — every number is the stopped world, not the game.");
3685
+ lines.push(" Point this at the scene the game PLAYS, or give the start button a label the probe can find (PLAY / START / RESUME).");
3686
+ } else lines.push(` could not move ${report.player}: no motion in 300 frames with ${names} pushed ±x and ±y. Either the movement is not wired to a vector action, or it is driven by buttons. Nothing below was measured.`);
3366
3687
  } else lines.push(` pushed '${report.moveAction}' [${report.moveAxis.join(", ")}]`);
3367
3688
  if (report.moveAxis) {
3368
3689
  lines.push(` top speed ${report.topSpeed} u/s`);
@@ -3384,8 +3705,16 @@ function feelText(report) {
3384
3705
  if (report.doubleJump) lines.push(` ${report.declaredMaxJumps === 2 ? "double jump" : `${report.declaredMaxJumps} jumps`} ${report.doubleJump.apex} u (${report.declaredMaxJumps} TAPS, maxJumps ${report.declaredMaxJumps}${report.doubleJump.airtimeMs !== null ? `, ${ms(report.doubleJump.airtimeMs)} airtime` : ""})`);
3385
3706
  lines.push(` time to apex ${ms(report.timeToApexMs)}`);
3386
3707
  lines.push(` airtime ${ms(report.airtimeMs)}${cut && report.heldAirtimeMs !== null ? ` (${ms(report.heldAirtimeMs)} held)` : ""}`);
3387
- lines.push(` coyote window ${report.jumpsInMidair ? "not measurable this character jumps in MID-AIR" : report.coyoteMs === null ? "always (no window)" : ms(report.coyoteMs)}`);
3388
- lines.push(` jump buffer ${report.jumpBufferMs === null ? "always (no window)" : ms(report.jumpBufferMs)}${report.jumpsInMidair ? " (suspectsee above)" : ""}`);
3708
+ const declared = (v) => v === null ? "" : ` (the scene declares ${ms(v)})`;
3709
+ lines.push(` coyote window ${report.timeStood ? `not measured the clock was stopped${declared(report.declaredCoyoteMs)}` : report.neverJumped ? `not measured — nothing ever jumped${declared(report.declaredCoyoteMs)}` : report.jumpsInMidair ? "not measurable this character jumps in MID-AIR" : report.coyoteMs === null ? "always (no window)" : ms(report.coyoteMs)}`);
3710
+ lines.push(` jump buffer ${report.timeStood ? `not measured — the clock was stopped${declared(report.declaredJumpBufferMs)}` : report.neverJumped ? `not measured — nothing ever jumped${declared(report.declaredJumpBufferMs)}` : report.jumpBufferMs === null ? "always (no window)" : ms(report.jumpBufferMs)}${report.jumpsInMidair && !report.timeStood && !report.neverJumped ? " (suspect — see above)" : ""}`);
3711
+ if (report.neverJumped && !report.timeStood) {
3712
+ lines.push("");
3713
+ lines.push(` note: this scene declares '${report.jumpAction}' and NOTHING left the ground in any`);
3714
+ lines.push(" of the four jump probes — standing, held, walking, sprinting. Check that the");
3715
+ lines.push(` controller reads '${report.jumpAction}', that its \`jumpVelocity\` is not 0, and that it is \`enabled\`.`);
3716
+ lines.push(" Both windows above are searched by jumping, so neither was measured.");
3717
+ }
3389
3718
  if (report.jumpsInMidair) {
3390
3719
  lines.push("");
3391
3720
  lines.push(" note: a jump still fires a full second after leaving the ground, so");
@@ -3394,12 +3723,12 @@ function feelText(report) {
3394
3723
  lines.push(" while `jump` is held — a glide, a jetpack, an updraft.");
3395
3724
  if (report.declaredCoyoteMs !== null) lines.push(` The controller DECLARES coyoteSeconds ${ms(report.declaredCoyoteMs)}; this probe cannot confirm it.`);
3396
3725
  }
3397
- if (report.coyoteMs === 0) {
3726
+ if (report.coyoteMs === 0 && !report.timeStood && !report.neverJumped) {
3398
3727
  lines.push("");
3399
3728
  lines.push(" note: coyote window is 0 — a jump that only fires while the ground check");
3400
3729
  lines.push(" is true reads to players as unresponsive controls. Try coyoteSeconds: 0.12.");
3401
3730
  }
3402
- if (report.jumpBufferMs === 0) {
3731
+ if (report.jumpBufferMs === 0 && !report.timeStood && !report.neverJumped) {
3403
3732
  lines.push(" note: jump buffer is 0 — a press a few frames before landing is thrown away.");
3404
3733
  lines.push(" Try jumpBufferSeconds: 0.15.");
3405
3734
  }
@@ -3413,19 +3742,35 @@ function feelText(report) {
3413
3742
  }
3414
3743
  return lines.join("\n");
3415
3744
  }
3416
- /** The action name a `CharacterController2D/3D` in this tree says it reads. */
3417
- function controllerProp(root, prop) {
3418
- const stack = [root];
3419
- while (stack.length > 0) {
3420
- const node = stack.pop();
3421
- const type = node.constructor.typeName ?? "";
3745
+ /**
3746
+ * The action name the CHOSEN player's controller says it reads.
3747
+ *
3748
+ * This took the first controller a stack-walk found anywhere in the tree, which
3749
+ * in a two-player scene is the LAST one in the file. Measured on
3750
+ * `examples/coop-3d`, a local co-op forge: the report chose `Smith1` and then
3751
+ * drove it with `jump2`, Smith2's button, so a character that jumps 0.563 m
3752
+ * reported `jump apex — (nothing left the ground)` and a coyote window of 0 at
3753
+ * a controller declaring `coyoteSeconds: 0.12`. Move survived only because the
3754
+ * move probe has a fallback SEARCH; jump has none.
3755
+ *
3756
+ * Two rules, and `coop-3d` needed both to be wrong at once: ask the player that
3757
+ * was chosen FIRST, and walk in document order so "the first controller" means
3758
+ * the first one in the file.
3759
+ */
3760
+ function controllerProp(root, prop, player = null) {
3761
+ const find = (from) => {
3762
+ const type = from.constructor.typeName ?? "";
3422
3763
  if (type === "CharacterController2D" || type === "CharacterController3D") {
3423
- const value = node[prop];
3764
+ const value = from[prop];
3424
3765
  return typeof value === "string" && value !== "" ? value : null;
3425
3766
  }
3426
- for (const child of node.children) stack.push(child);
3427
- }
3428
- return null;
3767
+ for (const child of from.children) {
3768
+ const found = find(child);
3769
+ if (found !== null) return found;
3770
+ }
3771
+ return null;
3772
+ };
3773
+ return (player ? find(player) : null) ?? find(root);
3429
3774
  }
3430
3775
  //#endregion
3431
3776
  //#region src/test/multiplay.ts
@@ -3457,6 +3802,7 @@ async function playMultiplayer(opts) {
3457
3802
  if (frame % 6 === 0) await settle();
3458
3803
  }
3459
3804
  await settle();
3805
+ for (const player of players) player.engine.input.clearInjected();
3460
3806
  for (let frame = 0; frame < 60; frame++) {
3461
3807
  now += stepMs;
3462
3808
  for (const player of players) player.engine.tick(now);
@@ -3464,6 +3810,7 @@ async function playMultiplayer(opts) {
3464
3810
  }
3465
3811
  await settle();
3466
3812
  const live = takeAgreementSnapshots(players);
3813
+ const moving = live.filter((s) => (s.motion?.speed ?? 0) > .05 || (s.motion?.spin ?? 0) > .05).map((s) => s.account);
3467
3814
  const matchDetail = players.map((p) => ({
3468
3815
  account: p.account,
3469
3816
  room: p.manager.roomId,
@@ -3496,7 +3843,8 @@ async function playMultiplayer(opts) {
3496
3843
  disagreements: [...agreementProblems(live, lateSnapshots), ...collectionProblems(collections)],
3497
3844
  keysCompared: live.reduce((n, s) => n + Object.keys(s.truth).length * Object.keys(s.seen).length, 0) + collections.reduce((n, c) => n + c.received.length, 0),
3498
3845
  lateJoinChecked,
3499
- ...lateJoinRefused ? { lateJoinRefused } : {}
3846
+ ...lateJoinRefused ? { lateJoinRefused } : {},
3847
+ moving
3500
3848
  });
3501
3849
  dispose();
3502
3850
  return report;
@@ -3510,11 +3858,14 @@ async function playMultiplayer(opts) {
3510
3858
  */
3511
3859
  async function joinLate(server, first, opts) {
3512
3860
  try {
3513
- const { Engine } = await import("./register-CjociOtt.js").then((n) => n.y);
3514
- const { loadScene } = await import("./save-slots-CEUGeLuB.js").then((n) => n.g);
3861
+ const { Engine } = await import("./register-B768Qedj.js").then((n) => n.x);
3862
+ const { loadScene } = await import("./pose-ByFB_J3O.js").then((n) => n.x);
3515
3863
  const { jsonClone } = await import("./json-CfTjpvW8.js").then((n) => n.i);
3516
3864
  const engine = new Engine(opts.seed !== void 0 ? { seed: opts.seed + 999 } : {});
3517
- engine.setScene(loadScene(jsonClone(opts.scene), opts.load));
3865
+ engine.setScene(loadScene(jsonClone(opts.scene), {
3866
+ ...opts.load,
3867
+ engine
3868
+ }));
3518
3869
  const manager = await NetworkManager.create(engine, {
3519
3870
  transport: server.createClient("late"),
3520
3871
  room: first.manager.roomId
@@ -3551,6 +3902,7 @@ function summarize(players, seconds, frames, matchDetail, agreement) {
3551
3902
  disagreements: agreement.disagreements,
3552
3903
  keysCompared: agreement.keysCompared,
3553
3904
  lateJoinChecked: agreement.lateJoinChecked,
3905
+ moving: agreement.moving,
3554
3906
  seconds,
3555
3907
  frames,
3556
3908
  rooms,
@@ -3606,6 +3958,7 @@ function multiplayText(report) {
3606
3958
  const spawned = Object.entries(p.spawned).map(([name, n]) => `${name}=${n}`).join(" ");
3607
3959
  lines.push(` ${p.account}: sees [${p.sawAccounts.join(", ") || "—"}]` + (spawned ? ` spawned ${spawned}` : "") + (p.errors ? ` · ${p.errors} error(s)` : ""));
3608
3960
  }
3961
+ if (report.moving.length > 0) lines.push(` · ${report.moving.join(", ")} still moving when compared — a copy may trail by one send window, and that is not a disagreement`);
3609
3962
  for (const problem of report.problems) lines.push(` ✗ ${problem}`);
3610
3963
  if (report.ok) lines.push(" ✓ every client in one room, seeing the others");
3611
3964
  return lines.join("\n");
@@ -4070,6 +4423,11 @@ function validateScene(json, opts = {}) {
4070
4423
  throw e;
4071
4424
  }
4072
4425
  }
4426
+ /** Does anything in this tree want a mouse? */
4427
+ function hasClickable(node) {
4428
+ if (node.behavior?.constructor.name === "Clickable") return true;
4429
+ return node.children.some(hasClickable);
4430
+ }
4073
4431
  /**
4074
4432
  * What an assert's return value MEANS: a failure message, or null for a pass.
4075
4433
  *
@@ -4109,10 +4467,10 @@ async function runScript(json, opts) {
4109
4467
  if (opts.locale) engine.locale.locale = opts.locale;
4110
4468
  const physics = opts.physics ?? "auto";
4111
4469
  if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
4112
- const { enablePhysics2D } = await import("./physics-2d-C-SfvjzH.js").then((n) => n.r);
4470
+ const { enablePhysics2D } = await import("./physics-2d-Tuf5LAyq.js").then((n) => n.r);
4113
4471
  await enablePhysics2D(engine);
4114
4472
  } else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
4115
- const { enablePhysics3D } = await import("./physics-3d-rOzeg890.js").then((n) => n.r);
4473
+ const { enablePhysics3D } = await import("./physics-3d-C5MOujfQ.js").then((n) => n.r);
4116
4474
  await enablePhysics3D(engine);
4117
4475
  }
4118
4476
  const failures = [];
@@ -4134,6 +4492,25 @@ async function runScript(json, opts) {
4134
4492
  for (let at = hit; at; at = at.parent) if (at === wanted) return true;
4135
4493
  return false;
4136
4494
  };
4495
+ /**
4496
+ * Did this click reach the same HANDLER the author asked for?
4497
+ *
4498
+ * Two meshes of one object overlap on screen, and which one the ray meets
4499
+ * first is not something an author chose: `click: '…/Ballista/Frame'` was
4500
+ * reported as a miss because the picker found `…/Ballista/Bolt`, while the
4501
+ * click had gone through the real `Clickable` on their shared parent and
4502
+ * fired it. A click is wrong when it lands on a different THING, not on a
4503
+ * different triangle of the same one.
4504
+ */
4505
+ const clickOwner = (node) => {
4506
+ for (let at = node; at; at = at.parent) if (at.behavior?.constructor.name === "Clickable") return at;
4507
+ return null;
4508
+ };
4509
+ const sameClick = (wanted, hit) => {
4510
+ if (isSelfOrAncestor(wanted, hit)) return true;
4511
+ const owner = clickOwner(wanted);
4512
+ return owner !== null && owner === clickOwner(hit);
4513
+ };
4137
4514
  /** Frames on which a queued mouse release is due — see `ScriptStep.click`. */
4138
4515
  let releaseAt = -1;
4139
4516
  /** The button `click` pressed, so the queued release lets go of the same one. */
@@ -4144,6 +4521,13 @@ async function runScript(json, opts) {
4144
4521
  * it — a behaviour reading `engine.pointerWorld()` must get an answer for the
4145
4522
  * same cursor the click is at.
4146
4523
  */
4524
+ {
4525
+ const view = geometricView(live);
4526
+ engine.toWorld ??= view.toWorld;
4527
+ engine.toScreen ??= view.toScreen;
4528
+ engine.screenRay ??= view.screenRay;
4529
+ }
4530
+ if (hasClickable(live().root)) engine.input.setPointerPosition(0, 0);
4147
4531
  const armPointer = () => {
4148
4532
  engine.picker ??= geometricPicker(live);
4149
4533
  if (!engine.toWorld) {
@@ -4224,7 +4608,7 @@ async function runScript(json, opts) {
4224
4608
  const at = engine.input.pointerPosition();
4225
4609
  const under = at ? engine.pickAt(at.x, at.y) : null;
4226
4610
  const wanted = live().root.getNodeOrNull(named);
4227
- if (wanted && (!under || !isSelfOrAncestor(wanted, under))) failures.push({
4611
+ if (wanted && (!under || !sameClick(wanted, under))) failures.push({
4228
4612
  atMs: step.atMs,
4229
4613
  label: step.label,
4230
4614
  message: `clicked '${named}' and the picker found ${under ? `'${under.getPath()}'` : "nothing"} there. A node is pickable when something can measure it: a \`size\` prop, a collider, or a spritesheet asset with \`frameWidth\`/\`frameHeight\`. A \`Sprite2D\` takes its size from the decoded texture, which headless never exists — give it a collider, or click the node that has the shape.`
@@ -4273,6 +4657,15 @@ async function runScript(json, opts) {
4273
4657
  releaseAt = -1;
4274
4658
  }
4275
4659
  engine.step();
4660
+ if (opts.onFrame) try {
4661
+ opts.onFrame(ctx);
4662
+ } catch (error) {
4663
+ failures.push({
4664
+ atMs: Math.round(t),
4665
+ label: "onFrame",
4666
+ message: error instanceof Error ? error.message : String(error)
4667
+ });
4668
+ }
4276
4669
  if (t >= nextSnapshot - 1e-6) {
4277
4670
  snapshots.push({
4278
4671
  atMs: Math.round(t),
@@ -4302,6 +4695,18 @@ async function runScript(json, opts) {
4302
4695
  label: "engine",
4303
4696
  message: `${swallowed} error(s) were swallowed to keep the loop alive — the logs above name the node, the behavior and the phase`
4304
4697
  });
4698
+ const said = /* @__PURE__ */ new Set();
4699
+ for (const entry of logs) {
4700
+ if (entry.level !== "error") continue;
4701
+ const line = entry.parts.map((part) => part instanceof Error ? `${part.name}: ${part.message}` : String(part)).join(" ").replace(/\s+/g, " ").trim();
4702
+ if (line === "" || said.has(line)) continue;
4703
+ said.add(line);
4704
+ failures.push({
4705
+ atMs: opts.durationMs,
4706
+ label: "engine said",
4707
+ message: line
4708
+ });
4709
+ }
4305
4710
  engine.dispose();
4306
4711
  const ok = failures.length === 0;
4307
4712
  return {
@@ -4350,10 +4755,10 @@ async function createPlaySession(json, opts = {}) {
4350
4755
  engine.setScene(scene);
4351
4756
  const physics = opts.physics ?? "auto";
4352
4757
  if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
4353
- const { enablePhysics2D } = await import("./physics-2d-C-SfvjzH.js").then((n) => n.r);
4758
+ const { enablePhysics2D } = await import("./physics-2d-Tuf5LAyq.js").then((n) => n.r);
4354
4759
  await enablePhysics2D(engine);
4355
4760
  } else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
4356
- const { enablePhysics3D } = await import("./physics-3d-rOzeg890.js").then((n) => n.r);
4761
+ const { enablePhysics3D } = await import("./physics-3d-C5MOujfQ.js").then((n) => n.r);
4357
4762
  await enablePhysics3D(engine);
4358
4763
  }
4359
4764
  const stepMs = 1e3 / (opts.fixedHz ?? 60);