incanto 0.58.0 → 0.60.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 (60) hide show
  1. package/bin/incanto-check.mjs +45 -4
  2. package/bin/incanto-multiplay.mjs +170 -0
  3. package/bin/incanto-verify.mjs +68 -4
  4. package/bin/incanto.mjs +1 -0
  5. package/dist/2d.d.ts +8 -2
  6. package/dist/2d.js +3 -3
  7. package/dist/3d.d.ts +9 -3
  8. package/dist/3d.js +4 -4
  9. package/dist/{behavior-l08AEbq9.d.ts → behavior-DWKTUzKI.d.ts} +10 -0
  10. package/dist/{create-game-D10bU5_J.js → create-game-BCm38FJV.js} +6 -6
  11. package/dist/{create-game-BZwWJIns.js → create-game-ClnIb_M5.js} +6 -6
  12. package/dist/debug.d.ts +1 -1
  13. package/dist/debug.js +2 -2
  14. package/dist/{duplicate-BPLZDZpd.js → duplicate-DJQd44CD.js} +1 -1
  15. package/dist/{environment-presets-CvvQr_bJ.js → environment-presets-8cjF3t6w.js} +12 -10
  16. package/dist/{gameplay-BVphcxmE.js → gameplay-BBEjPFsR.js} +62 -34
  17. package/dist/gameplay.d.ts +1 -1
  18. package/dist/gameplay.js +1 -1
  19. package/dist/index.d.ts +53 -8
  20. package/dist/index.js +8 -8
  21. package/dist/{json-BLk7H2Qa.js → json-CwwhxQgb.js} +7 -1
  22. package/dist/{loader-BcrRSjxB.js → loader-D8n7TU8W.js} +142 -5
  23. package/dist/{loader-BbEMTuWg.d.ts → loader-TvkRFbyL.d.ts} +1 -1
  24. package/dist/net.d.ts +2 -2
  25. package/dist/net.js +1 -1
  26. package/dist/{pathfinding-mEN4V1CU.d.ts → pathfinding-BqWBb0kh.d.ts} +1 -1
  27. package/dist/{physics-2d-EqA-jddf.js → physics-2d-DqdVp1bt.js} +12 -2
  28. package/dist/{physics-3d-Dnz4fsXX.js → physics-3d-BP0DZb_1.js} +13 -3
  29. package/dist/react.d.ts +1 -1
  30. package/dist/react.js +2 -2
  31. package/dist/{register-C6ZBFRjd.js → register-BSu2dWGC.js} +57 -27
  32. package/dist/{register-Ch70uByv.js → register-Da3hXh2H.js} +137 -46
  33. package/dist/{registry-C7u42TID.js → registry-WWcQcfMr.js} +1 -1
  34. package/dist/{replay-Dw6gMlYA.d.ts → replay-BCMK_VRP.d.ts} +15 -2
  35. package/dist/{replay-s7I2GstT.js → replay-BlNuIDdg.js} +41 -13
  36. package/dist/{split-screen-B0baBwxI.d.ts → split-screen-BQ3tAsf-.d.ts} +38 -1
  37. package/dist/{split-screen-DLsUrleX.js → split-screen-CL5Yvxse.js} +56 -13
  38. package/dist/{sprite-animation-C0wXLBZJ.js → sprite-animation-CY-mrr1L.js} +1 -1
  39. package/dist/{src-C1J09Op6.js → src-DFpXBMJN.js} +1 -1
  40. package/dist/{teardown-Cs113S9F.js → teardown-CTTwhWSe.js} +1 -1
  41. package/dist/{test-ZBga8kQ9.js → test-BeZ95pqw.js} +264 -25
  42. package/dist/test.d.ts +119 -5
  43. package/dist/test.js +3 -3
  44. package/dist/vite.js +2 -2
  45. package/editor/assets/{agent8-BQQjE9UQ.js → agent8-DSJries_.js} +1 -1
  46. package/editor/assets/{debug-C9UCsXBS.js → debug-D15Wi5TO.js} +1 -1
  47. package/editor/assets/{index-CBgfM3WD.js → index-BjC88k97.js} +91 -91
  48. package/editor/index.html +1 -1
  49. package/package.json +2 -1
  50. package/schemas/scene.schema.json +1174 -70
  51. package/skills/incanto-localization.md +64 -8
  52. package/skills/incanto-multiplayer.md +57 -4
  53. package/skills/incanto-node-reference.md +18 -0
  54. package/skills/incanto-physics-and-input.md +26 -0
  55. package/skills/incanto-verifying-your-game.md +22 -0
  56. package/templates-app/beacon-isle-3d/package.json +1 -1
  57. package/templates-app/platformer-2d/package.json +1 -1
  58. package/templates-app/star-survivor/package.json +1 -1
  59. package/templates-app/tps-3d/package.json +1 -1
  60. package/templates-app/village-quest-3d/package.json +1 -1
@@ -1,5 +1,6 @@
1
- import { At as Node, It as Signal, b as Engine } from "./behavior-l08AEbq9.js";
1
+ import { At as Node, It as Signal, b as Engine } from "./behavior-DWKTUzKI.js";
2
2
  import { c as JsonValue, i as SceneJson, s as JsonObject } from "./schema-CFeioQRE.js";
3
+ import { t as LoadSceneOptions } from "./loader-TvkRFbyL.js";
3
4
 
4
5
  //#region src/net/types.d.ts
5
6
  type Unsubscribe = () => void;
@@ -277,6 +278,17 @@ interface NetworkManagerOptions {
277
278
  };
278
279
  /** Replication send window in ms (default 50; node `network.throttleMs` can lower it). */
279
280
  throttleMs?: number;
281
+ /**
282
+ * How often the FULL sync set is re-sent regardless of what changed, in ms
283
+ * (default 2000; 0 disables).
284
+ *
285
+ * Deltas are right on the wire and wrong in the store: backends shallow-merge
286
+ * one level down, so each send replaces the whole `sync` object and a key
287
+ * that stopped changing is erased from the authoritative state. A keyframe
288
+ * puts it back — for the next joiner, and for a send that never reached the
289
+ * wire, which is never retried.
290
+ */
291
+ keyframeMs?: number;
280
292
  }
281
293
  /**
282
294
  * Per-engine multiplayer hub: joins a room, exposes the room channels as core
@@ -308,6 +320,7 @@ declare class NetworkManager {
308
320
  private readonly server;
309
321
  private readonly engine;
310
322
  private readonly throttleMs;
323
+ private readonly keyframeMs;
311
324
  private readonly subs;
312
325
  private readonly messageSignals;
313
326
  private readonly collectionSignals;
@@ -320,8 +333,23 @@ declare class NetworkManager {
320
333
  /** Sync keys already reported unreadable, so a per-frame send says it once. */
321
334
  private readonly reportedDeadKeys;
322
335
  /** Name a sync key the owner cannot read — a typo in the prop or the path. */
336
+ /**
337
+ * A sync key that has NEVER been readable, reported once — after a grace
338
+ * period.
339
+ *
340
+ * Behaviours set their props in `onReady`/`update`, and replication runs on
341
+ * the fixed step, so a perfectly correct key reads as `undefined` on the
342
+ * first pass or two. Reporting immediately made the engine's own co-op
343
+ * example print "sync key 'firing' … it is never sent" about a key that is
344
+ * sent from frame 2 onward — a diagnostic that cries wolf teaches its reader
345
+ * to ignore the one that does not.
346
+ */
323
347
  private reportDeadSyncKey;
324
348
  private sendAccumulator;
349
+ /** Simulated ms since the last time the FULL sync set went out. */
350
+ private keyframeAccumulator;
351
+ /** Simulated ms this manager has been replicating — see reportDeadSyncKey. */
352
+ private aliveMs;
325
353
  private detachReplication;
326
354
  private lastOwner;
327
355
  private boundScene;
@@ -399,6 +427,15 @@ interface SplitScreenOptions extends LocalGameServerOptions {
399
427
  /** Engine seed base (each panel gets seed + index). */
400
428
  seed?: number;
401
429
  /**
430
+ * Loader options, per panel — `stubMissingBehaviors` for a headless check
431
+ * that has no TypeScript, `resolveScene` for `instance:` sub-scenes.
432
+ *
433
+ * Without these, `createSplitScreen` hard-failed on any real game: it called
434
+ * `loadScene` with nothing registered, so a scene naming one of its own
435
+ * behaviours threw `Unknown behavior` before a single frame ran.
436
+ */
437
+ load?: LoadSceneOptions;
438
+ /**
402
439
  * The room every panel joins. Default: whatever the FIRST panel got.
403
440
  *
404
441
  * A shared scene usually says `multiplayer: { room: "auto" }`, and "auto"
@@ -1,8 +1,8 @@
1
- import { C as diagnose, M as Signal, S as Node, n as loadScene, t as buildNodeJson } from "./loader-BcrRSjxB.js";
2
- import { _ as Engine, t as registerCoreNodes } from "./register-Ch70uByv.js";
1
+ import { C as Node, N as Signal, n as loadScene, t as buildNodeJson, w as diagnose } from "./loader-D8n7TU8W.js";
2
+ import { _ as Engine, t as registerCoreNodes } from "./register-Da3hXh2H.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
- import { n as jsonEquals, t as jsonClone } from "./json-BLk7H2Qa.js";
5
- import { l as registerNode } from "./registry-C7u42TID.js";
4
+ import { n as jsonEquals, t as jsonClone } from "./json-CwwhxQgb.js";
5
+ import { l as registerNode } from "./registry-WWcQcfMr.js";
6
6
  //#region src/net/loopback.ts
7
7
  /**
8
8
  * In-memory multiplayer hub implementing the SAME kernel contract as
@@ -771,6 +771,15 @@ var LocalGameServerTransport = class {
771
771
  };
772
772
  //#endregion
773
773
  //#region src/net/network-manager.ts
774
+ /**
775
+ * How long a sync key may read as `undefined` before it is called dead.
776
+ *
777
+ * Long enough for `onReady` and a few `update`s to have run; short enough that
778
+ * a genuinely wrong prop name is named while its author is still looking.
779
+ */
780
+ const DEAD_KEY_GRACE_MS = 500;
781
+ /** Full-state resend interval, in simulated ms. See `keyframeMs`. */
782
+ const DEFAULT_KEYFRAME_MS = 2e3;
774
783
  const managers = /* @__PURE__ */ new WeakMap();
775
784
  /**
776
785
  * Per-engine multiplayer hub: joins a room, exposes the room channels as core
@@ -802,6 +811,7 @@ var NetworkManager = class NetworkManager {
802
811
  server;
803
812
  engine;
804
813
  throttleMs;
814
+ keyframeMs;
805
815
  subs = [];
806
816
  messageSignals = /* @__PURE__ */ new Map();
807
817
  collectionSignals = /* @__PURE__ */ new Map();
@@ -814,12 +824,28 @@ var NetworkManager = class NetworkManager {
814
824
  /** Sync keys already reported unreadable, so a per-frame send says it once. */
815
825
  reportedDeadKeys = /* @__PURE__ */ new Set();
816
826
  /** Name a sync key the owner cannot read — a typo in the prop or the path. */
827
+ /**
828
+ * A sync key that has NEVER been readable, reported once — after a grace
829
+ * period.
830
+ *
831
+ * Behaviours set their props in `onReady`/`update`, and replication runs on
832
+ * the fixed step, so a perfectly correct key reads as `undefined` on the
833
+ * first pass or two. Reporting immediately made the engine's own co-op
834
+ * example print "sync key 'firing' … it is never sent" about a key that is
835
+ * sent from frame 2 onward — a diagnostic that cries wolf teaches its reader
836
+ * to ignore the one that does not.
837
+ */
817
838
  reportDeadSyncKey(owner, key) {
818
839
  if (this.reportedDeadKeys.has(key)) return;
840
+ if (this.aliveMs < DEAD_KEY_GRACE_MS) return;
819
841
  this.reportedDeadKeys.add(key);
820
842
  diagnose(owner.tree?.engine ?? null, "error", `[incanto] ${owner.getPath()}: sync key '${key}' reads as undefined — it is never sent, so remote players never see it change. Check the prop name and the node path.`);
821
843
  }
822
844
  sendAccumulator = 0;
845
+ /** Simulated ms since the last time the FULL sync set went out. */
846
+ keyframeAccumulator = 0;
847
+ /** Simulated ms this manager has been replicating — see reportDeadSyncKey. */
848
+ aliveMs = 0;
823
849
  detachReplication = null;
824
850
  lastOwner = null;
825
851
  boundScene = null;
@@ -831,16 +857,17 @@ var NetworkManager = class NetworkManager {
831
857
  const server = opts.transport ?? await createDefaultTransport(opts.config);
832
858
  await server.connect();
833
859
  const requested = opts.room ?? engine.scene?.multiplayer?.room ?? "auto";
834
- const manager = new NetworkManager(engine, server, await server.remoteFunction("joinRoom", [requested === "auto" ? void 0 : requested], { needResponse: true }), opts.throttleMs ?? 50);
860
+ const manager = new NetworkManager(engine, server, await server.remoteFunction("joinRoom", [requested === "auto" ? void 0 : requested], { needResponse: true }), opts.throttleMs ?? 50, opts.keyframeMs ?? DEFAULT_KEYFRAME_MS);
835
861
  managers.set(engine, manager);
836
862
  return manager;
837
863
  }
838
- constructor(engine, server, roomId, throttleMs) {
864
+ constructor(engine, server, roomId, throttleMs, keyframeMs) {
839
865
  this.engine = engine;
840
866
  this.server = server;
841
867
  this.roomId = roomId;
842
868
  this.account = server.account;
843
869
  this.throttleMs = Math.max(30, throttleMs);
870
+ this.keyframeMs = Math.max(0, keyframeMs);
844
871
  this.boundScene = engine.scene;
845
872
  this.subs.push(server.subscribeRoomState(roomId, (state) => {
846
873
  this.latestRoomState = state;
@@ -996,6 +1023,7 @@ var NetworkManager = class NetworkManager {
996
1023
  const scene = this.engine.scene;
997
1024
  if (!scene || scene !== this.boundScene) return;
998
1025
  this.sendAccumulator += dt * 1e3;
1026
+ this.aliveMs += dt * 1e3;
999
1027
  const owner = findOwnerNode(scene.root, true);
1000
1028
  if (!owner) return;
1001
1029
  if (owner !== this.lastOwner) {
@@ -1005,22 +1033,29 @@ var NetworkManager = class NetworkManager {
1005
1033
  const net = owner.network;
1006
1034
  const window = Math.max(30, typeof net.throttleMs === "number" ? net.throttleMs : this.throttleMs);
1007
1035
  const syncKeys = Array.isArray(net.sync) ? net.sync : [];
1008
- const patch = {};
1036
+ const full = {};
1037
+ let changed = false;
1009
1038
  for (const key of syncKeys) {
1010
1039
  const value = readSyncKey(owner, key);
1011
1040
  if (value === void 0) {
1012
1041
  this.reportDeadSyncKey(owner, key);
1013
1042
  continue;
1014
1043
  }
1015
- if (!jsonEquals(this.lastSent.get(key) ?? null, value)) patch[key] = jsonClone(value);
1044
+ full[key] = jsonClone(value);
1045
+ if (!jsonEquals(this.lastSent.get(key) ?? null, value)) changed = true;
1016
1046
  }
1047
+ this.keyframeAccumulator += dt * 1e3;
1048
+ const keyframeDue = this.keyframeMs > 0 && this.keyframeAccumulator >= this.keyframeMs;
1017
1049
  if (this.sendAccumulator < window) return;
1018
- if (Object.keys(patch).length === 0) return;
1019
- for (const [key, value] of Object.entries(patch)) this.lastSent.set(key, jsonClone(value));
1050
+ if (!changed && !keyframeDue) return;
1051
+ if (Object.keys(full).length === 0) return;
1052
+ for (const [key, value] of Object.entries(full)) this.lastSent.set(key, jsonClone(value));
1053
+ this.keyframeAccumulator = 0;
1020
1054
  this.sendAccumulator = 0;
1021
- this.setMyState({ sync: patch });
1055
+ this.setMyState({ sync: full });
1022
1056
  }
1023
1057
  };
1058
+ /** The single `network.mode === 'owner'` node in a tree, or null. @internal */
1024
1059
  function findOwnerNode(node, enforceSingle = false) {
1025
1060
  const owners = [];
1026
1061
  collectOwners(node, owners);
@@ -1032,6 +1067,13 @@ function collectOwners(node, out) {
1032
1067
  for (const c of node.children) collectOwners(c, out);
1033
1068
  }
1034
1069
  /** `'position'` → own prop; `'Sprite.animation'` → child path + prop (last dot splits). */
1070
+ /**
1071
+ * Read one `network.sync` key off an owner node, exactly as the sender does.
1072
+ *
1073
+ * Exported so an agreement check reads GROUND TRUTH through the same last-dot
1074
+ * split the protocol uses — a second implementation would eventually disagree
1075
+ * with this one, and then the check would be measuring itself.
1076
+ */
1035
1077
  function readSyncKey(node, key) {
1036
1078
  const lastDot = key.lastIndexOf(".");
1037
1079
  const target = lastDot === -1 ? node : node.getNodeOrNull(key.slice(0, lastDot)) ?? void 0;
@@ -1176,6 +1218,7 @@ var NetworkSpawner = class extends Node {
1176
1218
  }
1177
1219
  }
1178
1220
  };
1221
+ /** The child name a spawner gives an account's instance. @internal */
1179
1222
  function sanitizeName(key) {
1180
1223
  return key.replace(/[/%]/g, "_") || "remote";
1181
1224
  }
@@ -1218,7 +1261,7 @@ async function createSplitScreen(opts) {
1218
1261
  for (let i = 0; i < accounts.length; i++) {
1219
1262
  const account = accounts[i];
1220
1263
  const engine = new Engine(opts.seed !== void 0 ? { seed: opts.seed + i } : {});
1221
- engine.setScene(loadScene(jsonClone(opts.scene)));
1264
+ engine.setScene(loadScene(jsonClone(opts.scene), opts.load));
1222
1265
  const manager = await NetworkManager.create(engine, {
1223
1266
  transport: server.createClient(account),
1224
1267
  ...opts.room !== void 0 || i > 0 ? { room: opts.room ?? players[0].manager.roomId } : {}
@@ -1275,4 +1318,4 @@ function hasBodyNamed(node, base) {
1275
1318
  return false;
1276
1319
  }
1277
1320
  //#endregion
1278
- export { applySyncPatch as a, createLocalGameServer as c, NetworkManager as i, LoopbackHub as l, registerNodesNet as n, LocalGameServer as o, NetworkSpawner as r, LocalGameServerTransport as s, createSplitScreen as t, LoopbackTransport as u };
1321
+ export { NetworkManager as a, readSyncKey as c, createLocalGameServer as d, LoopbackHub as f, sanitizeName as i, LocalGameServer as l, registerNodesNet as n, applySyncPatch as o, LoopbackTransport as p, NetworkSpawner as r, findOwnerNode as s, createSplitScreen as t, LocalGameServerTransport as u };
@@ -1,5 +1,5 @@
1
1
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
2
- import { n as jsonEquals } from "./json-BLk7H2Qa.js";
2
+ import { n as jsonEquals } from "./json-CwwhxQgb.js";
3
3
  //#region src/core/particle-presets.ts
4
4
  const PARTICLE_PRESETS = {
5
5
  fire: {
@@ -214,6 +214,6 @@ function newUid() {
214
214
  //#endregion
215
215
  //#region src/index.ts
216
216
  /** Engine version. Kept in sync with package.json by the release pipeline. */
217
- const VERSION = "0.58.0";
217
+ const VERSION = "0.60.0";
218
218
  //#endregion
219
219
  export { preloadUrls as a, preloadSceneAssets as i, newUid as n, findPath as o, assetUrls as r, gridFromRows as s, VERSION as t };
@@ -1,4 +1,4 @@
1
- import { g as AudioPlayer } from "./register-Ch70uByv.js";
1
+ import { g as AudioPlayer } from "./register-Da3hXh2H.js";
2
2
  import { a as logReport, s as parseDrive } from "./touch-DESwnpOc.js";
3
3
  import { a as frameSignature, i as frameImage, o as frameStats } from "./frame-report-BSMny7oe.js";
4
4
  //#region src/core/audio-errors.ts
@@ -1,13 +1,13 @@
1
- import { A as registerBehavior, n as loadScene, s as resolveViewport } from "./loader-BcrRSjxB.js";
2
- import { _ as Engine } from "./register-Ch70uByv.js";
1
+ import { c as resolveViewport, j as registerBehavior, n as loadScene } from "./loader-D8n7TU8W.js";
2
+ import { _ as Engine } from "./register-Da3hXh2H.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
- import { n as jsonEquals, t as jsonClone } from "./json-BLk7H2Qa.js";
5
- import { i as getNodeSchema, s as mergeStaticProps } from "./registry-C7u42TID.js";
6
- import { n as startRecording } from "./replay-s7I2GstT.js";
7
- import { n as registerGameplayBehaviors } from "./gameplay-BVphcxmE.js";
8
- import { t as registerNodes2D } from "./register-C6ZBFRjd.js";
9
- import { n as registerNodes3D, t as resolveEnvironmentHdri } from "./environment-presets-CvvQr_bJ.js";
10
- import { n as registerNodesNet, t as createSplitScreen } from "./split-screen-DLsUrleX.js";
4
+ import { n as jsonEquals, t as jsonClone } from "./json-CwwhxQgb.js";
5
+ import { i as getNodeSchema, s as mergeStaticProps } from "./registry-WWcQcfMr.js";
6
+ import { n as startRecording } from "./replay-BlNuIDdg.js";
7
+ import { n as registerGameplayBehaviors } from "./gameplay-BBEjPFsR.js";
8
+ import { t as registerNodes2D } from "./register-BSu2dWGC.js";
9
+ import { n as registerNodes3D, t as resolveEnvironmentHdri } from "./environment-presets-8cjF3t6w.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-CL5Yvxse.js";
11
11
  import { Box3, Euler, Matrix4, PerspectiveCamera, Quaternion, Vector3 } from "three";
12
12
  //#region src/test/framing.ts
13
13
  /**
@@ -1038,6 +1038,177 @@ function facingText(report) {
1038
1038
  return `facing: ${report.skin} faces its travel (dot ${report.dot.toFixed(2)})`;
1039
1039
  }
1040
1040
  //#endregion
1041
+ //#region src/test/agreement.ts
1042
+ /** Absolute slack, for values that are still converging out of a lerp. */
1043
+ const ABS_TOL = .001;
1044
+ /** Relative slack, so a large coordinate is not held to the absolute one. */
1045
+ const REL_TOL = .005;
1046
+ function closeEnough(a, b, absTol, relTol) {
1047
+ return Math.abs(a - b) <= Math.max(absTol, relTol * Math.abs(a));
1048
+ }
1049
+ function numbers(v) {
1050
+ if (typeof v === "number") return [v];
1051
+ if (Array.isArray(v) && v.every((x) => typeof x === "number")) return v;
1052
+ return null;
1053
+ }
1054
+ /**
1055
+ * Every way the clients disagree, most structural first.
1056
+ *
1057
+ * @param live snapshots taken after the match has quiesced
1058
+ * @param late the same, for a client that joined AFTER everything settled
1059
+ */
1060
+ function agreementProblems(live, late = [], opts = {}) {
1061
+ const absTol = opts.absTol ?? ABS_TOL;
1062
+ const relTol = opts.relTol ?? REL_TOL;
1063
+ const out = [];
1064
+ const compare = (snapshots, lateJoin) => {
1065
+ const found = [];
1066
+ for (const s of snapshots) for (const [observer, seen] of Object.entries(s.seen)) {
1067
+ if (seen === null) {
1068
+ found.push({
1069
+ kind: "missing",
1070
+ account: s.account,
1071
+ observer,
1072
+ key: "",
1073
+ 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.`
1074
+ });
1075
+ continue;
1076
+ }
1077
+ for (const [key, truth] of Object.entries(s.truth)) {
1078
+ const mine = seen[key];
1079
+ if (mine === void 0) {
1080
+ found.push({
1081
+ kind: lateJoin ? "erased" : "absent",
1082
+ account: s.account,
1083
+ observer,
1084
+ key,
1085
+ truth,
1086
+ 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.`
1087
+ });
1088
+ continue;
1089
+ }
1090
+ const a = numbers(truth);
1091
+ const b = numbers(mine);
1092
+ if (a && b) {
1093
+ if (a.length !== b.length) {
1094
+ found.push({
1095
+ kind: "shape",
1096
+ account: s.account,
1097
+ observer,
1098
+ key,
1099
+ truth,
1100
+ seen: mine,
1101
+ 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.`
1102
+ });
1103
+ continue;
1104
+ }
1105
+ if (a.every((v, i) => closeEnough(v, b[i], absTol, relTol))) continue;
1106
+ found.push({
1107
+ kind: "mismatch",
1108
+ account: s.account,
1109
+ observer,
1110
+ key,
1111
+ truth,
1112
+ seen: mine,
1113
+ summary: `${observer} has ${s.account}'s \`${key}\` as ${JSON.stringify(mine)}, not ${JSON.stringify(truth)}.`
1114
+ });
1115
+ continue;
1116
+ }
1117
+ if (jsonEquals(truth, mine)) continue;
1118
+ found.push({
1119
+ kind: "mismatch",
1120
+ account: s.account,
1121
+ observer,
1122
+ key,
1123
+ truth,
1124
+ seen: mine,
1125
+ summary: `${observer} has ${s.account}'s \`${key}\` as ${JSON.stringify(mine)}, not ${JSON.stringify(truth)}.`
1126
+ });
1127
+ }
1128
+ }
1129
+ return found;
1130
+ };
1131
+ out.push(...compare(live, false));
1132
+ if (late.length > 0) {
1133
+ const alreadyAbsent = new Set(out.filter((d) => d.kind === "absent").map((d) => `${d.account}|${d.key}`));
1134
+ for (const d of compare(late, true)) {
1135
+ if (d.kind === "erased" && alreadyAbsent.has(`${d.account}|${d.key}`)) continue;
1136
+ out.push(d);
1137
+ }
1138
+ }
1139
+ const rank = {
1140
+ missing: 0,
1141
+ erased: 1,
1142
+ absent: 2,
1143
+ shape: 3,
1144
+ mismatch: 4
1145
+ };
1146
+ return out.sort((x, y) => rank[x.kind] - rank[y.kind]);
1147
+ }
1148
+ //#endregion
1149
+ //#region src/test/agreement-snapshot.ts
1150
+ /** The last-dot split of the RECEIVE side, applied to a spawned instance. */
1151
+ function readFromInstance(instance, key) {
1152
+ const lastDot = key.lastIndexOf(".");
1153
+ const target = lastDot === -1 ? instance : instance.getNodeOrNull(key.slice(0, lastDot));
1154
+ if (!target) return void 0;
1155
+ return target[lastDot === -1 ? key : key.slice(lastDot + 1)];
1156
+ }
1157
+ /** Every spawned instance in a client's tree, by the child name a spawner gives it. */
1158
+ function instancesByName(engine) {
1159
+ const out = /* @__PURE__ */ new Map();
1160
+ const root = engine.scene?.tree.root;
1161
+ if (!root) return out;
1162
+ const walk = (node) => {
1163
+ for (const child of node.children) {
1164
+ if (!out.has(child.name)) out.set(child.name, child);
1165
+ walk(child);
1166
+ }
1167
+ };
1168
+ walk(root);
1169
+ return out;
1170
+ }
1171
+ /**
1172
+ * One snapshot per account: its own truth, and what each other client believes.
1173
+ *
1174
+ * @param clients every client in the match
1175
+ * @param observers the clients whose beliefs to record (defaults to `clients`)
1176
+ */
1177
+ function takeAgreementSnapshots(clients, observers = clients) {
1178
+ const snapshots = [];
1179
+ for (const owner of clients) {
1180
+ const root = owner.engine.scene?.tree.root;
1181
+ if (!root) continue;
1182
+ const node = findOwnerNode(root);
1183
+ if (!node) continue;
1184
+ const keys = Array.isArray(node.network?.sync) ? node.network.sync : [];
1185
+ if (keys.length === 0) continue;
1186
+ const truth = {};
1187
+ for (const key of keys) {
1188
+ const value = readSyncKey(node, key);
1189
+ if (value !== void 0) truth[key] = value;
1190
+ }
1191
+ const seen = {};
1192
+ for (const other of observers) {
1193
+ if (other.account === owner.account) continue;
1194
+ const instance = instancesByName(other.engine).get(sanitizeName(owner.account));
1195
+ if (!instance) {
1196
+ seen[other.account] = null;
1197
+ continue;
1198
+ }
1199
+ const held = {};
1200
+ for (const key of Object.keys(truth)) held[key] = readFromInstance(instance, key);
1201
+ seen[other.account] = held;
1202
+ }
1203
+ snapshots.push({
1204
+ account: owner.account,
1205
+ truth,
1206
+ seen
1207
+ });
1208
+ }
1209
+ return snapshots;
1210
+ }
1211
+ //#endregion
1041
1212
  //#region src/test/feel.ts
1042
1213
  const STEP_MS = 1e3 / 60;
1043
1214
  /** Drop height for the buffer probe — long enough to outlast a coyote window. */
@@ -1430,10 +1601,12 @@ async function playMultiplayer(opts) {
1430
1601
  const seconds = opts.seconds ?? 5;
1431
1602
  const stepMs = opts.stepMs ?? 1e3 / 60;
1432
1603
  const accounts = Array.from({ length: count }, (_, i) => `p${i + 1}`);
1433
- const { players, dispose } = await createSplitScreen({
1604
+ const { server, players, dispose } = await createSplitScreen({
1434
1605
  scene: opts.scene,
1435
1606
  ...opts.server ? { server: opts.server } : {},
1436
1607
  ...opts.scenes ? { scenes: opts.scenes } : {},
1608
+ ...opts.seed !== void 0 ? { seed: opts.seed } : {},
1609
+ ...opts.load ? { load: opts.load } : {},
1437
1610
  accounts,
1438
1611
  setup: () => {}
1439
1612
  });
@@ -1450,26 +1623,87 @@ async function playMultiplayer(opts) {
1450
1623
  if (frame % 6 === 0) await settle();
1451
1624
  }
1452
1625
  await settle();
1453
- const report = summarize(players, seconds, frames);
1626
+ for (let frame = 0; frame < 60; frame++) {
1627
+ now += stepMs;
1628
+ for (const player of players) player.engine.tick(now);
1629
+ if (frame % 6 === 0) await settle();
1630
+ }
1631
+ await settle();
1632
+ const live = takeAgreementSnapshots(players);
1633
+ const matchDetail = players.map((p) => ({
1634
+ account: p.account,
1635
+ room: p.manager.roomId,
1636
+ spawned: spawnerCounts(p.engine),
1637
+ sawAccounts: Object.keys(p.manager.latestUserStates ?? {}).filter((a) => a !== p.account),
1638
+ errors: p.engine.stats().errors
1639
+ }));
1640
+ let lateSnapshots = [];
1641
+ let lateJoinChecked = false;
1642
+ if ((opts.lateJoin ?? true) && players.length > 0) {
1643
+ const joiner = await joinLate(server, players[0], opts);
1644
+ if (joiner) {
1645
+ for (let frame = 0; frame < 60; frame++) {
1646
+ now += stepMs;
1647
+ for (const player of [...players, joiner]) player.engine.tick(now);
1648
+ if (frame % 6 === 0) await settle();
1649
+ }
1650
+ await settle();
1651
+ lateSnapshots = takeAgreementSnapshots(players, [joiner]);
1652
+ lateJoinChecked = true;
1653
+ }
1654
+ }
1655
+ const report = summarize(players, seconds, frames, matchDetail, {
1656
+ disagreements: agreementProblems(live, lateSnapshots),
1657
+ keysCompared: live.reduce((n, s) => n + Object.keys(s.truth).length * Object.keys(s.seen).length, 0),
1658
+ lateJoinChecked
1659
+ });
1454
1660
  dispose();
1455
1661
  return report;
1456
1662
  }
1663
+ /**
1664
+ * One more client, against the SAME server and the SAME room.
1665
+ *
1666
+ * `createSplitScreen` builds all its panels up front, so this is the ten lines
1667
+ * of it that matter — and the room id has to come from panel 0 or the joiner
1668
+ * lands in its own auto-assigned room and reports everything as missing.
1669
+ */
1670
+ async function joinLate(server, first, opts) {
1671
+ try {
1672
+ const { Engine } = await import("./register-Da3hXh2H.js").then((n) => n.v);
1673
+ const { loadScene } = await import("./loader-D8n7TU8W.js").then((n) => n.r);
1674
+ const { jsonClone } = await import("./json-CwwhxQgb.js").then((n) => n.i);
1675
+ const engine = new Engine(opts.seed !== void 0 ? { seed: opts.seed + 999 } : {});
1676
+ engine.setScene(loadScene(jsonClone(opts.scene), opts.load));
1677
+ const manager = await NetworkManager.create(engine, {
1678
+ transport: server.createClient("late"),
1679
+ room: first.manager.roomId
1680
+ });
1681
+ for (const [name, json] of Object.entries(opts.scenes ?? {})) manager.registerScene(name, json);
1682
+ return {
1683
+ account: "late",
1684
+ engine,
1685
+ manager
1686
+ };
1687
+ } catch {
1688
+ return null;
1689
+ }
1690
+ }
1457
1691
  /** Give the server's promise queue a turn. */
1458
1692
  function settle() {
1459
1693
  return new Promise((resolve) => setTimeout(resolve, 0));
1460
1694
  }
1461
- function summarize(players, seconds, frames) {
1695
+ function summarize(players, seconds, frames, matchDetail, agreement) {
1462
1696
  const rooms = players.map((p) => p.manager.roomId);
1463
- const detail = players.map((p) => ({
1464
- account: p.account,
1465
- room: p.manager.roomId,
1466
- spawned: spawnerCounts(p.engine),
1467
- sawAccounts: Object.keys(p.manager.latestUserStates ?? {}).filter((a) => a !== p.account),
1468
- errors: p.engine.stats().errors
1697
+ const detail = matchDetail.map((d, i) => ({
1698
+ ...d,
1699
+ errors: players[i]?.engine.stats().errors ?? d.errors
1469
1700
  }));
1470
- const problems = multiplayProblems(detail, rooms);
1701
+ const problems = [...multiplayProblems(detail, rooms), ...agreement.disagreements.map((d) => d.summary)];
1471
1702
  return {
1472
1703
  ok: problems.length === 0,
1704
+ disagreements: agreement.disagreements,
1705
+ keysCompared: agreement.keysCompared,
1706
+ lateJoinChecked: agreement.lateJoinChecked,
1473
1707
  seconds,
1474
1708
  frames,
1475
1709
  rooms,
@@ -1502,7 +1736,7 @@ function spawnerCounts(engine) {
1502
1736
  const root = engine.scene?.tree.root;
1503
1737
  if (!root) return out;
1504
1738
  const walk = (node) => {
1505
- if (node.constructor.name === "NetworkSpawner") out[node.name] = node.children.length;
1739
+ if (node instanceof NetworkSpawner) out[node.name] = node.children.length;
1506
1740
  for (const child of node.children) walk(child);
1507
1741
  };
1508
1742
  walk(root);
@@ -1511,6 +1745,7 @@ function spawnerCounts(engine) {
1511
1745
  /** The report as the one paragraph a reader needs. */
1512
1746
  function multiplayText(report) {
1513
1747
  const lines = [`multiplayer: ${report.players.length} clients, ${report.seconds}s, room ${[...new Set(report.rooms)].join(" + ")}`];
1748
+ 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)`);
1514
1749
  for (const p of report.players) {
1515
1750
  const spawned = Object.entries(p.spawned).map(([name, n]) => `${name}=${n}`).join(" ");
1516
1751
  lines.push(` ${p.account}: sees [${p.sawAccounts.join(", ") || "—"}]` + (spawned ? ` spawned ${spawned}` : "") + (p.errors ? ` · ${p.errors} error(s)` : ""));
@@ -1690,6 +1925,8 @@ function captureScene(scene) {
1690
1925
  type: ctor.typeName,
1691
1926
  props
1692
1927
  };
1928
+ const painted = node._paintedText?.();
1929
+ if (typeof painted === "string") capture.text = painted;
1693
1930
  if (node.uid) capture.uid = node.uid;
1694
1931
  if (node.groups.size > 0) capture.groups = [...node.groups];
1695
1932
  if (Object.keys(node.tags).length > 0) capture.tags = jsonClone(node.tags);
@@ -1716,6 +1953,7 @@ function describeCapture(capture) {
1716
1953
  const schema = defaultsFor(node.type);
1717
1954
  const deltas = Object.entries(node.props).filter(([key, value]) => !schema || !jsonEquals(value, schema[key]?.default ?? null)).map(([key, value]) => `${key}=${JSON.stringify(value)}`);
1718
1955
  const extras = [
1956
+ node.text !== void 0 ? `paints=${JSON.stringify(node.text)}` : "",
1719
1957
  node.script ? `script=${node.script}` : "",
1720
1958
  node.groups ? `groups=${node.groups.join(",")}` : "",
1721
1959
  ...deltas
@@ -1786,12 +2024,13 @@ async function runScript(json, opts) {
1786
2024
  });
1787
2025
  const scene = loadScene(structuredClone(json), { resolveScene: opts.resolveScene });
1788
2026
  engine.setScene(scene);
2027
+ if (opts.locale) engine.locale.locale = opts.locale;
1789
2028
  const physics = opts.physics ?? "auto";
1790
2029
  if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
1791
- const { enablePhysics2D } = await import("./physics-2d-EqA-jddf.js").then((n) => n.r);
2030
+ const { enablePhysics2D } = await import("./physics-2d-DqdVp1bt.js").then((n) => n.r);
1792
2031
  await enablePhysics2D(engine);
1793
2032
  } else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
1794
- const { enablePhysics3D } = await import("./physics-3d-Dnz4fsXX.js").then((n) => n.r);
2033
+ const { enablePhysics3D } = await import("./physics-3d-BP0DZb_1.js").then((n) => n.r);
1795
2034
  await enablePhysics3D(engine);
1796
2035
  }
1797
2036
  const failures = [];
@@ -1904,10 +2143,10 @@ async function createPlaySession(json, opts = {}) {
1904
2143
  engine.setScene(scene);
1905
2144
  const physics = opts.physics ?? "auto";
1906
2145
  if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
1907
- const { enablePhysics2D } = await import("./physics-2d-EqA-jddf.js").then((n) => n.r);
2146
+ const { enablePhysics2D } = await import("./physics-2d-DqdVp1bt.js").then((n) => n.r);
1908
2147
  await enablePhysics2D(engine);
1909
2148
  } else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
1910
- const { enablePhysics3D } = await import("./physics-3d-Dnz4fsXX.js").then((n) => n.r);
2149
+ const { enablePhysics3D } = await import("./physics-3d-BP0DZb_1.js").then((n) => n.r);
1911
2150
  await enablePhysics3D(engine);
1912
2151
  }
1913
2152
  const stepMs = 1e3 / (opts.fixedHz ?? 60);
@@ -1945,4 +2184,4 @@ async function createPlaySession(json, opts = {}) {
1945
2184
  };
1946
2185
  }
1947
2186
  //#endregion
1948
- export { framingText as S, failingReplays as _, registerAllNodes as a, playtestText as b, ladderText as c, multiplayText as d, playMultiplayer as f, facingText as g, facingReport 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, findPlayer as v, describeFraming as x, playtest as y };
2187
+ 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 };