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