@series-inc/rundot-syncplay 5.23.0-beta.7
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 +610 -0
- package/dist/animation.d.ts +119 -0
- package/dist/animation.js +488 -0
- package/dist/asset-cooking.d.ts +82 -0
- package/dist/asset-cooking.js +364 -0
- package/dist/authority-room.d.ts +217 -0
- package/dist/authority-room.js +701 -0
- package/dist/bot-documents.d.ts +115 -0
- package/dist/bot-documents.js +549 -0
- package/dist/bot-primitives.d.ts +152 -0
- package/dist/bot-primitives.js +472 -0
- package/dist/browser.d.ts +57 -0
- package/dist/browser.js +53 -0
- package/dist/canonical.d.ts +12 -0
- package/dist/canonical.js +288 -0
- package/dist/certification.d.ts +455 -0
- package/dist/certification.js +1855 -0
- package/dist/chaos-certification.d.ts +56 -0
- package/dist/chaos-certification.js +1397 -0
- package/dist/cjs/animation.js +495 -0
- package/dist/cjs/asset-cooking.js +372 -0
- package/dist/cjs/authority-room.js +704 -0
- package/dist/cjs/bot-documents.js +556 -0
- package/dist/cjs/bot-primitives.js +482 -0
- package/dist/cjs/browser.js +96 -0
- package/dist/cjs/canonical.js +299 -0
- package/dist/cjs/certification.js +1860 -0
- package/dist/cjs/chaos-certification.js +1402 -0
- package/dist/cjs/collections.js +36 -0
- package/dist/cjs/collision.js +96 -0
- package/dist/cjs/commands.js +987 -0
- package/dist/cjs/config-bundle.js +491 -0
- package/dist/cjs/creator.js +30 -0
- package/dist/cjs/deterministic-primitives.js +134 -0
- package/dist/cjs/deterministic-session.js +1410 -0
- package/dist/cjs/ecs-lite.js +155 -0
- package/dist/cjs/effects-adapter.js +97 -0
- package/dist/cjs/engine-complete.js +2297 -0
- package/dist/cjs/engine-core-proof.js +70 -0
- package/dist/cjs/engine-parity-matrix.js +874 -0
- package/dist/cjs/engine-parity.js +3183 -0
- package/dist/cjs/engine-runtime.js +3141 -0
- package/dist/cjs/errors.js +22 -0
- package/dist/cjs/events.js +247 -0
- package/dist/cjs/examples/counter-duel.js +157 -0
- package/dist/cjs/examples/fixed-arena.js +368 -0
- package/dist/cjs/index.js +229 -0
- package/dist/cjs/input-authority.js +150 -0
- package/dist/cjs/input-button.js +256 -0
- package/dist/cjs/input-timeline.js +418 -0
- package/dist/cjs/instant-replay.js +66 -0
- package/dist/cjs/lag-compensation3d.js +837 -0
- package/dist/cjs/local-authority-server-runner.js +1121 -0
- package/dist/cjs/local-authority-wire.js +1497 -0
- package/dist/cjs/local-authority.js +817 -0
- package/dist/cjs/local-ws-adapter.js +150 -0
- package/dist/cjs/match-log.js +61 -0
- package/dist/cjs/math.js +157 -0
- package/dist/cjs/mock-network-adapter.js +26 -0
- package/dist/cjs/movement.js +703 -0
- package/dist/cjs/movement3d.js +1246 -0
- package/dist/cjs/multiclient-authority.js +308 -0
- package/dist/cjs/multiplayer-service.js +434 -0
- package/dist/cjs/navigation.js +347 -0
- package/dist/cjs/navmesh.js +1437 -0
- package/dist/cjs/networked-client.js +691 -0
- package/dist/cjs/networked-input-authority.js +443 -0
- package/dist/cjs/node.js +47 -0
- package/dist/cjs/number-lab.js +78 -0
- package/dist/cjs/offline-session.js +370 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/pause-resume.js +43 -0
- package/dist/cjs/physics-cert.js +426 -0
- package/dist/cjs/physics2d.js +751 -0
- package/dist/cjs/physics3d.js +1996 -0
- package/dist/cjs/prediction-culling.js +203 -0
- package/dist/cjs/protocol-lab.js +1299 -0
- package/dist/cjs/r3f-render-adapter.js +209 -0
- package/dist/cjs/random.js +59 -0
- package/dist/cjs/replay-bundle.js +1598 -0
- package/dist/cjs/replay-inspect.js +236 -0
- package/dist/cjs/replay-scoring.js +68 -0
- package/dist/cjs/replay.js +356 -0
- package/dist/cjs/reports.js +181 -0
- package/dist/cjs/rollback-history.js +251 -0
- package/dist/cjs/runtime-guards.js +111 -0
- package/dist/cjs/sample-lab.js +145 -0
- package/dist/cjs/sample-scenes.js +752 -0
- package/dist/cjs/schema-artifacts.js +359 -0
- package/dist/cjs/schema-codegen.js +544 -0
- package/dist/cjs/schema-dsl-binary.js +655 -0
- package/dist/cjs/schema.js +319 -0
- package/dist/cjs/sdk-offline.js +99 -0
- package/dist/cjs/session-promotion.js +74 -0
- package/dist/cjs/session-snapshot.js +265 -0
- package/dist/cjs/session-wire.js +216 -0
- package/dist/cjs/signals.js +103 -0
- package/dist/cjs/sparse-set.js +169 -0
- package/dist/cjs/synctest.js +136 -0
- package/dist/cjs/system-lifecycle.js +286 -0
- package/dist/cjs/timers.js +61 -0
- package/dist/cjs/tools/static-checker.js +1468 -0
- package/dist/cjs/types.js +2 -0
- package/dist/cjs/venus-mp-budget.js +125 -0
- package/dist/cjs/ws-frame.js +255 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +11379 -0
- package/dist/collections.d.ts +9 -0
- package/dist/collections.js +30 -0
- package/dist/collision.d.ts +25 -0
- package/dist/collision.js +85 -0
- package/dist/commands.d.ts +137 -0
- package/dist/commands.js +977 -0
- package/dist/config-bundle.d.ts +89 -0
- package/dist/config-bundle.js +483 -0
- package/dist/creator.d.ts +20 -0
- package/dist/creator.js +11 -0
- package/dist/deterministic-primitives.d.ts +59 -0
- package/dist/deterministic-primitives.js +126 -0
- package/dist/deterministic-session.d.ts +197 -0
- package/dist/deterministic-session.js +1401 -0
- package/dist/ecs-lite.d.ts +26 -0
- package/dist/ecs-lite.js +152 -0
- package/dist/effects-adapter.d.ts +21 -0
- package/dist/effects-adapter.js +94 -0
- package/dist/engine-complete.d.ts +45 -0
- package/dist/engine-complete.js +2292 -0
- package/dist/engine-core-proof.d.ts +26 -0
- package/dist/engine-core-proof.js +66 -0
- package/dist/engine-parity-matrix.d.ts +300 -0
- package/dist/engine-parity-matrix.js +869 -0
- package/dist/engine-parity.d.ts +74 -0
- package/dist/engine-parity.js +3178 -0
- package/dist/engine-runtime.d.ts +351 -0
- package/dist/engine-runtime.js +3123 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +17 -0
- package/dist/events.d.ts +45 -0
- package/dist/events.js +242 -0
- package/dist/examples/counter-duel.d.ts +43 -0
- package/dist/examples/counter-duel.js +150 -0
- package/dist/examples/fixed-arena.d.ts +110 -0
- package/dist/examples/fixed-arena.js +356 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.js +66 -0
- package/dist/input-authority.d.ts +82 -0
- package/dist/input-authority.js +147 -0
- package/dist/input-button.d.ts +36 -0
- package/dist/input-button.js +249 -0
- package/dist/input-timeline.d.ts +65 -0
- package/dist/input-timeline.js +412 -0
- package/dist/instant-replay.d.ts +59 -0
- package/dist/instant-replay.js +62 -0
- package/dist/lag-compensation3d.d.ts +146 -0
- package/dist/lag-compensation3d.js +827 -0
- package/dist/local-authority-server-runner.d.ts +79 -0
- package/dist/local-authority-server-runner.js +1118 -0
- package/dist/local-authority-wire.d.ts +142 -0
- package/dist/local-authority-wire.js +1477 -0
- package/dist/local-authority.d.ts +160 -0
- package/dist/local-authority.js +810 -0
- package/dist/local-ws-adapter.d.ts +14 -0
- package/dist/local-ws-adapter.js +146 -0
- package/dist/match-log.d.ts +74 -0
- package/dist/match-log.js +57 -0
- package/dist/math.d.ts +41 -0
- package/dist/math.js +154 -0
- package/dist/mock-network-adapter.d.ts +13 -0
- package/dist/mock-network-adapter.js +23 -0
- package/dist/movement.d.ts +155 -0
- package/dist/movement.js +694 -0
- package/dist/movement3d.d.ts +195 -0
- package/dist/movement3d.js +1238 -0
- package/dist/multiclient-authority.d.ts +30 -0
- package/dist/multiclient-authority.js +305 -0
- package/dist/multiplayer-service.d.ts +153 -0
- package/dist/multiplayer-service.js +429 -0
- package/dist/navigation.d.ts +91 -0
- package/dist/navigation.js +338 -0
- package/dist/navmesh.d.ts +236 -0
- package/dist/navmesh.js +1423 -0
- package/dist/networked-client.d.ts +124 -0
- package/dist/networked-client.js +688 -0
- package/dist/networked-input-authority.d.ts +51 -0
- package/dist/networked-input-authority.js +439 -0
- package/dist/node.d.ts +22 -0
- package/dist/node.js +19 -0
- package/dist/number-lab.d.ts +15 -0
- package/dist/number-lab.js +75 -0
- package/dist/offline-session.d.ts +2 -0
- package/dist/offline-session.js +367 -0
- package/dist/pause-resume.d.ts +48 -0
- package/dist/pause-resume.js +40 -0
- package/dist/physics-cert.d.ts +40 -0
- package/dist/physics-cert.js +389 -0
- package/dist/physics2d.d.ts +142 -0
- package/dist/physics2d.js +741 -0
- package/dist/physics3d.d.ts +285 -0
- package/dist/physics3d.js +1979 -0
- package/dist/prediction-culling.d.ts +34 -0
- package/dist/prediction-culling.js +200 -0
- package/dist/protocol-lab.d.ts +115 -0
- package/dist/protocol-lab.js +1295 -0
- package/dist/r3f-render-adapter.d.ts +61 -0
- package/dist/r3f-render-adapter.js +203 -0
- package/dist/random.d.ts +13 -0
- package/dist/random.js +55 -0
- package/dist/replay-bundle.d.ts +150 -0
- package/dist/replay-bundle.js +1586 -0
- package/dist/replay-inspect.d.ts +60 -0
- package/dist/replay-inspect.js +228 -0
- package/dist/replay-scoring.d.ts +28 -0
- package/dist/replay-scoring.js +65 -0
- package/dist/replay.d.ts +17 -0
- package/dist/replay.js +351 -0
- package/dist/reports.d.ts +176 -0
- package/dist/reports.js +174 -0
- package/dist/rollback-history.d.ts +46 -0
- package/dist/rollback-history.js +247 -0
- package/dist/runtime-guards.d.ts +1 -0
- package/dist/runtime-guards.js +108 -0
- package/dist/sample-lab.d.ts +32 -0
- package/dist/sample-lab.js +134 -0
- package/dist/sample-scenes.d.ts +86 -0
- package/dist/sample-scenes.js +745 -0
- package/dist/schema-artifacts.d.ts +49 -0
- package/dist/schema-artifacts.js +356 -0
- package/dist/schema-codegen.d.ts +21 -0
- package/dist/schema-codegen.js +538 -0
- package/dist/schema-dsl-binary.d.ts +75 -0
- package/dist/schema-dsl-binary.js +648 -0
- package/dist/schema.d.ts +87 -0
- package/dist/schema.js +315 -0
- package/dist/sdk-offline.d.ts +34 -0
- package/dist/sdk-offline.js +96 -0
- package/dist/session-promotion.d.ts +26 -0
- package/dist/session-promotion.js +70 -0
- package/dist/session-snapshot.d.ts +95 -0
- package/dist/session-snapshot.js +255 -0
- package/dist/session-wire.d.ts +89 -0
- package/dist/session-wire.js +211 -0
- package/dist/signals.d.ts +31 -0
- package/dist/signals.js +99 -0
- package/dist/sparse-set.d.ts +34 -0
- package/dist/sparse-set.js +165 -0
- package/dist/synctest.d.ts +31 -0
- package/dist/synctest.js +133 -0
- package/dist/system-lifecycle.d.ts +72 -0
- package/dist/system-lifecycle.js +281 -0
- package/dist/timers.d.ts +17 -0
- package/dist/timers.js +53 -0
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.js +4 -0
- package/dist/tools/static-checker.d.ts +31 -0
- package/dist/tools/static-checker.js +1462 -0
- package/dist/tools/vite-plugin.d.ts +69 -0
- package/dist/tools/vite-plugin.js +203 -0
- package/dist/types.d.ts +148 -0
- package/dist/types.js +1 -0
- package/dist/venus-mp-budget.d.ts +29 -0
- package/dist/venus-mp-budget.js +122 -0
- package/dist/ws-frame.d.ts +49 -0
- package/dist/ws-frame.js +247 -0
- package/package.json +169 -0
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createDeterministicAuthorityRoom = createDeterministicAuthorityRoom;
|
|
4
|
+
const canonical_js_1 = require("./canonical.js");
|
|
5
|
+
const errors_js_1 = require("./errors.js");
|
|
6
|
+
const input_authority_js_1 = require("./input-authority.js");
|
|
7
|
+
const local_authority_wire_js_1 = require("./local-authority-wire.js");
|
|
8
|
+
const session_snapshot_js_1 = require("./session-snapshot.js");
|
|
9
|
+
const session_wire_js_1 = require("./session-wire.js");
|
|
10
|
+
const DEFAULT_SESSION_MSG_TYPE = 'rdm';
|
|
11
|
+
/** One hour at 60 Hz — see DeterministicAuthorityRoomConfig.historyRetentionTicks. */
|
|
12
|
+
const DEFAULT_HISTORY_RETENTION_TICKS = 216_000;
|
|
13
|
+
/** Frames per history-replay unicast. Bounds message size during catch-up. */
|
|
14
|
+
const HISTORY_CHUNK_FRAMES = 500;
|
|
15
|
+
/** One minute at 60 Hz — see DeterministicAuthorityRoomConfig.snapshotCadenceTicks. */
|
|
16
|
+
const DEFAULT_SNAPSHOT_CADENCE_TICKS = 3600;
|
|
17
|
+
/** Ten seconds at 60 Hz — see DeterministicAuthorityRoomConfig.transferTimeoutTicks. */
|
|
18
|
+
const DEFAULT_TRANSFER_TIMEOUT_TICKS = 600;
|
|
19
|
+
function createDeterministicAuthorityRoom(transport, config) {
|
|
20
|
+
const sessionMsgType = config.sessionMsgType ?? DEFAULT_SESSION_MSG_TYPE;
|
|
21
|
+
const historyRetentionTicks = config.historyRetentionTicks ?? DEFAULT_HISTORY_RETENTION_TICKS;
|
|
22
|
+
const authority = (0, input_authority_js_1.createInputAuthority)({
|
|
23
|
+
playerCount: config.playerCount,
|
|
24
|
+
hardToleranceTicks: config.hardToleranceTicks,
|
|
25
|
+
neutralInput: config.neutralInput,
|
|
26
|
+
maxFutureTicks: config.maxFutureTicks ?? 256,
|
|
27
|
+
// Retention serves two masters: the per-tick redundancy rebroadcast window
|
|
28
|
+
// (never below redundancyWindow*4) and late-join/rejoin history replay
|
|
29
|
+
// (historyRetentionTicks — explicit caller choice wins; the generous floor
|
|
30
|
+
// lives in DEFAULT_HISTORY_RETENTION_TICKS, not here, so tests can shrink it).
|
|
31
|
+
retainedConfirmedFrames: Math.max(historyRetentionTicks, config.redundancyWindowTicks * 4),
|
|
32
|
+
...(config.restoreFrom === undefined
|
|
33
|
+
? {}
|
|
34
|
+
: { restoreFrom: { confirmedThrough: config.restoreFrom.confirmedThrough, frames: config.restoreFrom.tail } }),
|
|
35
|
+
});
|
|
36
|
+
const slotByPlayer = new Map();
|
|
37
|
+
// G11 spectators: read-only connections with no slot. Their input /
|
|
38
|
+
// checksum-report / net-stats envelopes are already dropped by the
|
|
39
|
+
// slot-ownership check in onPlayerMessage.
|
|
40
|
+
const spectators = new Set();
|
|
41
|
+
// Rejoin reclaim map: every playerId ever seated, permanently bound to its
|
|
42
|
+
// slot. A returning player re-attaches to the SAME slot (their inputs must
|
|
43
|
+
// keep attributing to the same sim entity), and can never be treated as a
|
|
44
|
+
// fresh joiner stealing capacity.
|
|
45
|
+
const everSlotByPlayer = new Map();
|
|
46
|
+
// Per-tick client checksum reports, for desync detection (telemetry, never enforcement).
|
|
47
|
+
const checksumReports = new Map();
|
|
48
|
+
const desyncWindow = Math.max(config.redundancyWindowTicks * 8, 512);
|
|
49
|
+
// Monotonic — slot numbers are assigned in join order and never reused, even after a player
|
|
50
|
+
// leaves. Using slotByPlayer.size would recycle a departed player's slot onto the next joiner,
|
|
51
|
+
// colliding with a still-present player and orphaning the vacated slot.
|
|
52
|
+
let assignedSlots = 0;
|
|
53
|
+
let now = -1;
|
|
54
|
+
// M5.5 snapshot acquisition state. The authority stores at most one validated
|
|
55
|
+
// snapshot and runs at most one donor transfer at a time.
|
|
56
|
+
const snapshotCadenceTicks = config.snapshotCadenceTicks ?? DEFAULT_SNAPSHOT_CADENCE_TICKS;
|
|
57
|
+
const transferTimeoutTicks = config.transferTimeoutTicks ?? DEFAULT_TRANSFER_TIMEOUT_TICKS;
|
|
58
|
+
const roomDescriptorHash = (0, session_snapshot_js_1.deterministicRoomDescriptorHash)({
|
|
59
|
+
seed: config.seed,
|
|
60
|
+
playerCount: config.playerCount,
|
|
61
|
+
tickRateHz: config.tickRateHz,
|
|
62
|
+
hardToleranceTicks: config.hardToleranceTicks,
|
|
63
|
+
});
|
|
64
|
+
let storedSnapshot;
|
|
65
|
+
let pendingTransfer;
|
|
66
|
+
let nextCadenceTarget = snapshotCadenceTicks;
|
|
67
|
+
let wireSequence = 0;
|
|
68
|
+
let donorCursor = 0;
|
|
69
|
+
let forwardCounter = 0;
|
|
70
|
+
let reportsDirty = false;
|
|
71
|
+
// G8 netcode telemetry: cumulative substituted-input ticks per slot (counted
|
|
72
|
+
// once per newly-confirmed tick) and the latest client-reported stats.
|
|
73
|
+
const substitutedTicksBySlot = new Map();
|
|
74
|
+
const clientNetStatsBySlot = new Map();
|
|
75
|
+
let substitutionCountedThrough = -1;
|
|
76
|
+
// Highest tick ever included in a confirmed-input broadcast — the anchor
|
|
77
|
+
// that guarantees frontier jumps larger than the redundancy window can
|
|
78
|
+
// never skip a tick out of the broadcast stream (see onTick).
|
|
79
|
+
let broadcastThrough = -1;
|
|
80
|
+
// True for a crash-recovered room: reconnects must re-issue session-start so
|
|
81
|
+
// a surviving client (whose timeline forked past the restored frontier)
|
|
82
|
+
// takes its full-reset path instead of waiting on ticks that will re-confirm
|
|
83
|
+
// differently.
|
|
84
|
+
let restoredRoom = false;
|
|
85
|
+
if (config.restoreFrom !== undefined) {
|
|
86
|
+
const restore = config.restoreFrom;
|
|
87
|
+
for (const [playerId, slot] of Object.entries(restore.everSlots)) {
|
|
88
|
+
(0, errors_js_1.invariant)(Number.isInteger(slot) && slot >= 0 && slot < config.playerCount, 'restored everSlots entry is outside the player count', 'authority-room.restore-slot');
|
|
89
|
+
everSlotByPlayer.set(playerId, slot);
|
|
90
|
+
assignedSlots = Math.max(assignedSlots, slot + 1);
|
|
91
|
+
}
|
|
92
|
+
for (const playerId of restore.seatedPlayers) {
|
|
93
|
+
const slot = everSlotByPlayer.get(playerId);
|
|
94
|
+
(0, errors_js_1.invariant)(slot !== undefined, 'restored seated player is not in everSlots', 'authority-room.restore-seated');
|
|
95
|
+
slotByPlayer.set(playerId, slot);
|
|
96
|
+
}
|
|
97
|
+
(0, errors_js_1.invariant)(Number.isInteger(restore.now) && restore.now >= restore.confirmedThrough, 'restored now must be at or past the restored frontier', 'authority-room.restore-now');
|
|
98
|
+
now = restore.now;
|
|
99
|
+
if (restore.snapshotBytes !== undefined) {
|
|
100
|
+
const decoded = (0, session_snapshot_js_1.decodeDeterministicStateSnapshot)(restore.snapshotBytes);
|
|
101
|
+
(0, errors_js_1.invariant)(decoded.ok, 'restored snapshotBytes do not decode', 'authority-room.restore-snapshot');
|
|
102
|
+
// With preserveFullHistory the persisted tail reaches back to tick 0
|
|
103
|
+
// (below the snapshot); a tail starting ABOVE the snapshot tick would
|
|
104
|
+
// leave an unreplayable gap and must still fail loud.
|
|
105
|
+
(0, errors_js_1.invariant)(restore.tail[0] !== undefined && restore.tail[0].tick <= decoded.snapshot.tick, 'restored tail must start at or below the snapshot tick', 'authority-room.restore-tail');
|
|
106
|
+
// A tampered/corrupt record whose snapshot sits beyond the restored
|
|
107
|
+
// frontier would hydrate joiners into the authority's future, silently
|
|
108
|
+
// dropping confirms until the frontier catches up — fail loud instead.
|
|
109
|
+
(0, errors_js_1.invariant)(decoded.snapshot.tick <= restore.confirmedThrough, 'restored snapshot must not be beyond the restored frontier', 'authority-room.restore-snapshot-frontier');
|
|
110
|
+
storedSnapshot = {
|
|
111
|
+
tick: decoded.snapshot.tick,
|
|
112
|
+
snapshotChecksum: (0, canonical_js_1.defaultChecksum)(restore.snapshotBytes),
|
|
113
|
+
snapshotBytes: restore.snapshotBytes,
|
|
114
|
+
identity: {
|
|
115
|
+
descriptorHash: roomDescriptorHash,
|
|
116
|
+
...(restore.schemaHash === undefined ? {} : { schemaHash: restore.schemaHash }),
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
(0, errors_js_1.invariant)(restore.tail[0]?.tick === 0, 'a snapshot-less restore requires history reaching tick 0', 'authority-room.restore-history');
|
|
122
|
+
}
|
|
123
|
+
nextCadenceTarget = (Math.floor(restore.confirmedThrough / snapshotCadenceTicks) + 1) * snapshotCadenceTicks;
|
|
124
|
+
// Restored history is served per-player by resyncSeatedPlayers — the
|
|
125
|
+
// broadcast anchor starts at the restored frontier, not tick 0.
|
|
126
|
+
broadcastThrough = restore.confirmedThrough;
|
|
127
|
+
restoredRoom = true;
|
|
128
|
+
}
|
|
129
|
+
function sendSessionStart(playerId, slot, role = 'player') {
|
|
130
|
+
transport.sendTo(playerId, sessionMsgType, (0, session_wire_js_1.encodeDeterministicSessionMessage)({
|
|
131
|
+
kind: 'session-start',
|
|
132
|
+
slot,
|
|
133
|
+
playerCount: config.playerCount,
|
|
134
|
+
seed: config.seed,
|
|
135
|
+
tickRateHz: config.tickRateHz,
|
|
136
|
+
hardToleranceTicks: config.hardToleranceTicks,
|
|
137
|
+
role,
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Unicast the full confirmed-input history (tick 0 .. confirmedThrough) so a
|
|
142
|
+
* late joiner / rejoiner can fast-forward its sim deterministically. Rides
|
|
143
|
+
* the existing `confirmed-input` wire kind, chunked — clients apply frames
|
|
144
|
+
* contiguously and dedupe already-applied ticks, so this needs no protocol
|
|
145
|
+
* change and is idempotent for reconnects.
|
|
146
|
+
*/
|
|
147
|
+
function sendHistoryTo(playerId, fromTick) {
|
|
148
|
+
if (authority.confirmedThrough < 0)
|
|
149
|
+
return;
|
|
150
|
+
// History may be pruned below a validated snapshot: start at the earliest
|
|
151
|
+
// retained tick (a snapshot-hydrated joiner needs nothing below it). A
|
|
152
|
+
// snapshot-plan caller passes the snapshot tick explicitly — with
|
|
153
|
+
// preserveFullHistory the retained history reaches tick 0 but the joiner
|
|
154
|
+
// still needs only the post-snapshot tail (M2: keeps catch-up O(tail),
|
|
155
|
+
// never O(match length)).
|
|
156
|
+
const start = Math.max(0, authority.earliestRetainedTick, fromTick ?? 0);
|
|
157
|
+
for (let from = start; from <= authority.confirmedThrough; from += HISTORY_CHUNK_FRAMES) {
|
|
158
|
+
const to = Math.min(from + HISTORY_CHUNK_FRAMES - 1, authority.confirmedThrough);
|
|
159
|
+
const frames = [];
|
|
160
|
+
for (let tick = from; tick <= to; tick += 1) {
|
|
161
|
+
const frame = authority.confirmedFrame(tick);
|
|
162
|
+
if (frame !== undefined)
|
|
163
|
+
frames.push(frame);
|
|
164
|
+
}
|
|
165
|
+
if (frames.length === 0)
|
|
166
|
+
continue;
|
|
167
|
+
transport.sendTo(playerId, sessionMsgType, (0, session_wire_js_1.encodeDeterministicSessionMessage)({ kind: 'confirmed-input', authorityTick: now, frames }));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** True when tick 0 is still replayable (a joiner can reconstruct from the start). */
|
|
171
|
+
function historyReachesTickZero() {
|
|
172
|
+
return authority.confirmedThrough < 0 || authority.confirmedFrame(0) !== undefined;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* How a joiner/rejoiner can reconstruct state. Snapshot wins when one is
|
|
176
|
+
* stored AND the input tail after it is contiguous (a stored snapshot whose
|
|
177
|
+
* tail was ring-pruned must reject rather than seat a joiner that would
|
|
178
|
+
* stall on the gap forever — fail loud).
|
|
179
|
+
*/
|
|
180
|
+
function catchUpPlan() {
|
|
181
|
+
if (storedSnapshot !== undefined && authority.earliestRetainedTick <= storedSnapshot.tick + 1) {
|
|
182
|
+
return 'snapshot';
|
|
183
|
+
}
|
|
184
|
+
if (historyReachesTickZero()) {
|
|
185
|
+
return 'history';
|
|
186
|
+
}
|
|
187
|
+
return 'unavailable';
|
|
188
|
+
}
|
|
189
|
+
function sendCatchUpTo(playerId, plan, kind) {
|
|
190
|
+
if (plan === 'snapshot') {
|
|
191
|
+
sendStoredSnapshotTo(playerId);
|
|
192
|
+
}
|
|
193
|
+
sendHistoryTo(playerId, plan === 'snapshot' ? storedSnapshot?.tick : undefined);
|
|
194
|
+
const tailStart = Math.max(0, authority.earliestRetainedTick, plan === 'snapshot' ? (storedSnapshot?.tick ?? 0) : 0);
|
|
195
|
+
config.onCatchUpEvent?.({
|
|
196
|
+
playerId,
|
|
197
|
+
kind,
|
|
198
|
+
plan,
|
|
199
|
+
tailFrames: authority.confirmedThrough < 0 ? 0 : authority.confirmedThrough - tailStart + 1,
|
|
200
|
+
...(storedSnapshot === undefined ? {} : { snapshotTick: storedSnapshot.tick }),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
function sendStoredSnapshotTo(playerId) {
|
|
204
|
+
if (storedSnapshot === undefined)
|
|
205
|
+
return;
|
|
206
|
+
forwardCounter += 1;
|
|
207
|
+
const transfer = (0, session_snapshot_js_1.createSnapshotTransferFromBytes)(storedSnapshot.snapshotBytes, {
|
|
208
|
+
identity: storedSnapshot.identity,
|
|
209
|
+
frame: storedSnapshot.tick + 1,
|
|
210
|
+
transferId: `snap:${storedSnapshot.tick}:${forwardCounter}`,
|
|
211
|
+
chunkSize: session_snapshot_js_1.DEFAULT_SNAPSHOT_CHUNK_SIZE,
|
|
212
|
+
});
|
|
213
|
+
transport.sendTo(playerId, sessionMsgType, (0, local_authority_wire_js_1.createDeterministicLocalAuthoritySnapshotOfferEnvelope)(nextWireSequence(), transfer));
|
|
214
|
+
for (let index = 0; index < transfer.chunks.length; index += 1) {
|
|
215
|
+
transport.sendTo(playerId, sessionMsgType, (0, local_authority_wire_js_1.createDeterministicLocalAuthoritySnapshotChunkEnvelope)(nextWireSequence(), transfer, index));
|
|
216
|
+
}
|
|
217
|
+
transport.sendTo(playerId, sessionMsgType, (0, local_authority_wire_js_1.createDeterministicLocalAuthoritySnapshotCompleteEnvelope)(nextWireSequence(), transfer.transferId));
|
|
218
|
+
}
|
|
219
|
+
function nextWireSequence() {
|
|
220
|
+
wireSequence += 1;
|
|
221
|
+
return wireSequence;
|
|
222
|
+
}
|
|
223
|
+
function confirmedTail() {
|
|
224
|
+
if (authority.confirmedThrough < 0)
|
|
225
|
+
return [];
|
|
226
|
+
return authority.confirmedFrameRange(Math.max(0, authority.earliestRetainedTick), authority.confirmedThrough);
|
|
227
|
+
}
|
|
228
|
+
function resyncSeatedPlayers() {
|
|
229
|
+
for (const [playerId, slot] of slotByPlayer) {
|
|
230
|
+
const plan = catchUpPlan();
|
|
231
|
+
(0, errors_js_1.invariant)(plan !== 'unavailable', 'a restored room must have a catch-up plan for seated players', 'authority-room.restore-catchup');
|
|
232
|
+
sendSessionStart(playerId, slot);
|
|
233
|
+
sendCatchUpTo(playerId, plan, 'resync');
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function onPlayerJoin(playerId) {
|
|
237
|
+
const existing = slotByPlayer.get(playerId);
|
|
238
|
+
if (existing !== undefined) {
|
|
239
|
+
return { accepted: false, reason: 'already-joined' };
|
|
240
|
+
}
|
|
241
|
+
// Rejoin: a previously-seated playerId reclaims its original slot and
|
|
242
|
+
// catches up from history. Sits BEFORE the capacity check — their seat was
|
|
243
|
+
// never given away (slots are never recycled).
|
|
244
|
+
const formerSlot = everSlotByPlayer.get(playerId);
|
|
245
|
+
if (formerSlot !== undefined) {
|
|
246
|
+
const rejoinPlan = catchUpPlan();
|
|
247
|
+
if (rejoinPlan === 'unavailable') {
|
|
248
|
+
return { accepted: false, reason: 'history-unavailable' };
|
|
249
|
+
}
|
|
250
|
+
slotByPlayer.set(playerId, formerSlot);
|
|
251
|
+
sendSessionStart(playerId, formerSlot);
|
|
252
|
+
sendCatchUpTo(playerId, rejoinPlan, 'rejoin');
|
|
253
|
+
return { accepted: true, slot: formerSlot, rejoined: true };
|
|
254
|
+
}
|
|
255
|
+
if (assignedSlots >= config.playerCount) {
|
|
256
|
+
return { accepted: false, reason: 'room-full' };
|
|
257
|
+
}
|
|
258
|
+
// Late joiner (clock already running): seat them only when state is still
|
|
259
|
+
// reconstructible — from a validated snapshot + input tail (M5.5), or from
|
|
260
|
+
// the full history when tick 0 is retained. Never desync silently.
|
|
261
|
+
const plan = catchUpPlan();
|
|
262
|
+
if (plan === 'unavailable') {
|
|
263
|
+
return { accepted: false, reason: 'history-unavailable' };
|
|
264
|
+
}
|
|
265
|
+
const slot = assignedSlots;
|
|
266
|
+
assignedSlots += 1;
|
|
267
|
+
slotByPlayer.set(playerId, slot);
|
|
268
|
+
everSlotByPlayer.set(playerId, slot);
|
|
269
|
+
sendSessionStart(playerId, slot);
|
|
270
|
+
sendCatchUpTo(playerId, plan, 'join');
|
|
271
|
+
return { accepted: true, slot };
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Seat a connection as a SPECTATOR (G11): no slot, no input authority — a
|
|
275
|
+
* read-only session that hydrates like a late joiner (session-start with
|
|
276
|
+
* role 'spectator' + snapshot/history catch-up) and follows the live
|
|
277
|
+
* confirmed-input broadcast. Idempotent for an already-seated spectator
|
|
278
|
+
* (re-greet + refill). Returns false when the player holds a real slot or
|
|
279
|
+
* no catch-up plan exists.
|
|
280
|
+
*/
|
|
281
|
+
function onSpectatorJoin(playerId) {
|
|
282
|
+
if (slotByPlayer.has(playerId))
|
|
283
|
+
return false;
|
|
284
|
+
const plan = catchUpPlan();
|
|
285
|
+
if (plan === 'unavailable')
|
|
286
|
+
return false;
|
|
287
|
+
spectators.add(playerId);
|
|
288
|
+
sendSessionStart(playerId, -1, 'spectator');
|
|
289
|
+
sendCatchUpTo(playerId, plan, 'join');
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
function onPlayerReconnected(playerId) {
|
|
293
|
+
// A spectator reconnect is a full re-greet: its timeline may have forked
|
|
294
|
+
// arbitrarily far and it holds no slot the redundancy window protects.
|
|
295
|
+
if (spectators.has(playerId)) {
|
|
296
|
+
onSpectatorJoin(playerId);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const slot = slotByPlayer.get(playerId);
|
|
300
|
+
if (slot === undefined)
|
|
301
|
+
return;
|
|
302
|
+
// Post-restore, a reattaching client's timeline forked past the restored
|
|
303
|
+
// frontier — re-greet it so the client takes its full-reset + re-hydrate
|
|
304
|
+
// path rather than stalling on ticks that will re-confirm differently.
|
|
305
|
+
if (restoredRoom) {
|
|
306
|
+
sendSessionStart(playerId, slot);
|
|
307
|
+
}
|
|
308
|
+
// Socket swapped mid-match: refill anything missed while offline. The
|
|
309
|
+
// redundancy window only covers ~redundancyWindowTicks of outage, so a
|
|
310
|
+
// longer blip needs the history path; dedupe on the client makes the
|
|
311
|
+
// overlap free. In a pruned room the refill alone can have a gap for a
|
|
312
|
+
// client that fell behind the snapshot tick, so the stored snapshot rides
|
|
313
|
+
// along — the client applies it only when it is ahead of its own state.
|
|
314
|
+
const reconnectPlan = !historyReachesTickZero() && storedSnapshot !== undefined ? 'snapshot' : 'history';
|
|
315
|
+
sendCatchUpTo(playerId, reconnectPlan, 'reconnect');
|
|
316
|
+
}
|
|
317
|
+
function onPlayerMessage(playerId, envelope) {
|
|
318
|
+
// Fail closed: unknown/malformed messages are dropped. Snapshot transfer
|
|
319
|
+
// envelopes are the OTHER frozen wire family (schemaVersion vs v field) —
|
|
320
|
+
// the session decoder rejects them, and they get a second, equally
|
|
321
|
+
// fail-closed decode below.
|
|
322
|
+
const decoded = (0, session_wire_js_1.decodeDeterministicSessionMessage)(envelope);
|
|
323
|
+
if (!decoded.ok) {
|
|
324
|
+
handleSnapshotWireMessage(playerId, envelope);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const message = decoded.message;
|
|
328
|
+
// Clock sync (G1/G2): echo the authority tick so clients can estimate
|
|
329
|
+
// drift + RTT and pace themselves. Answered for any connected transport
|
|
330
|
+
// (no slot needed) — the reply carries nothing a spectator couldn't infer
|
|
331
|
+
// from confirmed-input broadcasts anyway.
|
|
332
|
+
if (message.kind === 'time-sync-ping') {
|
|
333
|
+
transport.sendTo(playerId, sessionMsgType, (0, session_wire_js_1.encodeDeterministicSessionMessage)({
|
|
334
|
+
kind: 'time-sync-pong',
|
|
335
|
+
clientSendTick: message.clientSendTick,
|
|
336
|
+
nonce: message.nonce,
|
|
337
|
+
authorityTick: now,
|
|
338
|
+
}));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (message.kind !== 'input' && message.kind !== 'checksum-report' && message.kind !== 'net-stats') {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const slot = slotByPlayer.get(playerId);
|
|
345
|
+
// Server owns the slot mapping — a client cannot act for a slot it was not assigned.
|
|
346
|
+
if (slot === undefined || message.slot !== slot) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (message.kind === 'input') {
|
|
350
|
+
authority.receiveInput(slot, message.tick, message.input);
|
|
351
|
+
}
|
|
352
|
+
else if (message.kind === 'checksum-report') {
|
|
353
|
+
recordChecksumReport(slot, message.tick, message.checksum);
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
clientNetStatsBySlot.set(slot, {
|
|
357
|
+
tick: message.tick,
|
|
358
|
+
rttMs: message.rttMs,
|
|
359
|
+
rollbacks: message.rollbacks,
|
|
360
|
+
maxRollbackDepth: message.maxRollbackDepth,
|
|
361
|
+
timescalePermille: message.timescalePermille,
|
|
362
|
+
inputDelayTicks: message.inputDelayTicks,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
function recordChecksumReport(slot, tick, checksum) {
|
|
367
|
+
let reports = checksumReports.get(tick);
|
|
368
|
+
if (reports === undefined) {
|
|
369
|
+
reports = new Map();
|
|
370
|
+
checksumReports.set(tick, reports);
|
|
371
|
+
if (checksumReports.size > desyncWindow) {
|
|
372
|
+
// Evict the OLDEST stored report. Clients sample checksums sparsely, so
|
|
373
|
+
// the keyed form `delete(tick - desyncWindow)` usually names a tick that
|
|
374
|
+
// was never stored and the map would grow for the whole match. Insertion
|
|
375
|
+
// order ≈ tick order, so the first key is the oldest.
|
|
376
|
+
const oldestTick = checksumReports.keys().next().value;
|
|
377
|
+
if (oldestTick !== undefined) {
|
|
378
|
+
checksumReports.delete(oldestTick);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// Detection only: a differing checksum for the same tick means a client has desynced itself.
|
|
383
|
+
if (config.onDesync !== undefined) {
|
|
384
|
+
for (const [otherSlot, otherChecksum] of reports) {
|
|
385
|
+
if (otherSlot !== slot && otherChecksum !== checksum) {
|
|
386
|
+
config.onDesync({
|
|
387
|
+
tick,
|
|
388
|
+
slot,
|
|
389
|
+
checksum,
|
|
390
|
+
conflictingSlot: otherSlot,
|
|
391
|
+
conflictingChecksum: otherChecksum,
|
|
392
|
+
reportsAtTick: { ...Object.fromEntries(reports), [slot]: checksum },
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
reports.set(slot, checksum);
|
|
398
|
+
reportsDirty = true;
|
|
399
|
+
}
|
|
400
|
+
// ── M5.5 snapshot acquisition ──
|
|
401
|
+
function handleSnapshotWireMessage(playerId, envelope) {
|
|
402
|
+
let parsed;
|
|
403
|
+
try {
|
|
404
|
+
parsed = (0, local_authority_wire_js_1.decodeDeterministicLocalAuthorityWireEnvelope)(envelope);
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (parsed.kind !== 'snapshot-offer' && parsed.kind !== 'snapshot-chunk' && parsed.kind !== 'snapshot-complete') {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
// Only the designated donor may feed the pending transfer.
|
|
413
|
+
if (pendingTransfer === undefined || pendingTransfer.donorId !== playerId) {
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (parsed.kind === 'snapshot-offer') {
|
|
417
|
+
const result = pendingTransfer.collector.offer(parsed.payload);
|
|
418
|
+
if (!result.accepted)
|
|
419
|
+
failPendingTransfer(result.reason);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (parsed.kind === 'snapshot-chunk') {
|
|
423
|
+
const result = pendingTransfer.collector.chunk(parsed.payload);
|
|
424
|
+
if (!result.accepted)
|
|
425
|
+
failPendingTransfer(result.reason);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const completed = pendingTransfer.collector.complete(parsed.payload);
|
|
429
|
+
if (!completed.accepted) {
|
|
430
|
+
failPendingTransfer(completed.reason);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
validateAndStoreSnapshot(completed.transfer);
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* The trust gate (§M5.5): a donated snapshot is stored only when its
|
|
437
|
+
* self-declared state checksum matches the checksum-report of EVERY
|
|
438
|
+
* currently-connected player at that tick. The authority never runs game
|
|
439
|
+
* code — this cross-client quorum is what makes an opaque donated blob safe
|
|
440
|
+
* to hand to joiners (the joiner re-verifies after hydrating, too).
|
|
441
|
+
*/
|
|
442
|
+
function validateAndStoreSnapshot(collected) {
|
|
443
|
+
if (pendingTransfer === undefined)
|
|
444
|
+
return;
|
|
445
|
+
const donorId = pendingTransfer.donorId;
|
|
446
|
+
const decoded = (0, session_snapshot_js_1.decodeDeterministicStateSnapshot)(collected.snapshotBytes);
|
|
447
|
+
if (!decoded.ok) {
|
|
448
|
+
failPendingTransfer(`snapshot-payload-${decoded.reason}`);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const snapshot = decoded.snapshot;
|
|
452
|
+
if (snapshot.sessionFrame !== collected.frame) {
|
|
453
|
+
failPendingTransfer('snapshot-frame-mismatch');
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (snapshot.tick < pendingTransfer.minFrame - 1) {
|
|
457
|
+
failPendingTransfer('snapshot-below-min-frame');
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (snapshot.tick > authority.confirmedThrough) {
|
|
461
|
+
failPendingTransfer('snapshot-beyond-frontier');
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (collected.identity.descriptorHash !== roomDescriptorHash) {
|
|
465
|
+
failPendingTransfer('snapshot-descriptor-mismatch');
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const reports = checksumReports.get(snapshot.tick);
|
|
469
|
+
for (const slot of slotByPlayer.values()) {
|
|
470
|
+
const reported = reports?.get(slot);
|
|
471
|
+
if (reported === undefined) {
|
|
472
|
+
failPendingTransfer('snapshot-unvalidatable');
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (reported !== snapshot.stateChecksum) {
|
|
476
|
+
failPendingTransfer('snapshot-quorum-mismatch');
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
storedSnapshot = {
|
|
481
|
+
tick: snapshot.tick,
|
|
482
|
+
snapshotChecksum: collected.snapshotChecksum,
|
|
483
|
+
snapshotBytes: collected.snapshotBytes,
|
|
484
|
+
identity: collected.identity,
|
|
485
|
+
};
|
|
486
|
+
pendingTransfer = undefined;
|
|
487
|
+
// History below the snapshot tick is no longer needed by anyone: joiners
|
|
488
|
+
// hydrate at the snapshot and replay the tail. Keeping the tick-N frame
|
|
489
|
+
// itself guarantees the persisted tail is never empty (a snapshot can sit
|
|
490
|
+
// exactly at the frontier) — restore always has frames to rebuild from.
|
|
491
|
+
// With preserveFullHistory (G3 match logs) the ring buffer keeps tick 0;
|
|
492
|
+
// memory stays bounded by historyRetentionTicks.
|
|
493
|
+
if (config.preserveFullHistory !== true) {
|
|
494
|
+
authority.pruneConfirmedBefore(snapshot.tick);
|
|
495
|
+
}
|
|
496
|
+
nextCadenceTarget = (Math.floor(snapshot.tick / snapshotCadenceTicks) + 1) * snapshotCadenceTicks;
|
|
497
|
+
config.onSnapshotEvent?.({ kind: 'stored', donorId, tick: snapshot.tick });
|
|
498
|
+
}
|
|
499
|
+
function failPendingTransfer(reason) {
|
|
500
|
+
if (pendingTransfer === undefined)
|
|
501
|
+
return;
|
|
502
|
+
const failed = pendingTransfer;
|
|
503
|
+
pendingTransfer = undefined;
|
|
504
|
+
config.onSnapshotEvent?.({ kind: 'rejected', donorId: failed.donorId, reason });
|
|
505
|
+
// One immediate retry with the next donor; further failures wait for the
|
|
506
|
+
// next report-driven attempt so a broken room cannot request-storm.
|
|
507
|
+
if (!failed.isRetry) {
|
|
508
|
+
beginSnapshotAcquisition(true);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
function beginSnapshotAcquisition(isRetry) {
|
|
512
|
+
const tick = pickSnapshotTick();
|
|
513
|
+
if (tick === undefined)
|
|
514
|
+
return;
|
|
515
|
+
const donorId = pickDonor();
|
|
516
|
+
if (donorId === undefined)
|
|
517
|
+
return;
|
|
518
|
+
pendingTransfer = {
|
|
519
|
+
donorId,
|
|
520
|
+
minFrame: tick + 1,
|
|
521
|
+
startedAt: now,
|
|
522
|
+
collector: (0, session_snapshot_js_1.createSnapshotTransferCollector)(),
|
|
523
|
+
isRetry,
|
|
524
|
+
};
|
|
525
|
+
transport.sendTo(donorId, sessionMsgType, (0, local_authority_wire_js_1.createDeterministicLocalAuthoritySnapshotRequestEnvelope)(nextWireSequence(), { descriptorHash: roomDescriptorHash }, tick + 1));
|
|
526
|
+
}
|
|
527
|
+
/** Greatest confirmed tick past the stored snapshot that EVERY connected player has reported. */
|
|
528
|
+
function pickSnapshotTick() {
|
|
529
|
+
if (slotByPlayer.size === 0)
|
|
530
|
+
return undefined;
|
|
531
|
+
const floor = storedSnapshot?.tick ?? -1;
|
|
532
|
+
let best;
|
|
533
|
+
for (const [tick, reports] of checksumReports) {
|
|
534
|
+
if (tick <= floor || tick > authority.confirmedThrough)
|
|
535
|
+
continue;
|
|
536
|
+
if (best !== undefined && tick <= best)
|
|
537
|
+
continue;
|
|
538
|
+
let fullyReported = true;
|
|
539
|
+
for (const slot of slotByPlayer.values()) {
|
|
540
|
+
if (!reports.has(slot)) {
|
|
541
|
+
fullyReported = false;
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (fullyReported)
|
|
546
|
+
best = tick;
|
|
547
|
+
}
|
|
548
|
+
return best;
|
|
549
|
+
}
|
|
550
|
+
function pickDonor() {
|
|
551
|
+
const donors = [...slotByPlayer.keys()];
|
|
552
|
+
if (donors.length === 0)
|
|
553
|
+
return undefined;
|
|
554
|
+
const donorId = donors[donorCursor % donors.length];
|
|
555
|
+
donorCursor += 1;
|
|
556
|
+
return donorId;
|
|
557
|
+
}
|
|
558
|
+
function maintainSnapshotAcquisition() {
|
|
559
|
+
if (pendingTransfer !== undefined) {
|
|
560
|
+
if (now - pendingTransfer.startedAt > transferTimeoutTicks) {
|
|
561
|
+
failPendingTransfer('snapshot-transfer-timeout');
|
|
562
|
+
}
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (authority.confirmedThrough < nextCadenceTarget)
|
|
566
|
+
return;
|
|
567
|
+
if (!reportsDirty)
|
|
568
|
+
return;
|
|
569
|
+
reportsDirty = false;
|
|
570
|
+
beginSnapshotAcquisition(false);
|
|
571
|
+
}
|
|
572
|
+
function onPlayerLeave(playerId) {
|
|
573
|
+
spectators.delete(playerId);
|
|
574
|
+
// Fixed roster for v1 (late-join/reconnect is M5). Dropping the mapping stops accepting the
|
|
575
|
+
// departed player's input; their slot stays reserved because `assignedSlots` never decreases,
|
|
576
|
+
// so the authority substitutes for it rather than handing the number to a new joiner.
|
|
577
|
+
slotByPlayer.delete(playerId);
|
|
578
|
+
if (pendingTransfer?.donorId === playerId) {
|
|
579
|
+
failPendingTransfer('snapshot-donor-left');
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
function onTick() {
|
|
583
|
+
// Opt-in lobby semantics: hold the authority clock at -1 until EVERY slot
|
|
584
|
+
// has joined, so all clients start from tick 0 together. The DEFAULT is
|
|
585
|
+
// drop-in: the clock runs from the first join and later players catch up
|
|
586
|
+
// via the history replay in onPlayerJoin. `assignedSlots` is monotonic, so
|
|
587
|
+
// a mid-match leave never re-pauses a started room.
|
|
588
|
+
if (config.waitForFullRoom === true && assignedSlots < config.playerCount) {
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
now += 1;
|
|
592
|
+
authority.advance(now);
|
|
593
|
+
maintainSnapshotAcquisition();
|
|
594
|
+
if (authority.confirmedThrough < 0) {
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
// Count substitutions once per newly-confirmed tick (the broadcast below
|
|
598
|
+
// re-sends a redundancy window, so it cannot be the counting site).
|
|
599
|
+
for (let tick = substitutionCountedThrough + 1; tick <= authority.confirmedThrough; tick += 1) {
|
|
600
|
+
const frame = authority.confirmedFrame(tick);
|
|
601
|
+
if (frame === undefined)
|
|
602
|
+
continue;
|
|
603
|
+
for (const slot of frame.substitutedSlots) {
|
|
604
|
+
substitutedTicksBySlot.set(slot, (substitutedTicksBySlot.get(slot) ?? 0) + 1);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
substitutionCountedThrough = authority.confirmedThrough;
|
|
608
|
+
// The window alone is NOT enough: a burst of late-arriving inputs can
|
|
609
|
+
// confirm a jump of more than redundancyWindowTicks in a single tick, and
|
|
610
|
+
// a tick that misses its window is never sent again — every client stalls
|
|
611
|
+
// forever on the contiguity gap (the live-network freeze). Anchor the
|
|
612
|
+
// window start at the first never-broadcast tick so no confirmed tick can
|
|
613
|
+
// ever be skipped; the redundancy re-send behavior is unchanged.
|
|
614
|
+
const from = Math.max(0, Math.min(authority.confirmedThrough - config.redundancyWindowTicks + 1, broadcastThrough + 1));
|
|
615
|
+
// Catch-up bursts ride the same per-message frame bound as the history
|
|
616
|
+
// unicast path — an anchored burst must never produce an oversized frame
|
|
617
|
+
// for the host transport.
|
|
618
|
+
for (let chunkFrom = from; chunkFrom <= authority.confirmedThrough; chunkFrom += HISTORY_CHUNK_FRAMES) {
|
|
619
|
+
const chunkTo = Math.min(chunkFrom + HISTORY_CHUNK_FRAMES - 1, authority.confirmedThrough);
|
|
620
|
+
const frames = [];
|
|
621
|
+
for (let tick = chunkFrom; tick <= chunkTo; tick += 1) {
|
|
622
|
+
const frame = authority.confirmedFrame(tick);
|
|
623
|
+
if (frame !== undefined) {
|
|
624
|
+
frames.push(frame);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (frames.length === 0) {
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
transport.broadcast(sessionMsgType, (0, session_wire_js_1.encodeDeterministicSessionMessage)({ kind: 'confirmed-input', authorityTick: now, frames }));
|
|
631
|
+
}
|
|
632
|
+
broadcastThrough = authority.confirmedThrough;
|
|
633
|
+
}
|
|
634
|
+
return {
|
|
635
|
+
get confirmedThrough() {
|
|
636
|
+
return authority.confirmedThrough;
|
|
637
|
+
},
|
|
638
|
+
playerCount: config.playerCount,
|
|
639
|
+
onPlayerJoin,
|
|
640
|
+
onSpectatorJoin,
|
|
641
|
+
onPlayerMessage,
|
|
642
|
+
onPlayerLeave,
|
|
643
|
+
onPlayerReconnected,
|
|
644
|
+
onTick,
|
|
645
|
+
snapshot() {
|
|
646
|
+
return {
|
|
647
|
+
confirmedThrough: authority.confirmedThrough,
|
|
648
|
+
now,
|
|
649
|
+
slots: Object.fromEntries(slotByPlayer),
|
|
650
|
+
everSlots: Object.fromEntries(everSlotByPlayer),
|
|
651
|
+
earliestRetainedTick: authority.earliestRetainedTick,
|
|
652
|
+
...(storedSnapshot === undefined
|
|
653
|
+
? {}
|
|
654
|
+
: {
|
|
655
|
+
latestSnapshot: {
|
|
656
|
+
tick: storedSnapshot.tick,
|
|
657
|
+
snapshotChecksum: storedSnapshot.snapshotChecksum,
|
|
658
|
+
descriptorHash: storedSnapshot.identity.descriptorHash,
|
|
659
|
+
...(storedSnapshot.identity.schemaHash === undefined ? {} : { schemaHash: storedSnapshot.identity.schemaHash }),
|
|
660
|
+
byteLength: storedSnapshot.snapshotBytes.length,
|
|
661
|
+
},
|
|
662
|
+
}),
|
|
663
|
+
};
|
|
664
|
+
},
|
|
665
|
+
latestSnapshotBytes() {
|
|
666
|
+
return storedSnapshot?.snapshotBytes;
|
|
667
|
+
},
|
|
668
|
+
confirmedTail,
|
|
669
|
+
resyncSeatedPlayers,
|
|
670
|
+
resyncPlayer(playerId) {
|
|
671
|
+
const slot = slotByPlayer.get(playerId);
|
|
672
|
+
if (slot === undefined)
|
|
673
|
+
return false;
|
|
674
|
+
const plan = catchUpPlan();
|
|
675
|
+
if (plan === 'unavailable')
|
|
676
|
+
return false;
|
|
677
|
+
sendSessionStart(playerId, slot);
|
|
678
|
+
sendCatchUpTo(playerId, plan, 'resync');
|
|
679
|
+
return true;
|
|
680
|
+
},
|
|
681
|
+
netcodeStats() {
|
|
682
|
+
return {
|
|
683
|
+
confirmedThrough: authority.confirmedThrough,
|
|
684
|
+
substitutedTicksBySlot: Object.fromEntries(substitutedTicksBySlot),
|
|
685
|
+
clientStats: Object.fromEntries(clientNetStatsBySlot),
|
|
686
|
+
};
|
|
687
|
+
},
|
|
688
|
+
matchLog() {
|
|
689
|
+
if (authority.confirmedThrough < 0 || !historyReachesTickZero()) {
|
|
690
|
+
return undefined;
|
|
691
|
+
}
|
|
692
|
+
return {
|
|
693
|
+
v: 1,
|
|
694
|
+
seed: config.seed,
|
|
695
|
+
tickRateHz: config.tickRateHz,
|
|
696
|
+
playerCount: config.playerCount,
|
|
697
|
+
hardToleranceTicks: config.hardToleranceTicks,
|
|
698
|
+
confirmedThrough: authority.confirmedThrough,
|
|
699
|
+
everSlots: Object.fromEntries(everSlotByPlayer),
|
|
700
|
+
frames: authority.confirmedFrameRange(0, authority.confirmedThrough),
|
|
701
|
+
};
|
|
702
|
+
},
|
|
703
|
+
};
|
|
704
|
+
}
|