onelobby 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +77 -2
  3. package/dist/chat.d.ts +60 -0
  4. package/dist/chat.js +163 -0
  5. package/dist/chat.js.map +1 -0
  6. package/dist/chunking.d.ts +17 -0
  7. package/dist/chunking.js +59 -0
  8. package/dist/chunking.js.map +1 -0
  9. package/dist/clock.d.ts +8 -0
  10. package/dist/clock.js +39 -0
  11. package/dist/clock.js.map +1 -0
  12. package/dist/codec.d.ts +141 -0
  13. package/dist/codec.js +308 -0
  14. package/dist/codec.js.map +1 -0
  15. package/dist/crockford.d.ts +14 -0
  16. package/dist/crockford.js +55 -0
  17. package/dist/crockford.js.map +1 -0
  18. package/dist/deflate.d.ts +4 -0
  19. package/dist/deflate.js +32 -0
  20. package/dist/deflate.js.map +1 -0
  21. package/dist/deprecated.d.ts +18 -0
  22. package/dist/deprecated.js +11 -0
  23. package/dist/deprecated.js.map +1 -0
  24. package/dist/direct/hub-host.d.ts +82 -0
  25. package/dist/direct/hub-host.js +393 -0
  26. package/dist/direct/hub-host.js.map +1 -0
  27. package/dist/direct/hub.d.ts +178 -0
  28. package/dist/direct/hub.js +862 -0
  29. package/dist/direct/hub.js.map +1 -0
  30. package/dist/direct/index.d.ts +57 -0
  31. package/dist/direct/index.js +71 -0
  32. package/dist/direct/index.js.map +1 -0
  33. package/dist/direct/portmap.d.ts +84 -0
  34. package/dist/direct/portmap.js +465 -0
  35. package/dist/direct/portmap.js.map +1 -0
  36. package/dist/direct-address.d.ts +20 -0
  37. package/dist/direct-address.js +61 -0
  38. package/dist/direct-address.js.map +1 -0
  39. package/dist/emitter.d.ts +9 -0
  40. package/dist/emitter.js +36 -0
  41. package/dist/emitter.js.map +1 -0
  42. package/dist/friendly.d.ts +20 -0
  43. package/dist/friendly.js +120 -0
  44. package/dist/friendly.js.map +1 -0
  45. package/dist/global.d.ts +8 -0
  46. package/dist/global.js +9 -0
  47. package/dist/global.js.map +1 -0
  48. package/dist/gridpaint.d.ts +27 -0
  49. package/dist/gridpaint.js +106 -0
  50. package/dist/gridpaint.js.map +1 -0
  51. package/dist/hub.d.ts +25 -0
  52. package/dist/hub.js +2 -0
  53. package/dist/hub.js.map +1 -0
  54. package/dist/index.d.ts +35 -0
  55. package/dist/index.js +25 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/internals.d.ts +28 -0
  58. package/dist/internals.js +24 -0
  59. package/dist/internals.js.map +1 -0
  60. package/dist/joincode.d.ts +21 -0
  61. package/dist/joincode.js +84 -0
  62. package/dist/joincode.js.map +1 -0
  63. package/dist/lobby.d.ts +203 -0
  64. package/dist/lobby.js +840 -0
  65. package/dist/lobby.js.map +1 -0
  66. package/dist/lockstep.d.ts +132 -0
  67. package/dist/lockstep.js +471 -0
  68. package/dist/lockstep.js.map +1 -0
  69. package/dist/migration.d.ts +17 -0
  70. package/dist/migration.js +34 -0
  71. package/dist/migration.js.map +1 -0
  72. package/dist/onelobby.bundle.mjs +140 -0
  73. package/dist/onelobby.bundle.mjs.map +7 -0
  74. package/dist/onelobby.d.ts +151 -0
  75. package/dist/onelobby.global.js +3897 -0
  76. package/dist/onelobby.global.js.map +7 -0
  77. package/dist/onelobby.global.min.js +140 -0
  78. package/dist/onelobby.global.min.js.map +7 -0
  79. package/dist/onelobby.js +337 -0
  80. package/dist/onelobby.js.map +1 -0
  81. package/dist/signaling.d.ts +94 -0
  82. package/dist/signaling.js +134 -0
  83. package/dist/signaling.js.map +1 -0
  84. package/dist/socket.d.ts +63 -0
  85. package/dist/socket.js +142 -0
  86. package/dist/socket.js.map +1 -0
  87. package/dist/testing.d.ts +135 -0
  88. package/dist/testing.js +438 -0
  89. package/dist/testing.js.map +1 -0
  90. package/dist/transport.d.ts +80 -0
  91. package/dist/transport.js +7 -0
  92. package/dist/transport.js.map +1 -0
  93. package/dist/ui/controller.d.ts +137 -0
  94. package/dist/ui/controller.js +337 -0
  95. package/dist/ui/controller.js.map +1 -0
  96. package/dist/ui/element.d.ts +22 -0
  97. package/dist/ui/element.js +284 -0
  98. package/dist/ui/element.js.map +1 -0
  99. package/dist/ui/index.d.ts +5 -0
  100. package/dist/ui/index.js +9 -0
  101. package/dist/ui/index.js.map +1 -0
  102. package/dist/ui/server-url.d.ts +9 -0
  103. package/dist/ui/server-url.js +39 -0
  104. package/dist/ui/server-url.js.map +1 -0
  105. package/dist/ui/styles.d.ts +2 -0
  106. package/dist/ui/styles.js +70 -0
  107. package/dist/ui/styles.js.map +1 -0
  108. package/dist/voice-browser.d.ts +19 -0
  109. package/dist/voice-browser.js +151 -0
  110. package/dist/voice-browser.js.map +1 -0
  111. package/dist/voice-core.d.ts +98 -0
  112. package/dist/voice-core.js +233 -0
  113. package/dist/voice-core.js.map +1 -0
  114. package/dist/voice-types.d.ts +35 -0
  115. package/dist/voice-types.js +3 -0
  116. package/dist/voice-types.js.map +1 -0
  117. package/dist/voice.d.ts +68 -0
  118. package/dist/voice.js +223 -0
  119. package/dist/voice.js.map +1 -0
  120. package/dist/webrtc.d.ts +29 -0
  121. package/dist/webrtc.js +169 -0
  122. package/dist/webrtc.js.map +1 -0
  123. package/package.json +93 -4
@@ -0,0 +1,471 @@
1
+ import { CHUNK_PAYLOAD, splitChunks } from "./chunking.js";
2
+ import { PROTO } from "./codec.js";
3
+ import { deflate, deflateAvailable, inflate } from "./deflate.js";
4
+ import { Emitter } from "./emitter.js";
5
+ export const MAX_DESYNCS = 3;
6
+ export const MAX_RESTORE_RETRIES = 3;
7
+ export const HOST_SILENCE_MS = 3000;
8
+ export const PEER_HASH_SILENCE_MS = 10_000;
9
+ const SNAPSHOT_COMPRESS_MIN = 1024;
10
+ /**
11
+ * Host-ordered lockstep (spec/binary.md, DESIGN s.8). One class for both roles: the host emits `frame(T)` on a fixed
12
+ * cadence and never waits; joiners queue frames and hand them out through `nextFrame()`.
13
+ *
14
+ * Ticks: the initial state is tick 0 and has no frame; `frame(T)` produces tick T. `reportHash(tick, h)` is called
15
+ * after applying `frame.tick` (the library only sends when `tick % hashEveryTicks == 0`, tick > 0). `snapshot()` must
16
+ * return the state after the last frame returned by `nextFrame()`; `restore(tick, bytes)` receives that tick, and the
17
+ * next frame applied is `tick + 1`.
18
+ */
19
+ export class Lockstep extends Emitter {
20
+ hub;
21
+ snapshot = null;
22
+ restore = null;
23
+ hashEveryTicks;
24
+ /** Chunk body size used when serving snapshots. */
25
+ chunkSize = CHUNK_PAYLOAD;
26
+ historyMax;
27
+ queue = [];
28
+ history = [];
29
+ /** Highest frame tick received/emitted; -1 = no state (a fresh joiner). */
30
+ lastFrameTick = -1;
31
+ /** Tick of the state the game holds (last frame returned by nextFrame, or a restored snapshot). */
32
+ consumedTick = 0;
33
+ cmdSeq = 1;
34
+ awaitingSnapshot = false;
35
+ lastFrameRx = 0;
36
+ lastSnapshotReq = -Infinity;
37
+ restoreFailures = 0;
38
+ // host side
39
+ hosting = false;
40
+ _started = false;
41
+ pending = [];
42
+ ready = new Set();
43
+ snapshotting = new Set();
44
+ cancelTicker = null;
45
+ baseTime = 0;
46
+ emitted = 0;
47
+ ownHashes = new Map();
48
+ peerHashes = new Map();
49
+ desyncs = new Map();
50
+ lastHashRx = new Map();
51
+ /** Hashes a peer sent for ticks up to this were computed before it restored a snapshot: stale, not evidence. */
52
+ staleUpTo = new Map();
53
+ // joiner side
54
+ pendingSnapshot = null;
55
+ constructor(hub, opts) {
56
+ super();
57
+ this.hub = hub;
58
+ this.hashEveryTicks = Math.max(1, opts.hashEveryTicks);
59
+ this.historyMax = opts.historyFrames ?? Math.max(60, hub.tickRate * 30);
60
+ this.lastFrameRx = hub.clock.now();
61
+ }
62
+ // ---- public game-facing surface -------------------------------------------------------------------------------
63
+ get started() {
64
+ return this._started;
65
+ }
66
+ get framesBuffered() {
67
+ return this.queue.length;
68
+ }
69
+ /** True when the game should run ticks without rendering (> 2 frames behind). */
70
+ get catchingUp() {
71
+ return this.queue.length > 2;
72
+ }
73
+ /** Tick of the game's current state. */
74
+ get currentTick() {
75
+ return this.consumedTick;
76
+ }
77
+ /** Highest frame tick known to this peer (0 when none yet; the host's `welcome_dc.tick`). */
78
+ get lastTick() {
79
+ return Math.max(0, this.lastFrameTick);
80
+ }
81
+ /** What `hi.lastTick` carries: -1 until the first frame/snapshot. */
82
+ get hiTick() {
83
+ return this.lastFrameTick;
84
+ }
85
+ /** Queue a command. Host: into its own next frame. Joiner: to the host (reliable). */
86
+ submit(bytes) {
87
+ if (this.hosting) {
88
+ this.pending.push({ peerId: this.hub.localPeerId, bytes: bytes.slice() });
89
+ }
90
+ else {
91
+ this.hub.sendToHost({ type: "cmd", seq: this.cmdSeq++ >>> 0, bytes });
92
+ }
93
+ }
94
+ nextFrame() {
95
+ const f = this.queue.shift() ?? null;
96
+ if (f)
97
+ this.consumedTick = f.tick;
98
+ return f;
99
+ }
100
+ reportHash(tick, hash) {
101
+ if (this.awaitingSnapshot || tick <= 0 || tick % this.hashEveryTicks !== 0)
102
+ return;
103
+ if (this.hosting) {
104
+ this.ownHashes.set(tick, hash);
105
+ this.prune(this.ownHashes);
106
+ const theirs = this.peerHashes.get(tick);
107
+ if (theirs) {
108
+ this.peerHashes.delete(tick);
109
+ for (const [peer, h] of [...theirs].sort((a, b) => a[0] - b[0]))
110
+ this.compare(peer, tick, hash, h);
111
+ }
112
+ }
113
+ else {
114
+ this.hub.sendToHost({ type: "hash", tick, hash });
115
+ }
116
+ }
117
+ // ---- role control (called by Lobby) ---------------------------------------------------------------------------
118
+ /** Host role without emitting yet (lobby phase): peers may already say hi. */
119
+ hostIdle() {
120
+ this.hosting = true;
121
+ if (this.lastFrameTick < 0)
122
+ this.lastFrameTick = 0;
123
+ }
124
+ /** Host begins emitting frames (game start, or after winning a migration), continuing after the last known tick. */
125
+ hostStart() {
126
+ this.hostIdle();
127
+ this._started = true;
128
+ this.emitted = 0;
129
+ this.baseTime = this.hub.clock.now();
130
+ this.cancelTicker?.();
131
+ const interval = 1000 / this.hub.tickRate;
132
+ this.cancelTicker = this.hub.clock.setInterval(() => this.pump(interval), Math.max(1, Math.floor(interval / 2)));
133
+ }
134
+ /** Winning a migration: continue from our last received frame. Pending commands are lost by design. */
135
+ becomeHost() {
136
+ this.pending = [];
137
+ this.ready.clear();
138
+ this.snapshotting.clear();
139
+ this.ownHashes.clear();
140
+ this.peerHashes.clear();
141
+ this.desyncs.clear();
142
+ this.awaitingSnapshot = false;
143
+ this.hostIdle();
144
+ if (this._started)
145
+ this.hostStart();
146
+ }
147
+ /** Losing a claim, or a new host appeared: stop emitting. Keep history; the new host decides replay vs snapshot. */
148
+ becomeJoiner() {
149
+ this.hosting = false;
150
+ this.cancelTicker?.();
151
+ this.cancelTicker = null;
152
+ this.pending = [];
153
+ this.ready.clear();
154
+ this.lastFrameRx = this.hub.clock.now();
155
+ }
156
+ /** A joiner just learned the session is in game (first frame, snapshot, or phase from the server). */
157
+ markStarted() {
158
+ if (this._started)
159
+ return;
160
+ this._started = true;
161
+ this.emit("started");
162
+ }
163
+ stop() {
164
+ this.cancelTicker?.();
165
+ this.cancelTicker = null;
166
+ }
167
+ onPeerLeft(peerId) {
168
+ this.ready.delete(peerId);
169
+ this.snapshotting.delete(peerId);
170
+ this.desyncs.delete(peerId);
171
+ this.lastHashRx.delete(peerId);
172
+ this.staleUpTo.delete(peerId);
173
+ for (const m of this.peerHashes.values())
174
+ m.delete(peerId);
175
+ this.pending = this.pending.filter((c) => c.peerId !== peerId);
176
+ }
177
+ // ---- host: frame emission ---------------------------------------------------------------------------------------
178
+ pump(interval) {
179
+ if (!this.hosting)
180
+ return;
181
+ const now = this.hub.clock.now();
182
+ let due = Math.floor((now - this.baseTime + 1) / interval) - this.emitted;
183
+ if (due > 10) {
184
+ // Starved (e.g. a throttled tab): do not flood. Rebase rather than emit a burst of empty frames.
185
+ this.baseTime = now - this.emitted * interval;
186
+ due = 1;
187
+ }
188
+ for (let i = 0; i < due; i++)
189
+ this.emitFrame();
190
+ }
191
+ emitFrame() {
192
+ // sorted by peerId, stable: one peer's commands keep their arrival order
193
+ const commands = this.pending.splice(0).sort((a, b) => a.peerId - b.peerId);
194
+ const frame = { tick: this.lastFrameTick + 1, commands };
195
+ this.emitted++;
196
+ this.lastFrameTick = frame.tick;
197
+ this.record(frame);
198
+ this.queue.push(frame);
199
+ const msg = { type: "frame", tick: frame.tick, commands };
200
+ for (const p of this.ready)
201
+ this.hub.sendTo(p, msg, 0);
202
+ this.checkPeerSilence();
203
+ }
204
+ record(f) {
205
+ this.history.push(f);
206
+ if (this.history.length > this.historyMax)
207
+ this.history.splice(0, this.history.length - this.historyMax);
208
+ }
209
+ /** The host drops a peer it has had no `hash` from for 10 s (scaled up when hashes are rare). */
210
+ checkPeerSilence() {
211
+ if (this.ownHashes.size === 0)
212
+ return;
213
+ const now = this.hub.clock.now();
214
+ const limit = Math.max(PEER_HASH_SILENCE_MS, (this.hashEveryTicks / this.hub.tickRate) * 1000 * 3);
215
+ for (const p of [...this.ready]) {
216
+ const last = this.lastHashRx.get(p) ?? now;
217
+ if (now - last > limit)
218
+ this.hub.dropPeer(p, "timeout");
219
+ }
220
+ }
221
+ // ---- message handling -------------------------------------------------------------------------------------------
222
+ handle(from, msg) {
223
+ switch (msg.type) {
224
+ case "cmd":
225
+ if (this.hosting)
226
+ this.pending.push({ peerId: from, bytes: msg.bytes });
227
+ break;
228
+ case "frame":
229
+ if (!this.hosting)
230
+ this.onFrame(msg.tick, msg.commands);
231
+ break;
232
+ case "hash":
233
+ if (this.hosting)
234
+ this.onPeerHash(from, msg.tick, msg.hash);
235
+ break;
236
+ case "desync":
237
+ if (!this.hosting) {
238
+ this.awaitingSnapshot = true;
239
+ this.queue = [];
240
+ this.emit("desync", { tick: msg.tick, hostHash: msg.hostHash, yourHash: msg.yourHash });
241
+ }
242
+ break;
243
+ case "snapshot_req":
244
+ if (this.hosting)
245
+ void this.sendSnapshot(from);
246
+ break;
247
+ case "snapshot":
248
+ if (!this.hosting) {
249
+ this.markStarted();
250
+ this.pendingSnapshot = { tick: msg.tick, total: msg.total, flags: msg.flags };
251
+ }
252
+ break;
253
+ }
254
+ }
255
+ /** True when a `snapshot` header was seen and its chunk set has not completed yet. */
256
+ get snapshotPending() {
257
+ return this.pendingSnapshot !== null;
258
+ }
259
+ /** The reassembled chunk set that follows a `snapshot` header. */
260
+ onSnapshotPayload(payload) {
261
+ const ps = this.pendingSnapshot;
262
+ this.pendingSnapshot = null;
263
+ if (!ps)
264
+ return;
265
+ if (payload.length !== ps.total) {
266
+ // spec: a receiver that gets a different sum drops the snapshot and re-requests
267
+ this.awaitingSnapshot = false;
268
+ this.requestSnapshot();
269
+ return;
270
+ }
271
+ void this.applySnapshot(ps, payload);
272
+ }
273
+ onFrame(tick, commands) {
274
+ this.lastFrameRx = this.hub.clock.now();
275
+ this.markStarted();
276
+ if (this.awaitingSnapshot)
277
+ return;
278
+ const expected = Math.max(0, this.lastFrameTick) + 1;
279
+ if (tick < expected)
280
+ return; // duplicate (replay overlap)
281
+ if (tick > expected) {
282
+ this.requestSnapshot(); // gap: the host changed under us
283
+ return;
284
+ }
285
+ const f = { tick, commands };
286
+ this.lastFrameTick = tick;
287
+ this.record(f);
288
+ this.queue.push(f);
289
+ if (this.queue.length > this.hub.tickRate * 3)
290
+ this.requestSnapshot();
291
+ }
292
+ /** Joiner asks the host for a snapshot (far behind, gap, silent host). Throttled while one is outstanding. */
293
+ requestSnapshot() {
294
+ const now = this.hub.clock.now();
295
+ if (this.awaitingSnapshot && now - this.lastSnapshotReq < 2000)
296
+ return;
297
+ this.lastSnapshotReq = now;
298
+ this.awaitingSnapshot = true;
299
+ this.queue = [];
300
+ this.hub.sendToHost({ type: "snapshot_req", sinceTick: this.lastFrameTick });
301
+ }
302
+ /** 3 s without frames: ask the host for a snapshot without discarding anything (a live host answers). */
303
+ pokeHost() {
304
+ this.hub.sendToHost({ type: "snapshot_req", sinceTick: this.lastFrameTick });
305
+ }
306
+ async applySnapshot(ps, payload) {
307
+ try {
308
+ let bytes = payload;
309
+ if (ps.flags & 1)
310
+ bytes = await inflate(payload);
311
+ if (!this.restore)
312
+ throw new Error("lockstep.restore is not set; cannot apply a snapshot");
313
+ // Restore first: if the game's restore() throws, our bookkeeping must not have moved.
314
+ this.restore(ps.tick, bytes);
315
+ this.queue = [];
316
+ this.history = [];
317
+ this.lastFrameTick = ps.tick;
318
+ this.consumedTick = ps.tick;
319
+ this.awaitingSnapshot = false;
320
+ this.restoreFailures = 0;
321
+ this.markStarted();
322
+ this.lastFrameRx = this.hub.clock.now();
323
+ this.emit("restored", ps.tick);
324
+ }
325
+ catch (e) {
326
+ this.hub.reportError(e instanceof Error ? e : new Error(String(e)));
327
+ // The game's state is unknown after a failed restore: keep our tick where it was, drop frames, ask again.
328
+ if (++this.restoreFailures <= MAX_RESTORE_RETRIES) {
329
+ this.awaitingSnapshot = false;
330
+ this.lastSnapshotReq = -Infinity;
331
+ this.requestSnapshot();
332
+ }
333
+ else {
334
+ this.hub.reportError(new Error(`snapshot restore failed ${this.restoreFailures} times; giving up`));
335
+ }
336
+ }
337
+ }
338
+ // ---- host: hello, hashes, snapshots -----------------------------------------------------------------------------
339
+ /** Host: a peer said hi (welcome_dc and chat_history already sent). Decide: nothing, replay, or snapshot. */
340
+ onPeerHi(peerId, lastTick) {
341
+ this.lastHashRx.set(peerId, this.hub.clock.now());
342
+ const hostTick = this.lastFrameTick;
343
+ const first = this.history.length ? this.history[0].tick : hostTick + 1;
344
+ if (lastTick < 0) {
345
+ // fresh joiner: initial state if no game is running, otherwise a snapshot
346
+ if (this._started && hostTick > 0)
347
+ void this.sendSnapshot(peerId);
348
+ else
349
+ this.ready.add(peerId);
350
+ return;
351
+ }
352
+ if (lastTick <= hostTick && lastTick + 1 >= first) {
353
+ // The peer's timeline is a prefix of ours: replay what it lacks, then go live.
354
+ for (const f of this.history) {
355
+ if (f.tick > lastTick)
356
+ this.hub.sendTo(peerId, { type: "frame", tick: f.tick, commands: f.commands }, 0);
357
+ }
358
+ this.ready.add(peerId);
359
+ }
360
+ else {
361
+ void this.sendSnapshot(peerId);
362
+ }
363
+ }
364
+ onPeerHash(peerId, tick, hash) {
365
+ this.lastHashRx.set(peerId, this.hub.clock.now());
366
+ if (this.isStale(peerId, tick))
367
+ return;
368
+ const own = this.ownHashes.get(tick);
369
+ if (own === undefined) {
370
+ let m = this.peerHashes.get(tick);
371
+ if (!m)
372
+ this.peerHashes.set(tick, (m = new Map()));
373
+ m.set(peerId, hash);
374
+ this.prune(this.peerHashes);
375
+ return;
376
+ }
377
+ this.compare(peerId, tick, own, hash);
378
+ }
379
+ compare(peerId, tick, hostHash, yourHash) {
380
+ if (hostHash === yourHash)
381
+ return;
382
+ if (this.isStale(peerId, tick))
383
+ return;
384
+ if (!this.ready.has(peerId) || this.snapshotting.has(peerId))
385
+ return; // already being resynced
386
+ const n = (this.desyncs.get(peerId) ?? 0) + 1;
387
+ this.desyncs.set(peerId, n);
388
+ if (n >= MAX_DESYNCS) {
389
+ this.hub.dropPeer(peerId, "desync");
390
+ return;
391
+ }
392
+ this.hub.sendTo(peerId, { type: "desync", tick, hostHash, yourHash }, 0);
393
+ void this.sendSnapshot(peerId, tick);
394
+ }
395
+ /** Hashes computed before a peer restored its snapshot (ticks after the strike, up to what we had emitted) are not evidence. */
396
+ isStale(peerId, tick) {
397
+ const w = this.staleUpTo.get(peerId);
398
+ return !!w && tick > w.from && tick <= w.to;
399
+ }
400
+ async sendSnapshot(peerId, strikeTick = Infinity) {
401
+ if (this.snapshotting.has(peerId))
402
+ return;
403
+ if (!this.snapshot) {
404
+ this.hub.reportError(new Error("lockstep.snapshot is not set; cannot serve a snapshot"));
405
+ return;
406
+ }
407
+ this.snapshotting.add(peerId);
408
+ this.ready.delete(peerId); // live frames now flow only through the replay below
409
+ try {
410
+ const S = this.consumedTick;
411
+ const raw = this.snapshot();
412
+ let payload = raw;
413
+ let flags = 0;
414
+ if (raw.length >= SNAPSHOT_COMPRESS_MIN && deflateAvailable()) {
415
+ payload = await deflate(raw);
416
+ flags = 1;
417
+ }
418
+ if (!this.snapshotting.has(peerId))
419
+ return; // peer left while compressing
420
+ // header + one contiguous chunk set + frames S+1... with nothing else in between
421
+ this.hub.sendTo(peerId, { type: "snapshot", tick: S, total: payload.length, flags }, 0);
422
+ for (const c of splitChunks(this.hub.nextChunkId(), payload, this.chunkSize))
423
+ this.hub.sendTo(peerId, c, 0);
424
+ for (const f of this.history) {
425
+ if (f.tick > S)
426
+ this.hub.sendTo(peerId, { type: "frame", tick: f.tick, commands: f.commands }, 0);
427
+ }
428
+ if (strikeTick !== Infinity)
429
+ this.staleUpTo.set(peerId, { from: strikeTick, to: this.lastFrameTick });
430
+ else
431
+ this.staleUpTo.delete(peerId);
432
+ for (const m of this.peerHashes.values())
433
+ m.delete(peerId);
434
+ this.ready.add(peerId);
435
+ this.lastHashRx.set(peerId, this.hub.clock.now());
436
+ }
437
+ catch (e) {
438
+ this.hub.reportError(e instanceof Error ? e : new Error(String(e)));
439
+ }
440
+ finally {
441
+ this.snapshotting.delete(peerId);
442
+ }
443
+ }
444
+ prune(m) {
445
+ if (m.size <= 64)
446
+ return;
447
+ const keys = [...m.keys()].sort((a, b) => a - b);
448
+ for (const k of keys.slice(0, keys.length - 64))
449
+ m.delete(k);
450
+ }
451
+ // ---- joiner watchdog --------------------------------------------------------------------------------------------
452
+ /** Joiner: ms since the last frame from the host (only meaningful once in game). */
453
+ get msSinceFrame() {
454
+ return this.hub.clock.now() - this.lastFrameRx;
455
+ }
456
+ /** True when a started joiner has heard no frames for 3 s: the Lobby then runs the host-loss rule (s.9). */
457
+ hostSilent() {
458
+ return !this.hosting && this._started && this.msSinceFrame > HOST_SILENCE_MS;
459
+ }
460
+ /** Reset the silence timer, e.g. after the link to a new host opened. */
461
+ touch() {
462
+ this.lastFrameRx = this.hub.clock.now();
463
+ }
464
+ hiMessage(peerId) {
465
+ return { type: "hi", proto: PROTO, peerId, lastTick: this.lastFrameTick };
466
+ }
467
+ get resyncCount() {
468
+ return this.desyncs;
469
+ }
470
+ }
471
+ //# sourceMappingURL=lockstep.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lockstep.js","sourceRoot":"","sources":["../src/lockstep.ts"],"names":[],"mappings":"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","sourcesContent":["import { CHUNK_PAYLOAD, splitChunks } from \"./chunking.js\";\nimport { PROTO, type FrameCommand, type Message } from \"./codec.js\";\nimport { deflate, deflateAvailable, inflate } from \"./deflate.js\";\nimport { Emitter } from \"./emitter.js\";\nimport type { Hub } from \"./hub.js\";\n\nexport interface Frame {\n tick: number;\n commands: FrameCommand[];\n}\n\nexport interface DesyncInfo {\n tick: number;\n hostHash: bigint;\n yourHash: bigint;\n}\n\nexport type LockstepEvents = {\n desync: [DesyncInfo];\n /** A snapshot was applied (late join, desync, falling too far behind). */\n restored: [tick: number];\n /** First frame (or snapshot) of the game reached this peer: the session is in game. */\n started: [];\n error: [Error];\n};\n\nexport const MAX_DESYNCS = 3;\nexport const MAX_RESTORE_RETRIES = 3;\nexport const HOST_SILENCE_MS = 3000;\nexport const PEER_HASH_SILENCE_MS = 10_000;\nconst SNAPSHOT_COMPRESS_MIN = 1024;\n\nexport interface LockstepOptions {\n hashEveryTicks: number;\n /** Frames kept for replays (late join, migration). Default 30 s of ticks. */\n historyFrames?: number;\n}\n\n/**\n * Host-ordered lockstep (spec/binary.md, DESIGN s.8). One class for both roles: the host emits `frame(T)` on a fixed\n * cadence and never waits; joiners queue frames and hand them out through `nextFrame()`.\n *\n * Ticks: the initial state is tick 0 and has no frame; `frame(T)` produces tick T. `reportHash(tick, h)` is called\n * after applying `frame.tick` (the library only sends when `tick % hashEveryTicks == 0`, tick > 0). `snapshot()` must\n * return the state after the last frame returned by `nextFrame()`; `restore(tick, bytes)` receives that tick, and the\n * next frame applied is `tick + 1`.\n */\nexport class Lockstep extends Emitter<LockstepEvents> {\n snapshot: (() => Uint8Array) | null = null;\n restore: ((tick: number, bytes: Uint8Array) => void) | null = null;\n\n readonly hashEveryTicks: number;\n /** Chunk body size used when serving snapshots. */\n chunkSize = CHUNK_PAYLOAD;\n private historyMax: number;\n\n private queue: Frame[] = [];\n private history: Frame[] = [];\n /** Highest frame tick received/emitted; -1 = no state (a fresh joiner). */\n private lastFrameTick = -1;\n /** Tick of the state the game holds (last frame returned by nextFrame, or a restored snapshot). */\n private consumedTick = 0;\n private cmdSeq = 1;\n private awaitingSnapshot = false;\n private lastFrameRx = 0;\n private lastSnapshotReq = -Infinity;\n private restoreFailures = 0;\n\n // host side\n private hosting = false;\n private _started = false;\n private pending: FrameCommand[] = [];\n private ready = new Set<number>();\n private snapshotting = new Set<number>();\n private cancelTicker: (() => void) | null = null;\n private baseTime = 0;\n private emitted = 0;\n private ownHashes = new Map<number, bigint>();\n private peerHashes = new Map<number, Map<number, bigint>>();\n private desyncs = new Map<number, number>();\n private lastHashRx = new Map<number, number>();\n /** Hashes a peer sent for ticks up to this were computed before it restored a snapshot: stale, not evidence. */\n private staleUpTo = new Map<number, { from: number; to: number }>();\n\n // joiner side\n private pendingSnapshot: { tick: number; total: number; flags: number } | null = null;\n\n constructor(private hub: Hub, opts: LockstepOptions) {\n super();\n this.hashEveryTicks = Math.max(1, opts.hashEveryTicks);\n this.historyMax = opts.historyFrames ?? Math.max(60, hub.tickRate * 30);\n this.lastFrameRx = hub.clock.now();\n }\n\n // ---- public game-facing surface -------------------------------------------------------------------------------\n\n get started(): boolean {\n return this._started;\n }\n get framesBuffered(): number {\n return this.queue.length;\n }\n /** True when the game should run ticks without rendering (> 2 frames behind). */\n get catchingUp(): boolean {\n return this.queue.length > 2;\n }\n /** Tick of the game's current state. */\n get currentTick(): number {\n return this.consumedTick;\n }\n /** Highest frame tick known to this peer (0 when none yet; the host's `welcome_dc.tick`). */\n get lastTick(): number {\n return Math.max(0, this.lastFrameTick);\n }\n /** What `hi.lastTick` carries: -1 until the first frame/snapshot. */\n get hiTick(): number {\n return this.lastFrameTick;\n }\n\n /** Queue a command. Host: into its own next frame. Joiner: to the host (reliable). */\n submit(bytes: Uint8Array): void {\n if (this.hosting) {\n this.pending.push({ peerId: this.hub.localPeerId, bytes: bytes.slice() });\n } else {\n this.hub.sendToHost({ type: \"cmd\", seq: this.cmdSeq++ >>> 0, bytes });\n }\n }\n\n nextFrame(): Frame | null {\n const f = this.queue.shift() ?? null;\n if (f) this.consumedTick = f.tick;\n return f;\n }\n\n reportHash(tick: number, hash: bigint): void {\n if (this.awaitingSnapshot || tick <= 0 || tick % this.hashEveryTicks !== 0) return;\n if (this.hosting) {\n this.ownHashes.set(tick, hash);\n this.prune(this.ownHashes);\n const theirs = this.peerHashes.get(tick);\n if (theirs) {\n this.peerHashes.delete(tick);\n for (const [peer, h] of [...theirs].sort((a, b) => a[0] - b[0])) this.compare(peer, tick, hash, h);\n }\n } else {\n this.hub.sendToHost({ type: \"hash\", tick, hash });\n }\n }\n\n // ---- role control (called by Lobby) ---------------------------------------------------------------------------\n\n /** Host role without emitting yet (lobby phase): peers may already say hi. */\n hostIdle(): void {\n this.hosting = true;\n if (this.lastFrameTick < 0) this.lastFrameTick = 0;\n }\n\n /** Host begins emitting frames (game start, or after winning a migration), continuing after the last known tick. */\n hostStart(): void {\n this.hostIdle();\n this._started = true;\n this.emitted = 0;\n this.baseTime = this.hub.clock.now();\n this.cancelTicker?.();\n const interval = 1000 / this.hub.tickRate;\n this.cancelTicker = this.hub.clock.setInterval(() => this.pump(interval), Math.max(1, Math.floor(interval / 2)));\n }\n\n /** Winning a migration: continue from our last received frame. Pending commands are lost by design. */\n becomeHost(): void {\n this.pending = [];\n this.ready.clear();\n this.snapshotting.clear();\n this.ownHashes.clear();\n this.peerHashes.clear();\n this.desyncs.clear();\n this.awaitingSnapshot = false;\n this.hostIdle();\n if (this._started) this.hostStart();\n }\n\n /** Losing a claim, or a new host appeared: stop emitting. Keep history; the new host decides replay vs snapshot. */\n becomeJoiner(): void {\n this.hosting = false;\n this.cancelTicker?.();\n this.cancelTicker = null;\n this.pending = [];\n this.ready.clear();\n this.lastFrameRx = this.hub.clock.now();\n }\n\n /** A joiner just learned the session is in game (first frame, snapshot, or phase from the server). */\n markStarted(): void {\n if (this._started) return;\n this._started = true;\n this.emit(\"started\");\n }\n\n stop(): void {\n this.cancelTicker?.();\n this.cancelTicker = null;\n }\n\n onPeerLeft(peerId: number): void {\n this.ready.delete(peerId);\n this.snapshotting.delete(peerId);\n this.desyncs.delete(peerId);\n this.lastHashRx.delete(peerId);\n this.staleUpTo.delete(peerId);\n for (const m of this.peerHashes.values()) m.delete(peerId);\n this.pending = this.pending.filter((c) => c.peerId !== peerId);\n }\n\n // ---- host: frame emission ---------------------------------------------------------------------------------------\n\n private pump(interval: number): void {\n if (!this.hosting) return;\n const now = this.hub.clock.now();\n let due = Math.floor((now - this.baseTime + 1) / interval) - this.emitted;\n if (due > 10) {\n // Starved (e.g. a throttled tab): do not flood. Rebase rather than emit a burst of empty frames.\n this.baseTime = now - this.emitted * interval;\n due = 1;\n }\n for (let i = 0; i < due; i++) this.emitFrame();\n }\n\n private emitFrame(): void {\n // sorted by peerId, stable: one peer's commands keep their arrival order\n const commands = this.pending.splice(0).sort((a, b) => a.peerId - b.peerId);\n const frame: Frame = { tick: this.lastFrameTick + 1, commands };\n this.emitted++;\n this.lastFrameTick = frame.tick;\n this.record(frame);\n this.queue.push(frame);\n const msg: Message = { type: \"frame\", tick: frame.tick, commands };\n for (const p of this.ready) this.hub.sendTo(p, msg, 0);\n this.checkPeerSilence();\n }\n\n private record(f: Frame): void {\n this.history.push(f);\n if (this.history.length > this.historyMax) this.history.splice(0, this.history.length - this.historyMax);\n }\n\n /** The host drops a peer it has had no `hash` from for 10 s (scaled up when hashes are rare). */\n private checkPeerSilence(): void {\n if (this.ownHashes.size === 0) return;\n const now = this.hub.clock.now();\n const limit = Math.max(PEER_HASH_SILENCE_MS, (this.hashEveryTicks / this.hub.tickRate) * 1000 * 3);\n for (const p of [...this.ready]) {\n const last = this.lastHashRx.get(p) ?? now;\n if (now - last > limit) this.hub.dropPeer(p, \"timeout\");\n }\n }\n\n // ---- message handling -------------------------------------------------------------------------------------------\n\n handle(from: number, msg: Message): void {\n switch (msg.type) {\n case \"cmd\":\n if (this.hosting) this.pending.push({ peerId: from, bytes: msg.bytes });\n break;\n case \"frame\":\n if (!this.hosting) this.onFrame(msg.tick, msg.commands);\n break;\n case \"hash\":\n if (this.hosting) this.onPeerHash(from, msg.tick, msg.hash);\n break;\n case \"desync\":\n if (!this.hosting) {\n this.awaitingSnapshot = true;\n this.queue = [];\n this.emit(\"desync\", { tick: msg.tick, hostHash: msg.hostHash, yourHash: msg.yourHash });\n }\n break;\n case \"snapshot_req\":\n if (this.hosting) void this.sendSnapshot(from);\n break;\n case \"snapshot\":\n if (!this.hosting) {\n this.markStarted();\n this.pendingSnapshot = { tick: msg.tick, total: msg.total, flags: msg.flags };\n }\n break;\n }\n }\n\n /** True when a `snapshot` header was seen and its chunk set has not completed yet. */\n get snapshotPending(): boolean {\n return this.pendingSnapshot !== null;\n }\n\n /** The reassembled chunk set that follows a `snapshot` header. */\n onSnapshotPayload(payload: Uint8Array): void {\n const ps = this.pendingSnapshot;\n this.pendingSnapshot = null;\n if (!ps) return;\n if (payload.length !== ps.total) {\n // spec: a receiver that gets a different sum drops the snapshot and re-requests\n this.awaitingSnapshot = false;\n this.requestSnapshot();\n return;\n }\n void this.applySnapshot(ps, payload);\n }\n\n private onFrame(tick: number, commands: FrameCommand[]): void {\n this.lastFrameRx = this.hub.clock.now();\n this.markStarted();\n if (this.awaitingSnapshot) return;\n const expected = Math.max(0, this.lastFrameTick) + 1;\n if (tick < expected) return; // duplicate (replay overlap)\n if (tick > expected) {\n this.requestSnapshot(); // gap: the host changed under us\n return;\n }\n const f: Frame = { tick, commands };\n this.lastFrameTick = tick;\n this.record(f);\n this.queue.push(f);\n if (this.queue.length > this.hub.tickRate * 3) this.requestSnapshot();\n }\n\n /** Joiner asks the host for a snapshot (far behind, gap, silent host). Throttled while one is outstanding. */\n requestSnapshot(): void {\n const now = this.hub.clock.now();\n if (this.awaitingSnapshot && now - this.lastSnapshotReq < 2000) return;\n this.lastSnapshotReq = now;\n this.awaitingSnapshot = true;\n this.queue = [];\n this.hub.sendToHost({ type: \"snapshot_req\", sinceTick: this.lastFrameTick });\n }\n\n /** 3 s without frames: ask the host for a snapshot without discarding anything (a live host answers). */\n pokeHost(): void {\n this.hub.sendToHost({ type: \"snapshot_req\", sinceTick: this.lastFrameTick });\n }\n\n private async applySnapshot(ps: { tick: number; total: number; flags: number }, payload: Uint8Array): Promise<void> {\n try {\n let bytes = payload;\n if (ps.flags & 1) bytes = await inflate(payload);\n if (!this.restore) throw new Error(\"lockstep.restore is not set; cannot apply a snapshot\");\n // Restore first: if the game's restore() throws, our bookkeeping must not have moved.\n this.restore(ps.tick, bytes);\n this.queue = [];\n this.history = [];\n this.lastFrameTick = ps.tick;\n this.consumedTick = ps.tick;\n this.awaitingSnapshot = false;\n this.restoreFailures = 0;\n this.markStarted();\n this.lastFrameRx = this.hub.clock.now();\n this.emit(\"restored\", ps.tick);\n } catch (e) {\n this.hub.reportError(e instanceof Error ? e : new Error(String(e)));\n // The game's state is unknown after a failed restore: keep our tick where it was, drop frames, ask again.\n if (++this.restoreFailures <= MAX_RESTORE_RETRIES) {\n this.awaitingSnapshot = false;\n this.lastSnapshotReq = -Infinity;\n this.requestSnapshot();\n } else {\n this.hub.reportError(new Error(`snapshot restore failed ${this.restoreFailures} times; giving up`));\n }\n }\n }\n\n // ---- host: hello, hashes, snapshots -----------------------------------------------------------------------------\n\n /** Host: a peer said hi (welcome_dc and chat_history already sent). Decide: nothing, replay, or snapshot. */\n onPeerHi(peerId: number, lastTick: number): void {\n this.lastHashRx.set(peerId, this.hub.clock.now());\n const hostTick = this.lastFrameTick;\n const first = this.history.length ? this.history[0].tick : hostTick + 1;\n if (lastTick < 0) {\n // fresh joiner: initial state if no game is running, otherwise a snapshot\n if (this._started && hostTick > 0) void this.sendSnapshot(peerId);\n else this.ready.add(peerId);\n return;\n }\n if (lastTick <= hostTick && lastTick + 1 >= first) {\n // The peer's timeline is a prefix of ours: replay what it lacks, then go live.\n for (const f of this.history) {\n if (f.tick > lastTick) this.hub.sendTo(peerId, { type: \"frame\", tick: f.tick, commands: f.commands }, 0);\n }\n this.ready.add(peerId);\n } else {\n void this.sendSnapshot(peerId);\n }\n }\n\n private onPeerHash(peerId: number, tick: number, hash: bigint): void {\n this.lastHashRx.set(peerId, this.hub.clock.now());\n if (this.isStale(peerId, tick)) return;\n const own = this.ownHashes.get(tick);\n if (own === undefined) {\n let m = this.peerHashes.get(tick);\n if (!m) this.peerHashes.set(tick, (m = new Map()));\n m.set(peerId, hash);\n this.prune(this.peerHashes);\n return;\n }\n this.compare(peerId, tick, own, hash);\n }\n\n private compare(peerId: number, tick: number, hostHash: bigint, yourHash: bigint): void {\n if (hostHash === yourHash) return;\n if (this.isStale(peerId, tick)) return;\n if (!this.ready.has(peerId) || this.snapshotting.has(peerId)) return; // already being resynced\n const n = (this.desyncs.get(peerId) ?? 0) + 1;\n this.desyncs.set(peerId, n);\n if (n >= MAX_DESYNCS) {\n this.hub.dropPeer(peerId, \"desync\");\n return;\n }\n this.hub.sendTo(peerId, { type: \"desync\", tick, hostHash, yourHash }, 0);\n void this.sendSnapshot(peerId, tick);\n }\n\n /** Hashes computed before a peer restored its snapshot (ticks after the strike, up to what we had emitted) are not evidence. */\n private isStale(peerId: number, tick: number): boolean {\n const w = this.staleUpTo.get(peerId);\n return !!w && tick > w.from && tick <= w.to;\n }\n\n private async sendSnapshot(peerId: number, strikeTick = Infinity): Promise<void> {\n if (this.snapshotting.has(peerId)) return;\n if (!this.snapshot) {\n this.hub.reportError(new Error(\"lockstep.snapshot is not set; cannot serve a snapshot\"));\n return;\n }\n this.snapshotting.add(peerId);\n this.ready.delete(peerId); // live frames now flow only through the replay below\n try {\n const S = this.consumedTick;\n const raw = this.snapshot();\n let payload = raw;\n let flags = 0;\n if (raw.length >= SNAPSHOT_COMPRESS_MIN && deflateAvailable()) {\n payload = await deflate(raw);\n flags = 1;\n }\n if (!this.snapshotting.has(peerId)) return; // peer left while compressing\n // header + one contiguous chunk set + frames S+1... with nothing else in between\n this.hub.sendTo(peerId, { type: \"snapshot\", tick: S, total: payload.length, flags }, 0);\n for (const c of splitChunks(this.hub.nextChunkId(), payload, this.chunkSize)) this.hub.sendTo(peerId, c, 0);\n for (const f of this.history) {\n if (f.tick > S) this.hub.sendTo(peerId, { type: \"frame\", tick: f.tick, commands: f.commands }, 0);\n }\n if (strikeTick !== Infinity) this.staleUpTo.set(peerId, { from: strikeTick, to: this.lastFrameTick });\n else this.staleUpTo.delete(peerId);\n for (const m of this.peerHashes.values()) m.delete(peerId);\n this.ready.add(peerId);\n this.lastHashRx.set(peerId, this.hub.clock.now());\n } catch (e) {\n this.hub.reportError(e instanceof Error ? e : new Error(String(e)));\n } finally {\n this.snapshotting.delete(peerId);\n }\n }\n\n private prune<T>(m: Map<number, T>): void {\n if (m.size <= 64) return;\n const keys = [...m.keys()].sort((a, b) => a - b);\n for (const k of keys.slice(0, keys.length - 64)) m.delete(k);\n }\n\n // ---- joiner watchdog --------------------------------------------------------------------------------------------\n\n /** Joiner: ms since the last frame from the host (only meaningful once in game). */\n get msSinceFrame(): number {\n return this.hub.clock.now() - this.lastFrameRx;\n }\n\n /** True when a started joiner has heard no frames for 3 s: the Lobby then runs the host-loss rule (s.9). */\n hostSilent(): boolean {\n return !this.hosting && this._started && this.msSinceFrame > HOST_SILENCE_MS;\n }\n\n /** Reset the silence timer, e.g. after the link to a new host opened. */\n touch(): void {\n this.lastFrameRx = this.hub.clock.now();\n }\n\n hiMessage(peerId: number): Message {\n return { type: \"hi\", proto: PROTO, peerId, lastTick: this.lastFrameTick };\n }\n\n get resyncCount(): Map<number, number> {\n return this.desyncs;\n }\n}\n"]}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Host migration rule (DESIGN s.9). Pure functions so the rule is testable without any network.
3
+ *
4
+ * - New host = lowest peerId still connected.
5
+ * - Two claims: higher epoch wins; equal epoch -> lower peerId wins; the loser demotes itself.
6
+ */
7
+ export interface Claim {
8
+ epoch: number;
9
+ hostId: number;
10
+ }
11
+ /** Lowest peerId among `connected`, skipping the failed host. Null when nobody is left. */
12
+ export declare function pickNewHost(connected: Iterable<number>, failedHost: number): number | null;
13
+ /** True when claim `a` beats claim `b`. */
14
+ export declare function claimWins(a: Claim, b: Claim): boolean;
15
+ /** Epochs are u16 on the wire; compare with wraparound so a long-lived lobby never sticks. */
16
+ export declare function epochAfter(a: number, b: number): boolean;
17
+ export declare function claimWinsWrapped(a: Claim, b: Claim): boolean;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Host migration rule (DESIGN s.9). Pure functions so the rule is testable without any network.
3
+ *
4
+ * - New host = lowest peerId still connected.
5
+ * - Two claims: higher epoch wins; equal epoch -> lower peerId wins; the loser demotes itself.
6
+ */
7
+ /** Lowest peerId among `connected`, skipping the failed host. Null when nobody is left. */
8
+ export function pickNewHost(connected, failedHost) {
9
+ let best = null;
10
+ for (const id of connected) {
11
+ if (id === failedHost)
12
+ continue;
13
+ if (best === null || id < best)
14
+ best = id;
15
+ }
16
+ return best;
17
+ }
18
+ /** True when claim `a` beats claim `b`. */
19
+ export function claimWins(a, b) {
20
+ if (a.epoch !== b.epoch)
21
+ return a.epoch > b.epoch;
22
+ return a.hostId < b.hostId;
23
+ }
24
+ /** Epochs are u16 on the wire; compare with wraparound so a long-lived lobby never sticks. */
25
+ export function epochAfter(a, b) {
26
+ const d = (a - b) & 0xffff;
27
+ return d !== 0 && d < 0x8000;
28
+ }
29
+ export function claimWinsWrapped(a, b) {
30
+ if (a.epoch !== b.epoch)
31
+ return epochAfter(a.epoch, b.epoch);
32
+ return a.hostId < b.hostId;
33
+ }
34
+ //# sourceMappingURL=migration.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migration.js","sourceRoot":"","sources":["../src/migration.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH,2FAA2F;AAC3F,MAAM,UAAU,WAAW,CAAC,SAA2B,EAAE,UAAkB;IACzE,IAAI,IAAI,GAAkB,IAAI,CAAC;IAC/B,KAAK,MAAM,EAAE,IAAI,SAAS,EAAE,CAAC;QAC3B,IAAI,EAAE,KAAK,UAAU;YAAE,SAAS;QAChC,IAAI,IAAI,KAAK,IAAI,IAAI,EAAE,GAAG,IAAI;YAAE,IAAI,GAAG,EAAE,CAAC;IAC5C,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,2CAA2C;AAC3C,MAAM,UAAU,SAAS,CAAC,CAAQ,EAAE,CAAQ;IAC1C,IAAI,CAAC,CAAC,KAAK,KAAK,CAAC,CAAC,KAAK;QAAE,OAAO,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC;IAClD,OAAO,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC;AAC7B,CAAC;AAED,8FAA8F;AAC9F,MAAM,UAAU,UAAU,CAAC,CAAS,EAAE,CAAS;IAC7C,MAAM,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,MAAM,CAAC;IAC3B,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC;AAC/B,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,CAAQ,EAAE,CAAQ;IACjD,IAAI,CAAC,CAAC,KAAK,KAAK,CAAC,CAAC,KAAK;QAAE,OAAO,UAAU,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC;IAC7D,OAAO,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC;AAC7B,CAAC","sourcesContent":["/**\n * Host migration rule (DESIGN s.9). Pure functions so the rule is testable without any network.\n *\n * - New host = lowest peerId still connected.\n * - Two claims: higher epoch wins; equal epoch -> lower peerId wins; the loser demotes itself.\n */\n\nexport interface Claim {\n epoch: number;\n hostId: number;\n}\n\n/** Lowest peerId among `connected`, skipping the failed host. Null when nobody is left. */\nexport function pickNewHost(connected: Iterable<number>, failedHost: number): number | null {\n let best: number | null = null;\n for (const id of connected) {\n if (id === failedHost) continue;\n if (best === null || id < best) best = id;\n }\n return best;\n}\n\n/** True when claim `a` beats claim `b`. */\nexport function claimWins(a: Claim, b: Claim): boolean {\n if (a.epoch !== b.epoch) return a.epoch > b.epoch;\n return a.hostId < b.hostId;\n}\n\n/** Epochs are u16 on the wire; compare with wraparound so a long-lived lobby never sticks. */\nexport function epochAfter(a: number, b: number): boolean {\n const d = (a - b) & 0xffff;\n return d !== 0 && d < 0x8000;\n}\n\nexport function claimWinsWrapped(a: Claim, b: Claim): boolean {\n if (a.epoch !== b.epoch) return epochAfter(a.epoch, b.epoch);\n return a.hostId < b.hostId;\n}\n"]}