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
package/dist/lobby.js ADDED
@@ -0,0 +1,840 @@
1
+ import { Chat } from "./chat.js";
2
+ import { CodecError, MAX_FRAME_BYTES, MODE_LOCKSTEP, MODE_RAW, PROTO, decodeMessage, encodeMessage, } from "./codec.js";
3
+ import { Reassembler, splitChunks } from "./chunking.js";
4
+ import { Emitter } from "./emitter.js";
5
+ import { Lockstep } from "./lockstep.js";
6
+ import { claimWinsWrapped, pickNewHost } from "./migration.js";
7
+ import { OneLobbyError } from "./signaling.js";
8
+ import { Voice } from "./voice.js";
9
+ import { hostConnections, joinerSocket } from "./socket.js";
10
+ /** The smallest `peerTimeoutMs` honoured: three missed pings. */
11
+ export const MIN_PEER_TIMEOUT_MS = 6000;
12
+ /** The name shown for a peer that chose none: "Player N", N = its seat (peer id, join order). */
13
+ export function seatName(p) {
14
+ const n = typeof p.name === "string" ? p.name.trim() : "";
15
+ // "Player" is onelobby-server's stand-in for an empty name
16
+ if (n && n !== "Player")
17
+ return p;
18
+ return { ...p, name: `Player ${p.peerId}` };
19
+ }
20
+ function hasRelay(ice) {
21
+ return ice.some((s) => (Array.isArray(s.urls) ? s.urls : [s.urls]).some((u) => /^turns?:/i.test(String(u))));
22
+ }
23
+ const HOST_WATCHDOG_MS = 250;
24
+ const PING_EVERY_MS = 2000;
25
+ /** One joined lobby: roster, star of peer links, and the lockstep/chat/voice engines riding on them. */
26
+ export class Lobby extends Emitter {
27
+ code;
28
+ mode;
29
+ tickRate;
30
+ lockstep;
31
+ chat;
32
+ voice;
33
+ localPeerId;
34
+ hostPeerId;
35
+ phase;
36
+ meta;
37
+ resumeToken;
38
+ /** Last measured round trip to the host in ms (joiners), or 0. */
39
+ rtt = 0;
40
+ /**
41
+ * Direct-IP host only (onelobby/direct): the code friends paste to join, e.g. "6B01-RGFE-P2B" (docs/direct-ip.md
42
+ * s.2a). Show it with joinCodeKind. Empty when the machine has no IPv4 address, and on joiners.
43
+ */
44
+ joinCode = "";
45
+ /** "internet": the router forwarded the port and the code carries its public address; "same_network": the LAN address. */
46
+ joinCodeKind = "none";
47
+ /** Direct-IP host only: this machine's addresses with the port, for an "Advanced" fold-out. */
48
+ directAddresses = [];
49
+ /** Direct-IP host only: how the router port-open went ("UPnP", "no UPnP gateway answered; ...", "off"). */
50
+ portMapping = "";
51
+ signaling;
52
+ cfg;
53
+ roster = new Map();
54
+ links = new Map();
55
+ connected = new Set(); // host: peers that said hi
56
+ hiSentTo = new Set();
57
+ epoch = 0;
58
+ ended = false;
59
+ serverUp = true;
60
+ cancelWatchdog = null;
61
+ cancelPing = null;
62
+ pingN = 0;
63
+ chunkId = 1;
64
+ reassemblers = new Map();
65
+ hub;
66
+ outbox = new Map();
67
+ markReady = () => { };
68
+ failReady = () => { };
69
+ isReady = false;
70
+ cancelReadyTimer = null;
71
+ relayRetry = false;
72
+ /** clock.now() of the last frame from each peer (peerTimeoutMs). */
73
+ lastHeard = new Map();
74
+ /** Host: per-peer fixed window for rateLimit. */
75
+ rate = new Map();
76
+ /**
77
+ * Resolves when this peer can exchange data: immediately for the host, once the link to the host is open for a
78
+ * joiner. Rejects with OneLobbyError("ice_failed") when the joiner cannot reach the host in `readyTimeoutMs`
79
+ * (or with "closed"/"kicked" when the lobby ends first).
80
+ */
81
+ ready = new Promise((res, rej) => {
82
+ this.markReady = () => {
83
+ if (this.isReady)
84
+ return;
85
+ this.isReady = true;
86
+ this.cancelReadyTimer?.();
87
+ this.cancelReadyTimer = null;
88
+ res();
89
+ };
90
+ this.failReady = rej;
91
+ });
92
+ constructor(cfg) {
93
+ super();
94
+ this.cfg = cfg;
95
+ this.signaling = cfg.signaling;
96
+ const st = cfg.state;
97
+ this.code = st.code;
98
+ this.mode = st.mode;
99
+ this.tickRate = st.tickRate;
100
+ this.localPeerId = st.you;
101
+ this.hostPeerId = st.hostId;
102
+ this.phase = st.phase;
103
+ this.meta = { ...st.meta };
104
+ this.resumeToken = st.resumeToken;
105
+ for (const p of st.peers)
106
+ this.roster.set(p.peerId, seatName(p));
107
+ this.ready.catch(() => { }); // a rejection is also reported through errorSink; awaiting `ready` stays optional
108
+ const self = this;
109
+ this.hub = {
110
+ get clock() { return self.cfg.clock; },
111
+ get localPeerId() { return self.localPeerId; },
112
+ get hostPeerId() { return self.hostPeerId; },
113
+ get isHost() { return self.isHost; },
114
+ get tickRate() { return self.tickRate; },
115
+ connectedPeers: () => self.connectedPeerIds(),
116
+ sendTo: (peerId, msg, route, channel) => self.sendTo(peerId, msg, route ?? 0, channel),
117
+ sendToHost: (msg, route, channel) => {
118
+ if (!self.isHost)
119
+ self.sendTo(self.hostPeerId, msg, route ?? 0, channel);
120
+ },
121
+ dropPeer: (peerId, reason) => self.dropPeer(peerId, reason),
122
+ reportError: (e) => self.reportError(e),
123
+ nextChunkId: () => self.chunkId++ >>> 0,
124
+ };
125
+ this.lockstep = new Lockstep(this.hub, { hashEveryTicks: cfg.hashEveryTicks });
126
+ this.chat = new Chat(this.hub, cfg.wall, (id) => this.voice.isMutedPeer(id));
127
+ this.voice = new Voice(this.hub, cfg.voice);
128
+ this.lockstep.on("started", () => this.setPhase("in_game"));
129
+ this.lockstep.on("error", (e) => this.reportError(e));
130
+ this.signaling.onMessage = (m) => this.onSignaling(m);
131
+ this.signaling.onClose = () => this.onServerClosed();
132
+ if (this.isHost)
133
+ this.markReady();
134
+ else
135
+ this.armReadyTimer();
136
+ if (this.isHost && this.mode === "lockstep") {
137
+ if (this.phase === "in_game")
138
+ this.lockstep.hostStart();
139
+ else
140
+ this.lockstep.hostIdle();
141
+ }
142
+ this.cancelWatchdog = cfg.clock.setInterval(() => this.watchdog(), HOST_WATCHDOG_MS);
143
+ this.cancelPing = cfg.clock.setInterval(() => this.pingHost(), PING_EVERY_MS);
144
+ }
145
+ // ---- state -----------------------------------------------------------------------------------------------------
146
+ get isHost() {
147
+ return this.localPeerId === this.hostPeerId;
148
+ }
149
+ get peers() {
150
+ return [...this.roster.values()].sort((a, b) => a.peerId - b.peerId);
151
+ }
152
+ peer(peerId) {
153
+ return this.roster.get(peerId);
154
+ }
155
+ /** Peers whose link is open and handshake done (host: the ready joiners; joiner: the host once ready). */
156
+ get connectedPeers() {
157
+ return this.connectedPeerIds();
158
+ }
159
+ /**
160
+ * Joiner: the link to the host as a WebSocket-shaped object (text by default), so WebSocket client code runs
161
+ * unchanged. It opens when `ready` resolves and closes when the session ends (`host_left` gives code 1001).
162
+ */
163
+ asWebSocket(opts) {
164
+ return joinerSocket(this, opts);
165
+ }
166
+ /** Host: `handler(conn)` once per joiner (a WebSocket-shaped connection), the server-side mirror of asWebSocket(). */
167
+ onConnection(handler, opts) {
168
+ return hostConnections(this, handler, opts);
169
+ }
170
+ set snapshotChunkSize(n) {
171
+ this.lockstep.chunkSize = n;
172
+ }
173
+ /** Current migration epoch (0 until the first host change). */
174
+ get hostEpoch() {
175
+ return this.epoch;
176
+ }
177
+ /** @internal Host role: create the link to `peerId` and start negotiating (adapters and tests). */
178
+ _offer(peerId) {
179
+ this.offerTo(peerId);
180
+ return this.links.get(peerId);
181
+ }
182
+ /** @internal Joiner role: create the answering link to `peerId` (adapters and tests). */
183
+ _answer(peerId) {
184
+ return this.makeLink(peerId);
185
+ }
186
+ /** @internal Called by OneLobby right after construction: the host waits for joiners, a joiner waits for the host's offer. */
187
+ _begin() {
188
+ /* joiners are passive until the host offers; hosts create links on peer_joined */
189
+ }
190
+ // ---- game-facing actions ---------------------------------------------------------------------------------------
191
+ async start() {
192
+ if (!this.isHost)
193
+ throw new Error("only the host can start the game");
194
+ if (this.phase === "in_game")
195
+ return;
196
+ this.signaling.send({ t: "phase", phase: "in_game" });
197
+ this.setPhase("in_game");
198
+ if (this.mode === "lockstep")
199
+ this.lockstep.hostStart();
200
+ }
201
+ async setMeta(meta) {
202
+ if (!this.isHost)
203
+ throw new Error("only the host can set metadata");
204
+ this.meta = { ...meta };
205
+ this.signaling.send({ t: "meta", meta });
206
+ this.emit("metaChanged", this.meta);
207
+ }
208
+ async kick(peerId) {
209
+ if (!this.isHost)
210
+ throw new Error("only the host can kick");
211
+ this.signaling.send({ t: "kick", peerId });
212
+ this.removePeer(peerId, "kicked");
213
+ }
214
+ async approve(peerId, ok) {
215
+ if (!this.isHost)
216
+ throw new Error("only the host can approve joins");
217
+ this.signaling.send({ t: "approve", peerId, ok });
218
+ }
219
+ async leave() {
220
+ if (this.ended)
221
+ return;
222
+ this.signaling.send({ t: "leave" });
223
+ this.teardown("left");
224
+ }
225
+ /** raw mode (and any extra game data): to one peer. */
226
+ send(peerId, bytes, opts = {}) {
227
+ const channel = opts.channel ?? "reliable";
228
+ if (this.isHost)
229
+ this.sendTo(peerId, { type: "raw", bytes }, this.localPeerId, channel);
230
+ else
231
+ this.sendTo(this.hostPeerId, { type: "raw", bytes }, peerId, channel);
232
+ }
233
+ /** To everyone else in the lobby. */
234
+ broadcast(bytes, opts = {}) {
235
+ const channel = opts.channel ?? "reliable";
236
+ if (this.isHost) {
237
+ // peers whose channels are not open yet get reliable broadcasts queued and flushed in order
238
+ for (const p of this.roster.keys())
239
+ if (p !== this.localPeerId)
240
+ this.sendTo(p, { type: "raw", bytes }, this.localPeerId, channel);
241
+ }
242
+ else {
243
+ this.sendTo(this.hostPeerId, { type: "raw", bytes }, 0, channel);
244
+ }
245
+ }
246
+ // ---- sending ---------------------------------------------------------------------------------------------------
247
+ channelFor(msg) {
248
+ if (msg.type === "voice")
249
+ return "voice";
250
+ if (msg.type === "ping" || msg.type === "pong")
251
+ return "fast";
252
+ return "reliable";
253
+ }
254
+ sendTo(peerId, msg, route, channel) {
255
+ const link = this.links.get(peerId);
256
+ const ch0 = channel ?? this.channelFor(msg);
257
+ const queueable = msg.type === "raw" || msg.type === "chat" || msg.type === "cmd";
258
+ // game data to a host-side peer waits for its `hi`; protocol messages only need the link
259
+ const ready = !!link?.isOpen && (!queueable || !this.isHost || this.connected.has(peerId));
260
+ if (!ready) {
261
+ // Reliable game data for a peer that is in the roster (or is the host) but not connected yet waits for its handshake.
262
+ const known = this.roster.has(peerId) || peerId === this.hostPeerId;
263
+ if (queueable && ch0 === "reliable" && known && !this.ended) {
264
+ let q = this.outbox.get(peerId);
265
+ if (!q)
266
+ this.outbox.set(peerId, (q = []));
267
+ if (q.length >= 1000)
268
+ q.shift();
269
+ q.push({ msg, route, channel });
270
+ }
271
+ return;
272
+ }
273
+ if (!link)
274
+ return;
275
+ try {
276
+ const ch = ch0;
277
+ let bytes;
278
+ try {
279
+ bytes = encodeMessage(msg, route);
280
+ }
281
+ catch (e) {
282
+ if (!(e instanceof CodecError) || e.code !== "too_long")
283
+ throw e;
284
+ bytes = encodeMessage(msg, route, true);
285
+ }
286
+ if (bytes.length > MAX_FRAME_BYTES) {
287
+ // Oversized frame: send the whole inner frame as a chunk set (spec/binary.md "Large non-snapshot frames").
288
+ for (const c of splitChunks(this.chunkId++ >>> 0, bytes))
289
+ link.send(ch, encodeMessage(c, route));
290
+ }
291
+ else {
292
+ link.send(ch, bytes);
293
+ }
294
+ }
295
+ catch (e) {
296
+ this.reportError(e instanceof Error ? e : new Error(String(e)));
297
+ }
298
+ }
299
+ connectedPeerIds() {
300
+ if (this.isHost)
301
+ return [...this.connected].filter((id) => this.links.get(id)?.isOpen).sort((a, b) => a - b);
302
+ const l = this.links.get(this.hostPeerId);
303
+ return l?.isOpen ? [this.hostPeerId] : [];
304
+ }
305
+ reportError(e) {
306
+ this.errorSink?.(e);
307
+ }
308
+ /** @internal set by OneLobby so lobby errors reach `kit.on("error")`. */
309
+ errorSink = null;
310
+ /** @internal set by OneLobby: the server issued a new resume token (rememberSeat). */
311
+ seatSink = null;
312
+ // ---- joiner connect timeout -------------------------------------------------------------------------------------
313
+ armReadyTimer() {
314
+ const total = this.cfg.readyTimeoutMs ?? 0;
315
+ if (!(total > 0))
316
+ return;
317
+ const canRelay = !this.cfg.relayOnly && hasRelay(this.cfg.ice);
318
+ const first = canRelay ? Math.ceil(total / 2) : total;
319
+ this.cancelReadyTimer = this.cfg.clock.setTimeout(() => {
320
+ if (this.isReady || this.ended)
321
+ return;
322
+ if (canRelay)
323
+ this.retryRelayed(total - first);
324
+ else
325
+ this.giveUpConnecting();
326
+ }, first);
327
+ }
328
+ /** The direct attempt stalled: dial the host ourselves through the TURN relay (a host answers any offer). */
329
+ retryRelayed(budget) {
330
+ this.relayRetry = true;
331
+ const link = this.makeLink(this.hostPeerId, true);
332
+ link.startOffer();
333
+ this.cancelReadyTimer = this.cfg.clock.setTimeout(() => {
334
+ if (!this.isReady && !this.ended)
335
+ this.giveUpConnecting();
336
+ }, budget);
337
+ }
338
+ giveUpConnecting() {
339
+ const err = new OneLobbyError("ice_failed", `could not open a connection to the host${this.relayRetry ? " (direct or relayed)" : ""}`);
340
+ this.failReady(err);
341
+ this.reportError(err);
342
+ if (this.ended)
343
+ return;
344
+ this.signaling.send({ t: "leave" });
345
+ this.teardown("ice_failed");
346
+ }
347
+ // ---- links -----------------------------------------------------------------------------------------------------
348
+ makeLink(peerId, relayOnly = this.cfg.relayOnly) {
349
+ const old = this.links.get(peerId);
350
+ if (old) {
351
+ old.onClose = null;
352
+ old.onMessage = null;
353
+ old.close();
354
+ }
355
+ const link = this.cfg.links.create({ remoteId: peerId, iceServers: this.cfg.ice, relayOnly });
356
+ this.links.set(peerId, link);
357
+ link.onSignal = (s) => this.signaling.send({ t: "signal", to: peerId, ...s });
358
+ link.onOpen = () => this.onLinkOpen(peerId, link);
359
+ link.onClose = (reason) => this.onLinkClosed(peerId, link, reason);
360
+ link.onMessage = (ch, bytes) => this.onData(peerId, ch, bytes);
361
+ link.onPath = (path) => this.signaling.send({ t: "path", path });
362
+ return link;
363
+ }
364
+ /** Host side: offer to a joiner. */
365
+ offerTo(peerId) {
366
+ const link = this.makeLink(peerId);
367
+ link.startOffer();
368
+ }
369
+ onLinkOpen(peerId, link) {
370
+ if (this.isHost) {
371
+ // Joiner speaks first (`hi`). After a migration the new host announces itself so the joiner knows who won.
372
+ if (this.epoch > 0)
373
+ this.sendTo(peerId, { type: "host_claim", epoch: this.epoch, hostId: this.localPeerId, tick: this.lockstep.lastTick }, 0);
374
+ return;
375
+ }
376
+ if (peerId === this.hostPeerId)
377
+ this.sendHi(link);
378
+ // else: an unexpected offerer; wait for its host_claim before trusting it
379
+ }
380
+ sendHi(link) {
381
+ if (this.hiSentTo.has(link))
382
+ return;
383
+ this.hiSentTo.add(link);
384
+ this.lockstep.touch();
385
+ link.send("reliable", encodeMessage(this.lockstep.hiMessage(this.localPeerId)));
386
+ this.markReady();
387
+ this.emit("ready");
388
+ this.emit("peerReady", this.roster.get(link.remoteId) ?? { peerId: link.remoteId, name: "" });
389
+ this.flush(link.remoteId);
390
+ }
391
+ onLinkClosed(peerId, link, _reason) {
392
+ if (this.links.get(peerId) !== link)
393
+ return; // replaced
394
+ this.links.delete(peerId);
395
+ this.hiSentTo.delete(link);
396
+ if (this.ended)
397
+ return;
398
+ if (this.isHost) {
399
+ this.connected.delete(peerId);
400
+ this.lockstep.onPeerLeft(peerId);
401
+ this.voice.onPeerLeft(peerId);
402
+ // The server announces the departure; if it is gone, drop the peer ourselves.
403
+ if (!this.serverUp && this.roster.has(peerId))
404
+ this.removePeer(peerId, "timeout");
405
+ }
406
+ else if (peerId === this.hostPeerId) {
407
+ // Never connected yet (ICE failed): not a host loss. The ready timer retries over the relay or gives up.
408
+ if (!this.isReady && this.cancelReadyTimer)
409
+ return;
410
+ this.handleHostLost("link closed");
411
+ }
412
+ }
413
+ // ---- data channel dispatch -------------------------------------------------------------------------------------
414
+ onData(from, channel, bytes, reassembled = false) {
415
+ let d;
416
+ try {
417
+ d = decodeMessage(bytes, reassembled);
418
+ }
419
+ catch (e) {
420
+ this.reportError(e instanceof Error ? e : new Error(String(e)));
421
+ return;
422
+ }
423
+ if (!d)
424
+ return;
425
+ if (!reassembled)
426
+ this.lastHeard.set(from, this.cfg.clock.now());
427
+ const { msg, route } = d;
428
+ switch (msg.type) {
429
+ case "hi":
430
+ if (this.isHost)
431
+ this.onHi(from, msg.lastTick);
432
+ break;
433
+ case "welcome_dc":
434
+ if (!this.isHost) {
435
+ if (msg.hostId !== this.hostPeerId)
436
+ this.setHost(msg.hostId);
437
+ this.lockstep.touch();
438
+ }
439
+ break;
440
+ case "cmd":
441
+ case "frame":
442
+ case "hash":
443
+ case "desync":
444
+ case "snapshot_req":
445
+ case "snapshot":
446
+ if (from === this.hostPeerId || this.isHost)
447
+ this.lockstep.handle(from, msg);
448
+ break;
449
+ case "chunk": {
450
+ if (reassembled)
451
+ break; // a chunk set never nests another
452
+ let r = this.reassemblers.get(from);
453
+ if (!r)
454
+ this.reassemblers.set(from, (r = new Reassembler()));
455
+ let whole;
456
+ try {
457
+ whole = r.push(msg);
458
+ }
459
+ catch (e) {
460
+ this.reportError(e instanceof Error ? e : new Error(String(e)));
461
+ break;
462
+ }
463
+ if (!whole)
464
+ break;
465
+ if (this.lockstep.snapshotPending && from === this.hostPeerId)
466
+ this.lockstep.onSnapshotPayload(whole);
467
+ else
468
+ this.onData(from, channel, whole, true); // an oversized inner frame
469
+ break;
470
+ }
471
+ case "chat":
472
+ case "chat_history":
473
+ case "chat_reject":
474
+ this.chat.handle(from, route, msg);
475
+ break;
476
+ case "voice":
477
+ if (channel === "voice")
478
+ this.voice.handle(from, route, msg); // a voice frame on another channel is ignored
479
+ break;
480
+ case "raw":
481
+ this.onRaw(from, route, msg.bytes, channel);
482
+ break;
483
+ case "ping":
484
+ this.sendTo(from, { type: "pong", n: msg.n, t: msg.t }, 0, "fast");
485
+ break;
486
+ case "pong":
487
+ this.rtt = Math.max(0, (this.cfg.clock.now() >>> 0) - msg.t);
488
+ break;
489
+ case "host_claim":
490
+ this.onHostClaim(from, msg.epoch, msg.hostId);
491
+ break;
492
+ }
493
+ }
494
+ onHi(from, lastTick) {
495
+ this.connected.add(from);
496
+ this.sendTo(from, {
497
+ type: "welcome_dc",
498
+ // the tick the snapshot will carry (the game's applied tick), so welcome_dc.tick == snapshot.tick even when
499
+ // the game loop has not yet consumed every emitted frame; 0 before the game starts
500
+ tick: this.mode === "lockstep" ? this.lockstep.currentTick : 0,
501
+ tickRate: this.tickRate,
502
+ mode: this.mode === "lockstep" ? MODE_LOCKSTEP : MODE_RAW,
503
+ hostId: this.localPeerId,
504
+ }, 0);
505
+ this.chat.sendHistory(from);
506
+ if (this.mode === "lockstep")
507
+ this.lockstep.onPeerHi(from, lastTick);
508
+ this.emit("peerReady", this.roster.get(from) ?? { peerId: from, name: "" });
509
+ this.flush(from);
510
+ }
511
+ flush(peerId) {
512
+ const q = this.outbox.get(peerId);
513
+ if (!q)
514
+ return;
515
+ this.outbox.delete(peerId);
516
+ for (const m of q)
517
+ this.sendTo(peerId, m.msg, m.route, m.channel);
518
+ }
519
+ onRaw(from, route, bytes, channel) {
520
+ if (!this.isHost) {
521
+ this.emit("message", route, bytes, channel);
522
+ return;
523
+ }
524
+ if (!this.allowRaw(from))
525
+ return;
526
+ if (route === 0 || route === this.localPeerId) {
527
+ this.emit("message", from, bytes, channel);
528
+ }
529
+ if (route === 0) {
530
+ for (const p of this.connectedPeerIds())
531
+ if (p !== from)
532
+ this.sendTo(p, { type: "raw", bytes }, from, channel);
533
+ }
534
+ else if (route !== this.localPeerId) {
535
+ this.sendTo(route, { type: "raw", bytes }, from, channel);
536
+ }
537
+ }
538
+ allowRaw(from) {
539
+ const lim = this.cfg.rateLimit;
540
+ if (!lim || !(lim.messages > 0) || !(lim.perMs > 0))
541
+ return true;
542
+ const now = this.cfg.clock.now();
543
+ let w = this.rate.get(from);
544
+ if (!w || now - w.start >= lim.perMs)
545
+ this.rate.set(from, (w = { start: now, n: 0 }));
546
+ return ++w.n <= lim.messages;
547
+ }
548
+ // ---- migration -------------------------------------------------------------------------------------------------
549
+ setHost(hostId) {
550
+ if (hostId === this.hostPeerId)
551
+ return;
552
+ this.hostPeerId = hostId;
553
+ this.emit("hostChanged", hostId);
554
+ }
555
+ /** The host is gone (link closed, no frames for 3 s, or the server said so): run the s.9 rule. */
556
+ handleHostLost(reason) {
557
+ if (this.isHost || this.ended)
558
+ return;
559
+ if (this.cfg.direct) {
560
+ this.teardown("host_left");
561
+ return;
562
+ }
563
+ const failed = this.hostPeerId;
564
+ const gone = this.roster.get(failed);
565
+ if (gone) {
566
+ this.roster.delete(failed);
567
+ this.emit("peerLeft", gone, reason === "link closed" ? "timeout" : reason);
568
+ }
569
+ const old = this.links.get(failed);
570
+ if (old) {
571
+ old.onClose = null;
572
+ old.onMessage = null;
573
+ old.close();
574
+ this.links.delete(failed);
575
+ this.hiSentTo.delete(old);
576
+ }
577
+ const candidate = pickNewHost([...this.roster.keys(), this.localPeerId], failed) ?? this.localPeerId;
578
+ if (candidate === this.localPeerId)
579
+ this.becomeHost();
580
+ // else: wait for the candidate's host_claim (or the server's host_changed); it offers to us and we answer with hi
581
+ }
582
+ becomeHost() {
583
+ this.epoch = (this.epoch + 1) & 0xffff;
584
+ this.hostPeerId = this.localPeerId;
585
+ this.markReady();
586
+ this.connected.clear();
587
+ this.hiSentTo.clear();
588
+ this.lastHeard.clear(); // joiners spoke to the old host; their clocks start when they say hi to us
589
+ this.lockstep.becomeHost();
590
+ this.emit("hostChanged", this.localPeerId);
591
+ if (this.serverUp)
592
+ this.signaling.send({ t: "host_changed", hostId: this.localPeerId, epoch: this.epoch });
593
+ for (const p of this.roster.keys()) {
594
+ if (p !== this.localPeerId)
595
+ this.offerTo(p);
596
+ }
597
+ }
598
+ onHostClaim(from, epoch, hostId) {
599
+ const mine = { epoch: this.epoch, hostId: this.hostPeerId };
600
+ const theirs = { epoch, hostId };
601
+ if (hostId === this.hostPeerId && epoch === this.epoch && !this.isHost) {
602
+ const l = this.links.get(from);
603
+ if (l)
604
+ this.sendHi(l);
605
+ return;
606
+ }
607
+ if (!claimWinsWrapped(theirs, mine))
608
+ return; // we hold the better claim
609
+ const wasHost = this.isHost;
610
+ this.epoch = epoch;
611
+ if (wasHost) {
612
+ // We lose: demote. Everyone else will be offered to by the winner.
613
+ for (const [id, l] of this.links) {
614
+ if (id === from)
615
+ continue;
616
+ l.onClose = null;
617
+ l.onMessage = null;
618
+ l.close();
619
+ this.links.delete(id);
620
+ }
621
+ this.connected.clear();
622
+ this.lockstep.becomeJoiner();
623
+ }
624
+ this.setHost(hostId);
625
+ const link = this.links.get(from);
626
+ if (link && from === hostId)
627
+ this.sendHi(link);
628
+ }
629
+ // ---- signaling -------------------------------------------------------------------------------------------------
630
+ onSignaling(m) {
631
+ switch (m.t) {
632
+ case "peer_joined": {
633
+ if (!m.peer || this.roster.has(m.peer.peerId))
634
+ return;
635
+ const peer = seatName(m.peer);
636
+ this.roster.set(peer.peerId, peer);
637
+ this.emit("peerJoined", peer);
638
+ if (this.isHost)
639
+ this.offerTo(peer.peerId);
640
+ break;
641
+ }
642
+ case "peer_left":
643
+ this.removePeer(Number(m.peerId), String(m.reason ?? "left"));
644
+ break;
645
+ case "host_changed": {
646
+ if (this.cfg.direct) {
647
+ // the hub promotes a joiner as the host leaves; in direct mode the hub is going with it
648
+ if (!this.isHost)
649
+ this.teardown("host_left");
650
+ break;
651
+ }
652
+ const id = Number(m.hostId);
653
+ if (typeof m.epoch === "number" && m.epoch > this.epoch)
654
+ this.epoch = m.epoch & 0xffff;
655
+ if (id === this.hostPeerId)
656
+ break;
657
+ if (id === this.localPeerId && !this.isHost) {
658
+ const failed = this.hostPeerId;
659
+ this.roster.delete(failed);
660
+ this.becomeHost();
661
+ }
662
+ else {
663
+ this.setHost(id);
664
+ }
665
+ break;
666
+ }
667
+ case "join_request":
668
+ this.emit("joinRequested", seatName(m.peer));
669
+ break;
670
+ case "lobby": {
671
+ const st = m;
672
+ this.meta = { ...st.meta };
673
+ if (st.resumeToken && st.resumeToken !== this.resumeToken) {
674
+ this.resumeToken = st.resumeToken;
675
+ this.seatSink?.(this.resumeToken);
676
+ }
677
+ this.roster = new Map(st.peers.map((p) => [p.peerId, seatName(p)]));
678
+ if (st.hostId !== this.hostPeerId && !this.isHost)
679
+ this.setHost(st.hostId);
680
+ this.setPhase(st.phase);
681
+ break;
682
+ }
683
+ case "meta":
684
+ this.meta = { ...m.meta };
685
+ this.emit("metaChanged", this.meta);
686
+ break;
687
+ case "phase":
688
+ this.setPhase(m.phase);
689
+ break;
690
+ case "signal":
691
+ void this.onSignal(Number(m.from), m);
692
+ break;
693
+ case "error":
694
+ if (m.code === "kicked")
695
+ this.teardown("kicked");
696
+ else
697
+ this.reportError(new OneLobbyError(String(m.code), String(m.msg ?? ""), m.data));
698
+ break;
699
+ }
700
+ }
701
+ async onSignal(from, s) {
702
+ let link = this.links.get(from);
703
+ if (s.sdp?.type === "offer") {
704
+ // We are the answerer; an offer always comes from the (new) host.
705
+ link = this.makeLink(from);
706
+ }
707
+ if (!link)
708
+ return;
709
+ const payload = {};
710
+ if (s.sdp)
711
+ payload.sdp = s.sdp;
712
+ if (s.ice)
713
+ payload.ice = s.ice;
714
+ await link.handleSignal(payload);
715
+ }
716
+ removePeer(peerId, reason) {
717
+ const peer = this.roster.get(peerId);
718
+ if (peerId === this.localPeerId) {
719
+ this.teardown(reason === "kicked" ? "kicked" : reason);
720
+ return;
721
+ }
722
+ if (peer)
723
+ this.roster.delete(peerId);
724
+ const link = this.links.get(peerId);
725
+ if (link && peerId !== this.hostPeerId) {
726
+ link.onClose = null;
727
+ link.onMessage = null;
728
+ link.close();
729
+ this.links.delete(peerId);
730
+ this.hiSentTo.delete(link);
731
+ }
732
+ this.connected.delete(peerId);
733
+ this.outbox.delete(peerId);
734
+ this.lastHeard.delete(peerId);
735
+ this.rate.delete(peerId);
736
+ this.lockstep.onPeerLeft(peerId);
737
+ this.chat.onPeerLeft(peerId);
738
+ this.voice.onPeerLeft(peerId);
739
+ if (peer)
740
+ this.emit("peerLeft", peer, reason);
741
+ if (peerId === this.hostPeerId && !this.isHost) {
742
+ this.handleHostLost(reason);
743
+ }
744
+ }
745
+ dropPeer(peerId, reason) {
746
+ this.signaling.send({ t: "kick", peerId });
747
+ this.removePeer(peerId, reason);
748
+ }
749
+ setPhase(p) {
750
+ if (p !== "lobby" && p !== "in_game")
751
+ return;
752
+ if (this.phase === p)
753
+ return;
754
+ this.phase = p;
755
+ if (p === "in_game" && !this.lockstep.started)
756
+ this.lockstep.markStarted();
757
+ this.emit("phase", p);
758
+ }
759
+ onServerClosed() {
760
+ if (this.ended)
761
+ return;
762
+ if (this.cfg.direct && !this.isHost) {
763
+ this.teardown("host_left");
764
+ return;
765
+ }
766
+ this.serverUp = false;
767
+ this.emit("serverLost");
768
+ // Alone with no peers: nothing keeps this session alive.
769
+ if (this.links.size === 0 && this.roster.size <= 1)
770
+ this.teardown("server_lost");
771
+ }
772
+ // ---- housekeeping ----------------------------------------------------------------------------------------------
773
+ watchdog() {
774
+ if (this.ended)
775
+ return;
776
+ const timeout = this.cfg.peerTimeoutMs ?? 0;
777
+ if (timeout > 0)
778
+ this.dropSilent(Math.max(timeout, MIN_PEER_TIMEOUT_MS));
779
+ if (this.ended || this.isHost)
780
+ return;
781
+ if (this.mode === "lockstep" && this.lockstep.hostSilent()) {
782
+ this.lockstep.touch();
783
+ this.lockstep.pokeHost(); // a live-but-stalled host answers with a snapshot; a dead one is replaced below
784
+ this.handleHostLost("timeout");
785
+ }
786
+ }
787
+ /** peerTimeoutMs: the host drops joiners it has not heard from; a joiner handles a silent host as lost. */
788
+ dropSilent(timeout) {
789
+ const now = this.cfg.clock.now();
790
+ if (this.isHost) {
791
+ for (const id of [...this.connected]) {
792
+ const last = this.lastHeard.get(id);
793
+ if (last !== undefined && now - last > timeout)
794
+ this.removePeer(id, "timeout");
795
+ }
796
+ }
797
+ else if (this.isReady) {
798
+ const last = this.lastHeard.get(this.hostPeerId);
799
+ if (last !== undefined && now - last > timeout) {
800
+ this.lastHeard.delete(this.hostPeerId);
801
+ this.handleHostLost("timeout");
802
+ }
803
+ }
804
+ }
805
+ pingHost() {
806
+ if (this.ended || this.isHost)
807
+ return;
808
+ this.sendTo(this.hostPeerId, { type: "ping", n: ++this.pingN, t: this.cfg.clock.now() >>> 0 }, 0, "fast");
809
+ }
810
+ teardown(reason) {
811
+ if (this.ended)
812
+ return;
813
+ this.ended = true;
814
+ this.cancelReadyTimer?.();
815
+ if (!this.isReady) {
816
+ const code = reason === "ice_failed" || reason === "kicked" ? reason : "closed";
817
+ this.failReady(new OneLobbyError(code, `the lobby ended before connecting (${reason})`));
818
+ }
819
+ this.cancelWatchdog?.();
820
+ this.cancelPing?.();
821
+ this.lockstep.stop();
822
+ this.voice.close();
823
+ for (const l of this.links.values()) {
824
+ l.onClose = null;
825
+ l.onMessage = null;
826
+ l.close();
827
+ }
828
+ this.links.clear();
829
+ this.signaling.onClose = null;
830
+ this.signaling.onMessage = null;
831
+ this.signaling.close();
832
+ this.emit("sessionEnded", reason);
833
+ }
834
+ /** Total in-flight links (diagnostics and tests). */
835
+ get linkCount() {
836
+ return this.links.size;
837
+ }
838
+ }
839
+ export { PROTO };
840
+ //# sourceMappingURL=lobby.js.map