@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,688 @@
1
+ import { canonicalStringify, cloneCanonical, defaultChecksum } from './canonical.js';
2
+ import { SyncplayError } from './errors.js';
3
+ import { createDeterministicLocalAuthoritySnapshotChunkEnvelope, createDeterministicLocalAuthoritySnapshotCompleteEnvelope, createDeterministicLocalAuthoritySnapshotOfferEnvelope, decodeDeterministicLocalAuthorityWireEnvelope, } from './local-authority-wire.js';
4
+ import { base64ToBytes, bytesToBase64, createSnapshotTransferCollector, createSnapshotTransferFromBytes, decodeDeterministicStateSnapshot, deterministicRoomDescriptorHash, encodeDeterministicStateSnapshot, } from './session-snapshot.js';
5
+ import { decodeDeterministicSessionMessage, encodeDeterministicSessionMessage } from './session-wire.js';
6
+ const DEFAULT_MSG_TYPE = 'rdm';
7
+ const DEFAULT_MAX_PREDICTION = 8;
8
+ const DEFAULT_CHECKSUM_CADENCE_TICKS = 120;
9
+ const DEFAULT_PING_INTERVAL_MS = 1000;
10
+ const DEFAULT_TARGET_LEAD_TICKS = 2;
11
+ const DEFAULT_MIN_TIMESCALE = 0.9;
12
+ const DEFAULT_MAX_TIMESCALE = 1.1;
13
+ const DEFAULT_CORRECTION_PER_TICK = 0.02;
14
+ const DEFAULT_MAX_STEPS_PER_PUMP = 4;
15
+ /** Frame-advantage error inside this band leaves the timescale at 1 (no hunting on jitter). */
16
+ const TIMESCALE_DEADBAND_TICKS = 1;
17
+ /** RTT smoothing factor (EWMA); ~4 samples to converge. */
18
+ const RTT_EWMA_ALPHA = 0.25;
19
+ /** Consecutive agreeing samples before an adaptive input-delay change applies. */
20
+ const INPUT_DELAY_HYSTERESIS_SAMPLES = 3;
21
+ /**
22
+ * Authority-clock observations retained for the estimate. Confirmed-input
23
+ * broadcasts feed one per authority tick, so a transient over-estimate
24
+ * (RTT spike, delivery burst) ages out in well under a second.
25
+ */
26
+ const AUTHORITY_EPOCH_WINDOW = 16;
27
+ /** Reported ticks kept for donor eligibility (a donor only offers ticks it reported). */
28
+ const REPORTED_TICKS_RETAINED = 64;
29
+ /** Donor upload pacing: transfer envelopes flushed per pump() call. */
30
+ const TRANSFER_SENDS_PER_PUMP = 2;
31
+ export function createNetworkedSyncplayClient(options) {
32
+ const sessionMsgType = options.sessionMsgType ?? DEFAULT_MSG_TYPE;
33
+ const maxPredictionTicks = options.maxPredictionTicks ?? DEFAULT_MAX_PREDICTION;
34
+ const checksumCadenceTicks = options.checksumCadenceTicks ?? DEFAULT_CHECKSUM_CADENCE_TICKS;
35
+ const pingIntervalMs = options.pacing?.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS;
36
+ const targetLeadTicks = options.pacing?.targetLeadTicks ?? DEFAULT_TARGET_LEAD_TICKS;
37
+ const minTimescale = options.pacing?.minTimescale ?? DEFAULT_MIN_TIMESCALE;
38
+ const maxTimescale = options.pacing?.maxTimescale ?? DEFAULT_MAX_TIMESCALE;
39
+ const correctionPerTick = options.pacing?.correctionPerTick ?? DEFAULT_CORRECTION_PER_TICK;
40
+ const maxStepsPerPump = options.pacing?.maxStepsPerPump ?? DEFAULT_MAX_STEPS_PER_PUMP;
41
+ const inputDelayMinTicks = options.inputDelay?.minTicks ?? 0;
42
+ const inputDelayMaxTicks = Math.max(options.inputDelay?.maxTicks ?? inputDelayMinTicks, inputDelayMinTicks);
43
+ const wallClock = options.now ?? (() => Date.now());
44
+ let session;
45
+ let slot = -1;
46
+ let role = 'player';
47
+ let seed = 0;
48
+ let playerCount = 0;
49
+ let predictedThrough = 0;
50
+ let appliedThrough = -1;
51
+ let rollbacks = 0;
52
+ let maxRollbackDepth = 0;
53
+ // ── G1/G2 pacing state (only advanced by pumpPaced; inert under raw pump) ──
54
+ let tickRateHz = 0;
55
+ let frameMs = 0;
56
+ let pingNonce = 0;
57
+ const pendingPings = new Map();
58
+ let lastPingSentMs;
59
+ let rttMs = -1;
60
+ // Windowed authority-clock observations, stored as epochs (the wall time at
61
+ // which authority tick 0 would have happened, assuming nominal rate). Using
62
+ // the window MIN epoch (= max tick estimate) keeps jitter robustness, while
63
+ // the bounded window lets the estimate RE-BASE DOWN: a slow-ticking server
64
+ // or a one-off RTT spike ages out of the window instead of inflating the
65
+ // estimate forever (which pinned clients at the prediction cap).
66
+ const authorityEpochsMs = [];
67
+ let timescale = 1;
68
+ let pacingAccumulatorMs = 0;
69
+ let lastPumpPacedMs;
70
+ // Own-input scheduling (G2): the next tick to sample+send our input for.
71
+ // With delay D, predicting tick T schedules through T+D; the cursor is
72
+ // monotone, so a mid-session delay change can never gap or rebind a tick
73
+ // that was already sent.
74
+ let inputDelayTicks = inputDelayMinTicks;
75
+ let ownScheduleCursor = 0;
76
+ let pendingDelayCandidate = inputDelayMinTicks;
77
+ let pendingDelayAgreement = 0;
78
+ const timeline = [];
79
+ const ownInputs = new Map();
80
+ const confirmed = new Map();
81
+ let lastConfirmedBySlot = [];
82
+ // M5.5 snapshot late-join state: identities from session-start (descriptor)
83
+ // and the session config (schema), the donor upload queue, and the joiner's
84
+ // transfer collector.
85
+ let descriptorHash = '';
86
+ let schemaHash = '';
87
+ const reportedTicks = [];
88
+ let lastAutoReportedTick = -1;
89
+ let transferCollector = createSnapshotTransferCollector();
90
+ const outgoingTransferQueue = [];
91
+ let donorTransferCounter = 0;
92
+ let readyResolve;
93
+ const readyPromise = new Promise((resolve) => {
94
+ readyResolve = resolve;
95
+ });
96
+ options.transport.onMessage((text) => {
97
+ const decoded = decodeDeterministicSessionMessage(text);
98
+ if (!decoded.ok) {
99
+ // The other frozen wire family (snapshot negotiation) rides the same
100
+ // msgType; it carries schemaVersion where session messages carry v, so
101
+ // a session-decode failure is the expected path for it. Both decoders
102
+ // fail closed.
103
+ handleSnapshotWireMessage(text);
104
+ return;
105
+ }
106
+ const message = decoded.message;
107
+ if (message.kind === 'session-start') {
108
+ // A REPEAT session-start means the authority restarted (M6.5 crash
109
+ // recovery re-greets every seated player): this client's timeline forked
110
+ // past the restored frontier, so everything resets — the snapshot+tail
111
+ // that follow re-hydrate it exactly like a fresh late joiner.
112
+ slot = message.slot;
113
+ role = message.role;
114
+ seed = message.seed;
115
+ playerCount = message.playerCount;
116
+ lastConfirmedBySlot = Array.from({ length: playerCount }, () => null);
117
+ appliedThrough = -1;
118
+ predictedThrough = 0;
119
+ timeline.length = 0;
120
+ ownInputs.clear();
121
+ confirmed.clear();
122
+ reportedTicks.length = 0;
123
+ lastAutoReportedTick = -1;
124
+ transferCollector = createSnapshotTransferCollector();
125
+ outgoingTransferQueue.length = 0;
126
+ tickRateHz = message.tickRateHz;
127
+ frameMs = tickRateHz > 0 ? 1000 / tickRateHz : 0;
128
+ ownScheduleCursor = 0;
129
+ pacingAccumulatorMs = 0;
130
+ // A re-greet resets the sim; pacing estimates must not survive it either
131
+ // (fresh observations arrive with the very next confirmed-input).
132
+ authorityEpochsMs.length = 0;
133
+ pendingPings.clear();
134
+ timescale = 1;
135
+ session = options.createSession({ seed, playerCount });
136
+ descriptorHash = deterministicRoomDescriptorHash({
137
+ seed: message.seed,
138
+ playerCount: message.playerCount,
139
+ tickRateHz: message.tickRateHz,
140
+ hardToleranceTicks: message.hardToleranceTicks,
141
+ });
142
+ schemaHash = defaultChecksum({ kind: 'rdm-schema-identity', ...session.schemaIdentity });
143
+ readyResolve?.();
144
+ }
145
+ else if (message.kind === 'confirmed-input') {
146
+ observeAuthorityTick(message.authorityTick, wallClock());
147
+ applyConfirmedFrames(message.frames);
148
+ }
149
+ else if (message.kind === 'time-sync-pong') {
150
+ handleTimeSyncPong(message.nonce, message.authorityTick);
151
+ }
152
+ });
153
+ // ── G1: authority-clock estimation + drift-corrected timescale ──
154
+ function handleTimeSyncPong(nonce, authorityTick) {
155
+ const sentAtMs = pendingPings.get(nonce);
156
+ if (sentAtMs === undefined) {
157
+ return;
158
+ }
159
+ pendingPings.delete(nonce);
160
+ const nowMs = wallClock();
161
+ const sampleRttMs = Math.max(0, nowMs - sentAtMs);
162
+ rttMs = rttMs < 0 ? sampleRttMs : rttMs + RTT_EWMA_ALPHA * (sampleRttMs - rttMs);
163
+ // observeAuthorityTick adds the one-way delay itself — pass the raw tick.
164
+ observeAuthorityTick(authorityTick, nowMs);
165
+ maybeAdaptInputDelay();
166
+ }
167
+ function halfRttTicks() {
168
+ if (rttMs < 0 || frameMs <= 0) {
169
+ return 0;
170
+ }
171
+ return rttMs / 2 / frameMs;
172
+ }
173
+ /**
174
+ * Fold a new authority-tick observation into the windowed estimator.
175
+ * Confirmed-input broadcasts and pongs both feed it. Each observation is
176
+ * normalized to an epoch; the estimate extrapolates from the window-min
177
+ * epoch (jitter-robust max bias) while old observations age out, so the
178
+ * estimate tracks a server whose real tick period drifts from nominal and
179
+ * recovers from transient RTT inflation instead of overestimating forever.
180
+ */
181
+ function observeAuthorityTick(tick, atMs) {
182
+ if (frameMs <= 0) {
183
+ return;
184
+ }
185
+ const observed = tick + halfRttTicks();
186
+ authorityEpochsMs.push(atMs - observed * frameMs);
187
+ if (authorityEpochsMs.length > AUTHORITY_EPOCH_WINDOW) {
188
+ authorityEpochsMs.shift();
189
+ }
190
+ }
191
+ function estimatedAuthorityTick(nowMs) {
192
+ if (authorityEpochsMs.length === 0 || frameMs <= 0) {
193
+ return undefined;
194
+ }
195
+ return (nowMs - Math.min(...authorityEpochsMs)) / frameMs;
196
+ }
197
+ /**
198
+ * The pacing controller (G1). Input for tick T+delay is sent while predicting
199
+ * T and needs one-way latency to arrive, so the client wants to run ahead of
200
+ * the authority by ~(halfRTT - inputDelay + safety margin) ticks. Error
201
+ * outside a ±1-tick deadband nudges the timescale, clamped to ±10% —
202
+ * micro-sleep when ahead, step-faster when behind, never a visible snap.
203
+ */
204
+ function updateTimescale(nowMs) {
205
+ const authorityNow = estimatedAuthorityTick(nowMs);
206
+ if (authorityNow === undefined) {
207
+ timescale = 1;
208
+ return;
209
+ }
210
+ const frameAdvantage = predictedThrough - authorityNow;
211
+ const targetAdvantage = Math.max(targetLeadTicks, halfRttTicks() - inputDelayTicks + targetLeadTicks);
212
+ const error = frameAdvantage - targetAdvantage;
213
+ if (Math.abs(error) <= TIMESCALE_DEADBAND_TICKS) {
214
+ timescale = 1;
215
+ return;
216
+ }
217
+ timescale = clamp(1 - error * correctionPerTick, minTimescale, maxTimescale);
218
+ }
219
+ function maybeSendPing(nowMs) {
220
+ if (lastPingSentMs !== undefined && nowMs - lastPingSentMs < pingIntervalMs) {
221
+ return;
222
+ }
223
+ lastPingSentMs = nowMs;
224
+ pingNonce += 1;
225
+ pendingPings.set(pingNonce, nowMs);
226
+ // A lost pong must not leak: keep only the most recent few outstanding.
227
+ if (pendingPings.size > 8) {
228
+ const oldest = pendingPings.keys().next().value;
229
+ if (oldest !== undefined)
230
+ pendingPings.delete(oldest);
231
+ }
232
+ options.transport.send(encodeDeterministicSessionMessage({ kind: 'time-sync-ping', clientSendTick: predictedThrough, nonce: pingNonce }));
233
+ }
234
+ // ── G2: ping-adaptive own-input delay ──
235
+ function maybeAdaptInputDelay() {
236
+ if (inputDelayMaxTicks <= inputDelayMinTicks) {
237
+ return;
238
+ }
239
+ const desired = clamp(Math.ceil(halfRttTicks()), inputDelayMinTicks, inputDelayMaxTicks);
240
+ if (desired === inputDelayTicks) {
241
+ pendingDelayAgreement = 0;
242
+ return;
243
+ }
244
+ if (desired === pendingDelayCandidate) {
245
+ pendingDelayAgreement += 1;
246
+ }
247
+ else {
248
+ pendingDelayCandidate = desired;
249
+ pendingDelayAgreement = 1;
250
+ }
251
+ if (pendingDelayAgreement >= INPUT_DELAY_HYSTERESIS_SAMPLES) {
252
+ inputDelayTicks = pendingDelayCandidate;
253
+ pendingDelayAgreement = 0;
254
+ }
255
+ }
256
+ function clamp(value, min, max) {
257
+ return Math.min(max, Math.max(min, value));
258
+ }
259
+ /**
260
+ * Decode a wire input fail-closed (§2.4.5 hostile-input hardening): the
261
+ * authority is game-agnostic and relays opaque payloads, so a hostile peer
262
+ * can put anything into a CONFIRMED frame. A payload the game's decoder
263
+ * throws on becomes the canonical neutral instead of crashing every honest
264
+ * client — deterministically, since every client throws on the same bytes.
265
+ * If even the neutral decode throws, that is a game bug: fail loud.
266
+ */
267
+ function decodeInputSafe(encoded) {
268
+ try {
269
+ return options.decodeInput(encoded);
270
+ }
271
+ catch {
272
+ return options.decodeInput(null);
273
+ }
274
+ }
275
+ function inputForSlot(targetSlot, tick) {
276
+ const conf = confirmed.get(tick);
277
+ if (conf !== undefined) {
278
+ return conf[targetSlot];
279
+ }
280
+ if (targetSlot === slot) {
281
+ // Scheduled by the cursor (possibly delay-shifted). A tick with no
282
+ // schedule entry (e.g. the first `inputDelayTicks` ticks) plays the
283
+ // canonical neutral — the same substitute the authority would use.
284
+ return ownInputs.get(tick) ?? null;
285
+ }
286
+ return lastConfirmedBySlot[targetSlot];
287
+ }
288
+ /**
289
+ * Sample, bind and send this client's input for every unscheduled tick up to
290
+ * `targetTick`. The cursor is monotone: a tick is sampled and sent exactly
291
+ * once, so adaptive delay changes shift only FUTURE bindings. Spectators
292
+ * (G11) hold no slot and never sample or send input.
293
+ */
294
+ function scheduleOwnInputsThrough(targetTick) {
295
+ if (role === 'spectator') {
296
+ return;
297
+ }
298
+ while (ownScheduleCursor <= targetTick) {
299
+ const tick = ownScheduleCursor;
300
+ let own = ownInputs.get(tick);
301
+ if (own === undefined) {
302
+ own = options.encodeInput(options.localInputForTick(slot, tick));
303
+ ownInputs.set(tick, own);
304
+ }
305
+ sendInput(tick, own);
306
+ ownScheduleCursor = tick + 1;
307
+ }
308
+ }
309
+ /** Predict exactly one frame: schedule own input (delay-shifted), step, extend the head. */
310
+ function stepPredictedFrame() {
311
+ const active = session;
312
+ if (active === undefined) {
313
+ return;
314
+ }
315
+ const tick = predictedThrough;
316
+ scheduleOwnInputsThrough(tick + inputDelayTicks);
317
+ const inputs = [];
318
+ for (let s = 0; s < playerCount; s += 1) {
319
+ const encoded = inputForSlot(s, tick);
320
+ inputs.push(encoded);
321
+ active.setInput(s, decodeInputSafe(encoded));
322
+ }
323
+ timeline[tick] = inputs;
324
+ active.stepFrames(1);
325
+ predictedThrough = tick + 1;
326
+ }
327
+ function simulate(from) {
328
+ const active = session;
329
+ if (active === undefined) {
330
+ return;
331
+ }
332
+ active.restoreToFrame(from);
333
+ for (let tick = from; tick < predictedThrough; tick += 1) {
334
+ const inputs = [];
335
+ for (let s = 0; s < playerCount; s += 1) {
336
+ const encoded = inputForSlot(s, tick);
337
+ inputs.push(encoded);
338
+ active.setInput(s, decodeInputSafe(encoded));
339
+ }
340
+ timeline[tick] = inputs;
341
+ active.stepFrames(1);
342
+ }
343
+ }
344
+ function pump() {
345
+ if (session === undefined) {
346
+ return;
347
+ }
348
+ flushOutgoingTransfer();
349
+ while (predictedThrough <= appliedThrough + maxPredictionTicks) {
350
+ stepPredictedFrame();
351
+ }
352
+ }
353
+ function pumpPaced(nowMs) {
354
+ flushOutgoingTransfer();
355
+ maybeSendPing(nowMs);
356
+ if (session === undefined || frameMs <= 0) {
357
+ lastPumpPacedMs = nowMs;
358
+ return;
359
+ }
360
+ if (lastPumpPacedMs === undefined) {
361
+ lastPumpPacedMs = nowMs;
362
+ return;
363
+ }
364
+ updateTimescale(nowMs);
365
+ pacingAccumulatorMs += Math.max(0, nowMs - lastPumpPacedMs) * timescale;
366
+ lastPumpPacedMs = nowMs;
367
+ let steps = Math.floor(pacingAccumulatorMs / frameMs);
368
+ pacingAccumulatorMs -= steps * frameMs;
369
+ if (steps > maxStepsPerPump) {
370
+ // Shed the debt instead of bursting later (a backgrounded-tab stall must
371
+ // not replay as a violent catch-up sprint).
372
+ steps = maxStepsPerPump;
373
+ pacingAccumulatorMs = 0;
374
+ }
375
+ for (let i = 0; i < steps; i += 1) {
376
+ if (predictedThrough > appliedThrough + maxPredictionTicks) {
377
+ // Prediction cap: the authority is the missing party, not local time —
378
+ // drop the debt so its arrival doesn't trigger a burst.
379
+ pacingAccumulatorMs = 0;
380
+ break;
381
+ }
382
+ stepPredictedFrame();
383
+ }
384
+ }
385
+ function sendInput(tick, input) {
386
+ options.transport.send(encodeDeterministicSessionMessage({ kind: 'input', slot, tick, input, attempt: 0 }));
387
+ }
388
+ function confirmedStateChecksum(frame) {
389
+ const active = session;
390
+ if (active === undefined) {
391
+ return '';
392
+ }
393
+ active.restoreToFrame(frame);
394
+ return String(defaultChecksum(active.getState()));
395
+ }
396
+ function applyConfirmedFrames(frames) {
397
+ for (const frame of frames) {
398
+ if (frame.tick > appliedThrough) {
399
+ confirmed.set(frame.tick, frame.inputs);
400
+ }
401
+ }
402
+ while (confirmed.has(appliedThrough + 1)) {
403
+ const tick = appliedThrough + 1;
404
+ const confInputs = confirmed.get(tick);
405
+ const predInputs = timeline[tick];
406
+ if (predInputs === undefined || canonicalStringify(confInputs) !== canonicalStringify(predInputs)) {
407
+ timeline[tick] = confInputs.slice();
408
+ if (predictedThrough > tick) {
409
+ // Rollback: we had already predicted this tick and the confirmed inputs differ.
410
+ rollbacks += 1;
411
+ maxRollbackDepth = Math.max(maxRollbackDepth, predictedThrough - tick);
412
+ simulate(tick);
413
+ }
414
+ else {
415
+ // Confirm leads prediction (a lagging client): the authority confirmed a tick we had not
416
+ // predicted yet. Extend the predicted head and apply the confirmed inputs so the session
417
+ // actually advances through it — otherwise appliedThrough would outrun the simulated state.
418
+ predictedThrough = tick + 1;
419
+ simulate(tick);
420
+ }
421
+ }
422
+ for (let s = 0; s < playerCount; s += 1) {
423
+ lastConfirmedBySlot[s] = confInputs[s];
424
+ }
425
+ appliedThrough = tick;
426
+ }
427
+ autoReportCrossedCadences();
428
+ }
429
+ // ── M5.5: cadence-aligned checksum reports (quorum food + desync telemetry) ──
430
+ function autoReportCrossedCadences() {
431
+ let next = (Math.floor(lastAutoReportedTick / checksumCadenceTicks) + 1) * checksumCadenceTicks;
432
+ let reported = false;
433
+ while (next <= appliedThrough) {
434
+ sendChecksumReportAt(next);
435
+ lastAutoReportedTick = next;
436
+ reported = true;
437
+ next += checksumCadenceTicks;
438
+ }
439
+ // Netcode telemetry rides the same cadence (G8) — only once RTT exists,
440
+ // so step-counted tests (which never drive pumpPaced) stay silent.
441
+ if (reported && rttMs >= 0 && role !== 'spectator') {
442
+ options.transport.send(encodeDeterministicSessionMessage({
443
+ kind: 'net-stats',
444
+ slot,
445
+ tick: appliedThrough,
446
+ rttMs: Math.round(rttMs),
447
+ rollbacks,
448
+ maxRollbackDepth,
449
+ timescalePermille: Math.round(timescale * 1000),
450
+ inputDelayTicks,
451
+ }));
452
+ }
453
+ }
454
+ /**
455
+ * Report the confirmed-state checksum at `tick`, read from the rolling
456
+ * snapshot buffer — NOT via restoreToFrame, which would rewind the live
457
+ * session out from under prediction. Buffer state is the serialize
458
+ * round-trip of the live state, i.e. exactly what a snapshot joiner will
459
+ * hydrate, so quorum comparisons are round-trip-consistent by construction.
460
+ * Ticks that fell out of the buffer are skipped (the client simply is not
461
+ * donor-eligible for them).
462
+ */
463
+ function sendChecksumReportAt(tick) {
464
+ const active = session;
465
+ // Spectators hold no slot — the authority would drop their reports anyway.
466
+ if (active === undefined || tick < 0 || role === 'spectator') {
467
+ return;
468
+ }
469
+ const snapshot = active.getSnapshot(tick + 1);
470
+ if (snapshot === undefined) {
471
+ return;
472
+ }
473
+ options.transport.send(encodeDeterministicSessionMessage({
474
+ kind: 'checksum-report',
475
+ slot,
476
+ tick,
477
+ checksum: defaultChecksum(snapshot.state),
478
+ }));
479
+ reportedTicks.push(tick);
480
+ if (reportedTicks.length > REPORTED_TICKS_RETAINED) {
481
+ reportedTicks.shift();
482
+ }
483
+ }
484
+ // ── M5.5: snapshot late-join wire handling (donor + joiner roles) ──
485
+ function handleSnapshotWireMessage(text) {
486
+ let envelope;
487
+ try {
488
+ envelope = decodeDeterministicLocalAuthorityWireEnvelope(text);
489
+ }
490
+ catch {
491
+ return;
492
+ }
493
+ if (envelope.kind === 'snapshot-request') {
494
+ handleSnapshotRequest(envelope.payload);
495
+ return;
496
+ }
497
+ if (envelope.kind === 'snapshot-offer') {
498
+ transferCollector.offer(envelope.payload);
499
+ return;
500
+ }
501
+ if (envelope.kind === 'snapshot-chunk') {
502
+ transferCollector.chunk(envelope.payload);
503
+ return;
504
+ }
505
+ if (envelope.kind === 'snapshot-complete') {
506
+ const completed = transferCollector.complete(envelope.payload);
507
+ if (completed.accepted) {
508
+ applyCollectedTransfer(completed.transfer);
509
+ }
510
+ }
511
+ }
512
+ /** Donor role: answer with the smallest self-reported tick the buffer can still export. */
513
+ function handleSnapshotRequest(payload) {
514
+ const active = session;
515
+ if (active === undefined) {
516
+ return;
517
+ }
518
+ const requestIdentity = payload.identity;
519
+ if (typeof requestIdentity !== 'object' ||
520
+ requestIdentity === null ||
521
+ requestIdentity.descriptorHash !== descriptorHash) {
522
+ return;
523
+ }
524
+ const minFrame = payload.minFrame;
525
+ if (typeof minFrame !== 'number' || !Number.isInteger(minFrame)) {
526
+ return;
527
+ }
528
+ const minTick = minFrame - 1;
529
+ const candidates = reportedTicks.filter((tick) => tick >= minTick).sort((a, b) => a - b);
530
+ for (const tick of candidates) {
531
+ const snapshot = active.getSnapshot(tick + 1);
532
+ const confirmedAtTick = timeline[tick];
533
+ if (snapshot === undefined || confirmedAtTick === undefined) {
534
+ continue;
535
+ }
536
+ const bytes = encodeDeterministicStateSnapshot({
537
+ v: 1,
538
+ tick,
539
+ sessionFrame: tick + 1,
540
+ stateBytes: bytesToBase64(snapshot.stateBytes),
541
+ randomState: snapshot.randomState,
542
+ stateChecksum: defaultChecksum(snapshot.state),
543
+ confirmedInputs: confirmedAtTick.map((input) => cloneCanonical(input)),
544
+ });
545
+ donorTransferCounter += 1;
546
+ const transfer = createSnapshotTransferFromBytes(bytes, {
547
+ identity: { descriptorHash, schemaHash },
548
+ frame: tick + 1,
549
+ transferId: `donor:${slot}:${tick}:${donorTransferCounter}`,
550
+ });
551
+ let sequence = donorTransferCounter * 1000;
552
+ options.transport.send(createDeterministicLocalAuthoritySnapshotOfferEnvelope(sequence++, transfer));
553
+ for (let index = 0; index < transfer.chunks.length; index += 1) {
554
+ outgoingTransferQueue.push(createDeterministicLocalAuthoritySnapshotChunkEnvelope(sequence++, transfer, index));
555
+ }
556
+ outgoingTransferQueue.push(createDeterministicLocalAuthoritySnapshotCompleteEnvelope(sequence++, transfer.transferId));
557
+ return;
558
+ }
559
+ // Nothing exportable: stay silent — the authority times out and rotates donors.
560
+ }
561
+ /** Paced donor upload: a burst of large chunks must not trip host flood limits. */
562
+ function flushOutgoingTransfer() {
563
+ for (let i = 0; i < TRANSFER_SENDS_PER_PUMP && outgoingTransferQueue.length > 0; i += 1) {
564
+ const text = outgoingTransferQueue.shift();
565
+ if (text !== undefined) {
566
+ options.transport.send(text);
567
+ }
568
+ }
569
+ }
570
+ /**
571
+ * Joiner role: hydrate from an authority-forwarded snapshot. Stale transfers
572
+ * (reconnect refills the client is already past) are silently discarded;
573
+ * identity or state-checksum mismatches are FAIL-LOUD — a wrong room, wrong
574
+ * game build, or forged snapshot must never seat a desynced player.
575
+ */
576
+ function applyCollectedTransfer(collected) {
577
+ const active = session;
578
+ if (active === undefined) {
579
+ return;
580
+ }
581
+ const decoded = decodeDeterministicStateSnapshot(collected.snapshotBytes);
582
+ if (!decoded.ok || decoded.snapshot.sessionFrame !== collected.frame) {
583
+ return;
584
+ }
585
+ const snapshot = decoded.snapshot;
586
+ if (snapshot.tick <= appliedThrough) {
587
+ return;
588
+ }
589
+ if (collected.identity.descriptorHash !== descriptorHash) {
590
+ throw new SyncplayError('Snapshot descriptorHash does not match this room', {
591
+ code: 'client.snapshot-identity-mismatch',
592
+ frame: snapshot.sessionFrame,
593
+ });
594
+ }
595
+ if (collected.identity.schemaHash !== undefined && collected.identity.schemaHash !== schemaHash) {
596
+ throw new SyncplayError('Snapshot schemaHash does not match this game build', {
597
+ code: 'client.snapshot-identity-mismatch',
598
+ frame: snapshot.sessionFrame,
599
+ });
600
+ }
601
+ if (snapshot.confirmedInputs.length !== playerCount) {
602
+ throw new SyncplayError('Snapshot confirmedInputs do not cover every player slot', {
603
+ code: 'client.snapshot-inputs-mismatch',
604
+ frame: snapshot.sessionFrame,
605
+ });
606
+ }
607
+ active.restoreSnapshot({
608
+ frame: snapshot.sessionFrame,
609
+ stateBytes: base64ToBytes(snapshot.stateBytes),
610
+ randomState: snapshot.randomState,
611
+ inputs: snapshot.confirmedInputs.map((input) => decodeInputSafe(input)),
612
+ });
613
+ const actualChecksum = defaultChecksum(active.getState());
614
+ if (actualChecksum !== snapshot.stateChecksum) {
615
+ throw new SyncplayError('Hydrated snapshot state does not match its declared checksum', {
616
+ code: 'client.snapshot-state-mismatch',
617
+ frame: snapshot.sessionFrame,
618
+ });
619
+ }
620
+ appliedThrough = snapshot.tick;
621
+ predictedThrough = snapshot.tick + 1;
622
+ // Never send input for ticks at/below the hydration point — they are
623
+ // already confirmed. Anything the cursor had scheduled beyond it stands.
624
+ ownScheduleCursor = Math.max(ownScheduleCursor, snapshot.tick + 1);
625
+ lastConfirmedBySlot = snapshot.confirmedInputs.map((input) => cloneCanonical(input));
626
+ timeline.length = 0;
627
+ timeline[snapshot.tick] = lastConfirmedBySlot.slice();
628
+ for (const key of [...ownInputs.keys()]) {
629
+ if (key <= snapshot.tick)
630
+ ownInputs.delete(key);
631
+ }
632
+ for (const key of [...confirmed.keys()]) {
633
+ if (key <= snapshot.tick)
634
+ confirmed.delete(key);
635
+ }
636
+ lastAutoReportedTick = Math.max(lastAutoReportedTick, snapshot.tick);
637
+ // Frames that arrived before hydration are buffered in `confirmed` — the
638
+ // normal contiguous-apply drains the tail from here.
639
+ applyConfirmedFrames([]);
640
+ }
641
+ return {
642
+ get slot() {
643
+ return slot;
644
+ },
645
+ get role() {
646
+ return role;
647
+ },
648
+ get ready() {
649
+ return session !== undefined;
650
+ },
651
+ get predictedThrough() {
652
+ return predictedThrough;
653
+ },
654
+ get appliedThrough() {
655
+ return appliedThrough;
656
+ },
657
+ get rollbacks() {
658
+ return rollbacks;
659
+ },
660
+ get netStats() {
661
+ return {
662
+ rttMs: rttMs < 0 ? -1 : Math.round(rttMs),
663
+ timescalePermille: Math.round(timescale * 1000),
664
+ inputDelayTicks,
665
+ rollbacks,
666
+ maxRollbackDepth,
667
+ };
668
+ },
669
+ whenReady() {
670
+ return readyPromise;
671
+ },
672
+ pump,
673
+ pumpPaced,
674
+ reportChecksum() {
675
+ if (session === undefined || appliedThrough < 0) {
676
+ return;
677
+ }
678
+ sendChecksumReportAt(appliedThrough);
679
+ },
680
+ confirmedStateChecksum,
681
+ exportReplay() {
682
+ if (session === undefined) {
683
+ throw new Error('networked client has not started');
684
+ }
685
+ return session.exportReplay();
686
+ },
687
+ };
688
+ }