incanto 0.57.0 → 0.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/bin/incanto-check.mjs +27 -0
- package/bin/incanto-multiplay.mjs +170 -0
- package/bin/incanto-new.mjs +29 -7
- package/bin/incanto-playtest.mjs +28 -2
- package/bin/incanto-verify.mjs +155 -19
- package/bin/incanto.mjs +107 -0
- package/dist/2d.d.ts +8 -2
- package/dist/2d.js +3 -3
- package/dist/3d.d.ts +9 -3
- package/dist/3d.js +4 -4
- package/dist/{behavior-l08AEbq9.d.ts → behavior-DWKTUzKI.d.ts} +10 -0
- package/dist/{create-game-C5jQYPah.js → create-game-BiW8Men_.js} +61 -13
- package/dist/{create-game-DpbUrMOQ.js → create-game-CHDLDQsQ.js} +6 -6
- package/dist/debug.d.ts +1 -1
- package/dist/debug.js +2 -2
- package/dist/{duplicate-BPLZDZpd.js → duplicate-DJQd44CD.js} +1 -1
- package/dist/{environment-presets-CvvQr_bJ.js → environment-presets-DRAz5EV9.js} +12 -10
- package/dist/{gameplay-BVphcxmE.js → gameplay-BBEjPFsR.js} +62 -34
- package/dist/gameplay.d.ts +1 -1
- package/dist/gameplay.js +1 -1
- package/dist/index.d.ts +36 -8
- package/dist/index.js +8 -8
- package/dist/{json-BLk7H2Qa.js → json-CwwhxQgb.js} +7 -1
- package/dist/{loader-BcrRSjxB.js → loader-D8n7TU8W.js} +142 -5
- package/dist/{loader-BbEMTuWg.d.ts → loader-TvkRFbyL.d.ts} +1 -1
- package/dist/net.d.ts +2 -2
- package/dist/net.js +1 -1
- package/dist/{pathfinding-mEN4V1CU.d.ts → pathfinding-BqWBb0kh.d.ts} +1 -1
- package/dist/{physics-2d-BLcvEFDR.js → physics-2d-BaRSRrrZ.js} +14 -3
- package/dist/{physics-3d-QBrfIT2Y.js → physics-3d-CYxjh-HW.js} +15 -4
- package/dist/quiet-rapier-BAJ4K94N.js +46 -0
- package/dist/react.d.ts +1 -1
- package/dist/react.js +2 -2
- package/dist/{register-C6ZBFRjd.js → register-BpFcgdcL.js} +57 -27
- package/dist/{register-Ch70uByv.js → register-CDrAQqPp.js} +90 -41
- package/dist/{registry-C7u42TID.js → registry-WWcQcfMr.js} +1 -1
- package/dist/{replay-s7I2GstT.js → replay-CEPyQtF_.js} +31 -7
- package/dist/{replay-Dw6gMlYA.d.ts → replay-O-yAGM76.d.ts} +1 -1
- package/dist/{split-screen-B0baBwxI.d.ts → split-screen-BQ3tAsf-.d.ts} +38 -1
- package/dist/{split-screen-DLsUrleX.js → split-screen-DDMZutQ6.js} +56 -13
- package/dist/{sprite-animation-C0wXLBZJ.js → sprite-animation-CY-mrr1L.js} +1 -1
- package/dist/{src-CGjmPw65.js → src-CY21B462.js} +1 -1
- package/dist/{teardown-Cs113S9F.js → teardown-RApWnM1G.js} +1 -1
- package/dist/{test-it1VekWs.js → test-DHYuFyAu.js} +292 -31
- package/dist/test.d.ts +104 -6
- package/dist/test.js +3 -3
- package/dist/vite.js +2 -2
- package/editor/assets/{agent8-CGT7r3Mb.js → agent8-BoRGtVxK.js} +1 -1
- package/editor/assets/{debug-BxWSIHG3.js → debug-CzdyCg75.js} +1 -1
- package/editor/assets/{index-CV1m-aX5.js → index-VesuVEhe.js} +91 -91
- package/editor/index.html +1 -1
- package/package.json +3 -1
- package/schemas/scene.schema.json +1174 -70
- package/skills/incanto-building-2d-games.md +13 -0
- package/skills/incanto-building-3d-games.md +3 -3
- package/skills/incanto-localization.md +40 -8
- package/skills/incanto-multiplayer.md +57 -4
- package/skills/incanto-node-reference.md +18 -0
- package/skills/incanto-physics-and-input.md +26 -0
- package/skills/incanto-playtesting.md +19 -5
- package/skills/incanto-verifying-your-game.md +33 -4
- package/templates-app/beacon-isle-3d/package.json +1 -1
- package/templates-app/beacon-isle-3d/src/game.scene.json +7 -6
- package/templates-app/platformer-2d/PROJECT/Context.md +70 -0
- package/templates-app/platformer-2d/PROJECT/Requirements.md +63 -0
- package/templates-app/platformer-2d/PROJECT/Status.md +60 -0
- package/templates-app/platformer-2d/PROJECT/Structure.md +77 -0
- package/templates-app/platformer-2d/docs/project-2d-rules.md +61 -0
- package/templates-app/platformer-2d/index.html +99 -0
- package/templates-app/platformer-2d/package.json +23 -0
- package/templates-app/platformer-2d/src/behaviors.ts +541 -0
- package/templates-app/platformer-2d/src/game.scene.json +2061 -0
- package/templates-app/platformer-2d/src/main.ts +68 -0
- package/templates-app/platformer-2d/tsconfig.json +13 -0
- package/templates-app/platformer-2d/verify.ts +275 -0
- package/templates-app/platformer-2d/vite.config.ts +12 -0
- package/templates-app/star-survivor/PROJECT/Context.md +55 -0
- package/templates-app/star-survivor/PROJECT/Requirements.md +47 -0
- package/templates-app/star-survivor/PROJECT/Status.md +44 -0
- package/templates-app/star-survivor/PROJECT/Structure.md +63 -0
- package/templates-app/star-survivor/docs/project-2d-rules.md +53 -0
- package/templates-app/star-survivor/index.html +232 -0
- package/templates-app/star-survivor/package.json +23 -0
- package/templates-app/star-survivor/src/behaviors.ts +624 -0
- package/templates-app/star-survivor/src/game.scene.json +464 -0
- package/templates-app/star-survivor/src/main.ts +49 -0
- package/templates-app/star-survivor/tsconfig.json +13 -0
- package/templates-app/star-survivor/verify.ts +193 -0
- package/templates-app/star-survivor/vite.config.ts +12 -0
- package/templates-app/tps-3d/package.json +1 -1
- package/templates-app/tps-3d/src/game.scene.json +6 -3
- package/templates-app/tps-3d/verify.ts +17 -1
- package/templates-app/village-quest-3d/package.json +1 -1
- package/templates-app/village-quest-3d/src/grove.scene.json +14 -13
- package/templates-app/village-quest-3d/src/village.scene.json +5 -5
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { C as
|
|
2
|
-
import { _ as Engine, t as registerCoreNodes } from "./register-
|
|
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-CDrAQqPp.js";
|
|
3
3
|
import { t as IncantoError } from "./errors-BpWbnbb_.js";
|
|
4
|
-
import { n as jsonEquals, t as jsonClone } from "./json-
|
|
5
|
-
import { l as registerNode } from "./registry-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
1019
|
-
|
|
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:
|
|
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 {
|
|
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 };
|
|
@@ -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.
|
|
217
|
+
const VERSION = "0.59.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-
|
|
1
|
+
import { g as AudioPlayer } from "./register-CDrAQqPp.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 {
|
|
2
|
-
import { _ as Engine } from "./register-
|
|
1
|
+
import { c as resolveViewport, j as registerBehavior, n as loadScene } from "./loader-D8n7TU8W.js";
|
|
2
|
+
import { _ as Engine } from "./register-CDrAQqPp.js";
|
|
3
3
|
import { t as IncantoError } from "./errors-BpWbnbb_.js";
|
|
4
|
-
import { n as jsonEquals, t as jsonClone } from "./json-
|
|
5
|
-
import { i as getNodeSchema, s as mergeStaticProps } from "./registry-
|
|
6
|
-
import { n as startRecording } from "./replay-
|
|
7
|
-
import { n as registerGameplayBehaviors } from "./gameplay-
|
|
8
|
-
import { t as registerNodes2D } from "./register-
|
|
9
|
-
import { n as registerNodes3D, t as resolveEnvironmentHdri } from "./environment-presets-
|
|
10
|
-
import { n as registerNodesNet, t as createSplitScreen } from "./split-screen-
|
|
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-CEPyQtF_.js";
|
|
7
|
+
import { n as registerGameplayBehaviors } from "./gameplay-BBEjPFsR.js";
|
|
8
|
+
import { t as registerNodes2D } from "./register-BpFcgdcL.js";
|
|
9
|
+
import { n as registerNodes3D, t as resolveEnvironmentHdri } from "./environment-presets-DRAz5EV9.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-DDMZutQ6.js";
|
|
11
11
|
import { Box3, Euler, Matrix4, PerspectiveCamera, Quaternion, Vector3 } from "three";
|
|
12
12
|
//#region src/test/framing.ts
|
|
13
13
|
/**
|
|
@@ -474,6 +474,24 @@ function framingText(report) {
|
|
|
474
474
|
const FALL_2D_PX = 1e3;
|
|
475
475
|
/** "Reached it", in pixels — one 32 px tile, next to a ~34 px character. */
|
|
476
476
|
const REACH_2D_PX = 32;
|
|
477
|
+
/**
|
|
478
|
+
* How hard this scene pulls, in its own units — 0 only for a scene that says so.
|
|
479
|
+
*
|
|
480
|
+
* Gravity is what makes "left the world downward" mean anything, and a
|
|
481
|
+
* top-down game has none. But ABSENT is not ZERO: the physics adapters default
|
|
482
|
+
* an undeclared scene to [0, 980] px/s² in 2D and [0, -9.81, 0] m/s² in 3D, so
|
|
483
|
+
* a platformer that never wrote a physics header still falls, and reading
|
|
484
|
+
* absent as zero would have stopped reporting the pit it fell into.
|
|
485
|
+
*/
|
|
486
|
+
function gravityMagnitude(physics) {
|
|
487
|
+
const g = physics?.gravity;
|
|
488
|
+
if (g === void 0) return 1;
|
|
489
|
+
if (!Array.isArray(g)) return 1;
|
|
490
|
+
return g.reduce((sum, v) => sum + (typeof v === "number" ? Math.abs(v) : 0), 0);
|
|
491
|
+
}
|
|
492
|
+
/** Furthest the player ever got from its spawn, below which it went nowhere. */
|
|
493
|
+
const STUCK_3D_M = 3;
|
|
494
|
+
const STUCK_2D_PX = 96;
|
|
477
495
|
const CONTROLLERS = new Set(["CharacterController2D", "CharacterController3D"]);
|
|
478
496
|
/**
|
|
479
497
|
* Who the driver is playing as.
|
|
@@ -718,7 +736,8 @@ async function runOnce(json, seed, opts) {
|
|
|
718
736
|
0
|
|
719
737
|
];
|
|
720
738
|
const down = scene.dimension === "2d" ? 1 : -1;
|
|
721
|
-
const
|
|
739
|
+
const pull = gravityMagnitude(scene.physics);
|
|
740
|
+
const floor = opts.fallBelow ?? (pull > 0 ? start[1] + down * (down > 0 ? FALL_2D_PX : 50) : null);
|
|
722
741
|
const radius = opts.reachRadius ?? (down > 0 ? REACH_2D_PX : 2);
|
|
723
742
|
const recorder = startRecording(engine);
|
|
724
743
|
const rand = () => engine.rng.next();
|
|
@@ -726,10 +745,12 @@ async function runOnce(json, seed, opts) {
|
|
|
726
745
|
const reached = /* @__PURE__ */ new Set();
|
|
727
746
|
const stepMs = 1e3 / 60;
|
|
728
747
|
const deadline = (opts.seconds ?? 60) * 1e3;
|
|
729
|
-
let outcome =
|
|
748
|
+
let outcome = null;
|
|
730
749
|
let at = start;
|
|
750
|
+
let wentAsFarAs = 0;
|
|
731
751
|
while (session.timeMs < deadline) {
|
|
732
752
|
at = player ? worldOf(player) : start;
|
|
753
|
+
wentAsFarAs = Math.max(wentAsFarAs, Math.hypot(at[0] - start[0], at[2] - start[2]));
|
|
733
754
|
driver.step(stepMs / 1e3, at);
|
|
734
755
|
session.step(stepMs);
|
|
735
756
|
if (player) for (const [path, node] of targetNodes) {
|
|
@@ -746,15 +767,17 @@ async function runOnce(json, seed, opts) {
|
|
|
746
767
|
outcome = "error";
|
|
747
768
|
break;
|
|
748
769
|
}
|
|
749
|
-
if (player && (at[1] - floor) * down > 0) {
|
|
770
|
+
if (player && floor !== null && (at[1] - floor) * down > 0) {
|
|
750
771
|
outcome = "fell";
|
|
751
772
|
break;
|
|
752
773
|
}
|
|
753
774
|
}
|
|
754
775
|
driver.release();
|
|
776
|
+
const WENT_NOWHERE = scene.dimension === "2d" ? STUCK_2D_PX : STUCK_3D_M;
|
|
777
|
+
outcome ??= wentAsFarAs > WENT_NOWHERE ? "unfinished" : "stuck";
|
|
755
778
|
const heard = engine.audio.recent().map((e) => e.from);
|
|
756
779
|
const shown = engine.effects.recent().map((e) => e.from);
|
|
757
|
-
const bad = outcome !== "won";
|
|
780
|
+
const bad = outcome !== "won" && outcome !== "unfinished";
|
|
758
781
|
const run = {
|
|
759
782
|
seed,
|
|
760
783
|
outcome,
|
|
@@ -857,12 +880,15 @@ function playtestText(report) {
|
|
|
857
880
|
"error",
|
|
858
881
|
"fell",
|
|
859
882
|
"lost",
|
|
860
|
-
"stuck"
|
|
883
|
+
"stuck",
|
|
884
|
+
"unfinished"
|
|
861
885
|
]) {
|
|
862
886
|
const hits = runs.filter((r) => r.outcome === bad);
|
|
863
887
|
if (hits.length === 0) continue;
|
|
864
888
|
const where = bad === "fell" ? ` (last at y=${hits[0].endedAt[1].toFixed(1)})` : "";
|
|
865
|
-
|
|
889
|
+
const mark = bad === "unfinished" ? "·" : "✗";
|
|
890
|
+
const what = bad === "unfinished" ? "ran out of time still playing" : `${bad} in ${pct(hits.length, total)}`;
|
|
891
|
+
lines.push(bad === "unfinished" ? ` ${mark} ${pct(hits.length, total)} ${what}` : ` ${mark} ${what}${where}`);
|
|
866
892
|
}
|
|
867
893
|
const everReached = new Set(runs.flatMap((r) => r.reached));
|
|
868
894
|
const missed = targets.filter((path) => !everReached.has(path));
|
|
@@ -1012,6 +1038,177 @@ function facingText(report) {
|
|
|
1012
1038
|
return `facing: ${report.skin} faces its travel (dot ${report.dot.toFixed(2)})`;
|
|
1013
1039
|
}
|
|
1014
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
|
|
1015
1212
|
//#region src/test/feel.ts
|
|
1016
1213
|
const STEP_MS = 1e3 / 60;
|
|
1017
1214
|
/** Drop height for the buffer probe — long enough to outlast a coyote window. */
|
|
@@ -1404,10 +1601,12 @@ async function playMultiplayer(opts) {
|
|
|
1404
1601
|
const seconds = opts.seconds ?? 5;
|
|
1405
1602
|
const stepMs = opts.stepMs ?? 1e3 / 60;
|
|
1406
1603
|
const accounts = Array.from({ length: count }, (_, i) => `p${i + 1}`);
|
|
1407
|
-
const { players, dispose } = await createSplitScreen({
|
|
1604
|
+
const { server, players, dispose } = await createSplitScreen({
|
|
1408
1605
|
scene: opts.scene,
|
|
1409
1606
|
...opts.server ? { server: opts.server } : {},
|
|
1410
1607
|
...opts.scenes ? { scenes: opts.scenes } : {},
|
|
1608
|
+
...opts.seed !== void 0 ? { seed: opts.seed } : {},
|
|
1609
|
+
...opts.load ? { load: opts.load } : {},
|
|
1411
1610
|
accounts,
|
|
1412
1611
|
setup: () => {}
|
|
1413
1612
|
});
|
|
@@ -1424,26 +1623,87 @@ async function playMultiplayer(opts) {
|
|
|
1424
1623
|
if (frame % 6 === 0) await settle();
|
|
1425
1624
|
}
|
|
1426
1625
|
await settle();
|
|
1427
|
-
|
|
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
|
+
});
|
|
1428
1660
|
dispose();
|
|
1429
1661
|
return report;
|
|
1430
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-CDrAQqPp.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
|
+
}
|
|
1431
1691
|
/** Give the server's promise queue a turn. */
|
|
1432
1692
|
function settle() {
|
|
1433
1693
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
1434
1694
|
}
|
|
1435
|
-
function summarize(players, seconds, frames) {
|
|
1695
|
+
function summarize(players, seconds, frames, matchDetail, agreement) {
|
|
1436
1696
|
const rooms = players.map((p) => p.manager.roomId);
|
|
1437
|
-
const detail =
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
spawned: spawnerCounts(p.engine),
|
|
1441
|
-
sawAccounts: Object.keys(p.manager.latestUserStates ?? {}).filter((a) => a !== p.account),
|
|
1442
|
-
errors: p.engine.stats().errors
|
|
1697
|
+
const detail = matchDetail.map((d, i) => ({
|
|
1698
|
+
...d,
|
|
1699
|
+
errors: players[i]?.engine.stats().errors ?? d.errors
|
|
1443
1700
|
}));
|
|
1444
|
-
const problems = multiplayProblems(detail, rooms);
|
|
1701
|
+
const problems = [...multiplayProblems(detail, rooms), ...agreement.disagreements.map((d) => d.summary)];
|
|
1445
1702
|
return {
|
|
1446
1703
|
ok: problems.length === 0,
|
|
1704
|
+
disagreements: agreement.disagreements,
|
|
1705
|
+
keysCompared: agreement.keysCompared,
|
|
1706
|
+
lateJoinChecked: agreement.lateJoinChecked,
|
|
1447
1707
|
seconds,
|
|
1448
1708
|
frames,
|
|
1449
1709
|
rooms,
|
|
@@ -1476,7 +1736,7 @@ function spawnerCounts(engine) {
|
|
|
1476
1736
|
const root = engine.scene?.tree.root;
|
|
1477
1737
|
if (!root) return out;
|
|
1478
1738
|
const walk = (node) => {
|
|
1479
|
-
if (node
|
|
1739
|
+
if (node instanceof NetworkSpawner) out[node.name] = node.children.length;
|
|
1480
1740
|
for (const child of node.children) walk(child);
|
|
1481
1741
|
};
|
|
1482
1742
|
walk(root);
|
|
@@ -1485,6 +1745,7 @@ function spawnerCounts(engine) {
|
|
|
1485
1745
|
/** The report as the one paragraph a reader needs. */
|
|
1486
1746
|
function multiplayText(report) {
|
|
1487
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)`);
|
|
1488
1749
|
for (const p of report.players) {
|
|
1489
1750
|
const spawned = Object.entries(p.spawned).map(([name, n]) => `${name}=${n}`).join(" ");
|
|
1490
1751
|
lines.push(` ${p.account}: sees [${p.sawAccounts.join(", ") || "—"}]` + (spawned ? ` spawned ${spawned}` : "") + (p.errors ? ` · ${p.errors} error(s)` : ""));
|
|
@@ -1762,10 +2023,10 @@ async function runScript(json, opts) {
|
|
|
1762
2023
|
engine.setScene(scene);
|
|
1763
2024
|
const physics = opts.physics ?? "auto";
|
|
1764
2025
|
if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
|
|
1765
|
-
const { enablePhysics2D } = await import("./physics-2d-
|
|
2026
|
+
const { enablePhysics2D } = await import("./physics-2d-BaRSRrrZ.js").then((n) => n.r);
|
|
1766
2027
|
await enablePhysics2D(engine);
|
|
1767
2028
|
} else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
|
|
1768
|
-
const { enablePhysics3D } = await import("./physics-3d-
|
|
2029
|
+
const { enablePhysics3D } = await import("./physics-3d-CYxjh-HW.js").then((n) => n.r);
|
|
1769
2030
|
await enablePhysics3D(engine);
|
|
1770
2031
|
}
|
|
1771
2032
|
const failures = [];
|
|
@@ -1878,10 +2139,10 @@ async function createPlaySession(json, opts = {}) {
|
|
|
1878
2139
|
engine.setScene(scene);
|
|
1879
2140
|
const physics = opts.physics ?? "auto";
|
|
1880
2141
|
if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
|
|
1881
|
-
const { enablePhysics2D } = await import("./physics-2d-
|
|
2142
|
+
const { enablePhysics2D } = await import("./physics-2d-BaRSRrrZ.js").then((n) => n.r);
|
|
1882
2143
|
await enablePhysics2D(engine);
|
|
1883
2144
|
} else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
|
|
1884
|
-
const { enablePhysics3D } = await import("./physics-3d-
|
|
2145
|
+
const { enablePhysics3D } = await import("./physics-3d-CYxjh-HW.js").then((n) => n.r);
|
|
1885
2146
|
await enablePhysics3D(engine);
|
|
1886
2147
|
}
|
|
1887
2148
|
const stepMs = 1e3 / (opts.fixedHz ?? 60);
|
|
@@ -1919,4 +2180,4 @@ async function createPlaySession(json, opts = {}) {
|
|
|
1919
2180
|
};
|
|
1920
2181
|
}
|
|
1921
2182
|
//#endregion
|
|
1922
|
-
export {
|
|
2183
|
+
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 };
|