onelobby 0.5.0 → 0.7.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 (109) hide show
  1. package/README.md +2 -2
  2. package/blueprint.schema.json +8 -1
  3. package/dist/blueprint.d.ts +4 -0
  4. package/dist/blueprint.js +5 -0
  5. package/dist/blueprint.js.map +1 -1
  6. package/dist/codec.d.ts +23 -0
  7. package/dist/codec.js +28 -0
  8. package/dist/codec.js.map +1 -1
  9. package/dist/direct/browser-host.d.ts +46 -0
  10. package/dist/direct/browser-host.js +93 -0
  11. package/dist/direct/browser-host.js.map +1 -0
  12. package/dist/direct/hub-host.d.ts +34 -23
  13. package/dist/direct/hub-host.js +75 -59
  14. package/dist/direct/hub-host.js.map +1 -1
  15. package/dist/direct/hub-local.d.ts +22 -0
  16. package/dist/direct/hub-local.js +46 -0
  17. package/dist/direct/hub-local.js.map +1 -0
  18. package/dist/direct/hub.d.ts +39 -10
  19. package/dist/direct/hub.js +134 -24
  20. package/dist/direct/hub.js.map +1 -1
  21. package/dist/direct/index.d.ts +36 -3
  22. package/dist/direct/index.js +146 -20
  23. package/dist/direct/index.js.map +1 -1
  24. package/dist/direct/metrics.d.ts +12 -0
  25. package/dist/direct/metrics.js +45 -0
  26. package/dist/direct/metrics.js.map +1 -0
  27. package/dist/direct/portmap.d.ts +9 -2
  28. package/dist/direct/portmap.js +7 -2
  29. package/dist/direct/portmap.js.map +1 -1
  30. package/dist/direct/sha1.d.ts +16 -0
  31. package/dist/direct/sha1.js +111 -0
  32. package/dist/direct/sha1.js.map +1 -0
  33. package/dist/direct-address.d.ts +2 -2
  34. package/dist/direct-address.js.map +1 -1
  35. package/dist/friendly.d.ts +24 -0
  36. package/dist/friendly.js +30 -0
  37. package/dist/friendly.js.map +1 -1
  38. package/dist/index.d.ts +8 -4
  39. package/dist/index.js +6 -4
  40. package/dist/index.js.map +1 -1
  41. package/dist/internals.d.ts +9 -0
  42. package/dist/internals.js +9 -0
  43. package/dist/internals.js.map +1 -1
  44. package/dist/join-code-kind.d.ts +10 -0
  45. package/dist/join-code-kind.js +10 -0
  46. package/dist/join-code-kind.js.map +1 -0
  47. package/dist/joincode.d.ts +10 -1
  48. package/dist/joincode.js +23 -3
  49. package/dist/joincode.js.map +1 -1
  50. package/dist/lobby.d.ts +94 -5
  51. package/dist/lobby.js +177 -18
  52. package/dist/lobby.js.map +1 -1
  53. package/dist/lockstep.d.ts +8 -0
  54. package/dist/lockstep.js +23 -2
  55. package/dist/lockstep.js.map +1 -1
  56. package/dist/nostr/crypto.d.ts +20 -0
  57. package/dist/nostr/crypto.js +78 -0
  58. package/dist/nostr/crypto.js.map +1 -0
  59. package/dist/nostr/host.d.ts +66 -0
  60. package/dist/nostr/host.js +196 -0
  61. package/dist/nostr/host.js.map +1 -0
  62. package/dist/nostr/limits.d.ts +33 -0
  63. package/dist/nostr/limits.js +38 -0
  64. package/dist/nostr/limits.js.map +1 -0
  65. package/dist/nostr/link.d.ts +70 -0
  66. package/dist/nostr/link.js +180 -0
  67. package/dist/nostr/link.js.map +1 -0
  68. package/dist/nostr/relays.d.ts +79 -0
  69. package/dist/nostr/relays.js +325 -0
  70. package/dist/nostr/relays.js.map +1 -0
  71. package/dist/nostr/room.d.ts +43 -0
  72. package/dist/nostr/room.js +104 -0
  73. package/dist/nostr/room.js.map +1 -0
  74. package/dist/nostr/secp256k1.d.ts +10 -0
  75. package/dist/nostr/secp256k1.js +171 -0
  76. package/dist/nostr/secp256k1.js.map +1 -0
  77. package/dist/nostr/socket.d.ts +48 -0
  78. package/dist/nostr/socket.js +141 -0
  79. package/dist/nostr/socket.js.map +1 -0
  80. package/dist/onelobby.bundle.js +146 -0
  81. package/dist/onelobby.bundle.js.map +7 -0
  82. package/dist/onelobby.bundle.mjs +11 -5
  83. package/dist/onelobby.bundle.mjs.map +4 -4
  84. package/dist/onelobby.d.ts +31 -3
  85. package/dist/onelobby.global.js +2673 -60
  86. package/dist/onelobby.global.js.map +4 -4
  87. package/dist/onelobby.global.min.js +11 -5
  88. package/dist/onelobby.global.min.js.map +4 -4
  89. package/dist/onelobby.js +77 -14
  90. package/dist/onelobby.js.map +1 -1
  91. package/dist/pause.d.ts +85 -0
  92. package/dist/pause.js +160 -0
  93. package/dist/pause.js.map +1 -0
  94. package/dist/signaling.d.ts +2 -0
  95. package/dist/signaling.js.map +1 -1
  96. package/dist/socket.d.ts +15 -6
  97. package/dist/socket.js.map +1 -1
  98. package/dist/testing.d.ts +2 -0
  99. package/dist/testing.js +18 -2
  100. package/dist/testing.js.map +1 -1
  101. package/dist/transport.d.ts +5 -1
  102. package/dist/transport.js.map +1 -1
  103. package/dist/webrtc.d.ts +6 -0
  104. package/dist/webrtc.js +27 -3
  105. package/dist/webrtc.js.map +1 -1
  106. package/package.json +1 -1
  107. package/dist/deprecated.d.ts +0 -18
  108. package/dist/deprecated.js +0 -11
  109. package/dist/deprecated.js.map +0 -1
package/dist/lobby.js CHANGED
@@ -4,6 +4,7 @@ import { Reassembler, splitChunks } from "./chunking.js";
4
4
  import { Emitter } from "./emitter.js";
5
5
  import { Lockstep } from "./lockstep.js";
6
6
  import { claimWinsWrapped, pickNewHost } from "./migration.js";
7
+ import { Pause } from "./pause.js";
7
8
  import { OneLobbyError } from "./signaling.js";
8
9
  import { Voice } from "./voice.js";
9
10
  import { hostConnections, joinerSocket } from "./socket.js";
@@ -11,6 +12,8 @@ import { hostConnections, joinerSocket } from "./socket.js";
11
12
  export const MIN_PEER_TIMEOUT_MS = 6000;
12
13
  /** Host: how long a kicked / disconnected peer's link stays open for the hub's notice to reach it first. */
13
14
  export const KICK_LINGER_MS = 3000;
15
+ /** How long an offer held during a possible migration stays answerable. */
16
+ const HELD_OFFER_MS = 20_000;
14
17
  /** The name shown for a peer that chose none: "Player N", N = its seat (peer id, join order). */
15
18
  export function seatName(p) {
16
19
  const n = typeof p.name === "string" ? p.name.trim() : "";
@@ -31,31 +34,62 @@ export class Lobby extends Emitter {
31
34
  tickRate;
32
35
  lockstep;
33
36
  chat;
37
+ /** The shared co-op pause (docs/recipes/pause.md). */
38
+ pause;
34
39
  voice;
35
40
  localPeerId;
36
41
  hostPeerId;
37
42
  phase;
38
43
  meta;
44
+ /** The lobby's name as the server keeps it (host()'s `name`, sanitised); "" if the server sends none. */
45
+ name;
39
46
  resumeToken;
40
47
  /** Last measured round trip to the host in ms (joiners), or 0. */
41
48
  rtt = 0;
42
49
  /**
43
- * Direct-IP host only (onelobby/direct): the code friends paste to join, e.g. "6B01-RGFE-P2B" (docs/direct-ip.md
50
+ * Direct-IP host only (onelobby/direct): the code friends paste to join, e.g. "6B01-RGFE-P2B" (docs/design/direct-ip.md
44
51
  * s.2a). Show it with joinCodeKind. Empty when the machine has no IPv4 address, and on joiners.
45
52
  */
46
53
  joinCode = "";
47
- /** "internet": the router forwarded the port and the code carries its public address; "same_network": the LAN address. */
54
+ /**
55
+ * "nostr": a version 1 code that works through public Nostr relays (spec/nostr-signaling.md; the default for a direct
56
+ * host). "internet": the router forwarded the port and the code carries its public address; "same_network": the LAN
57
+ * address. "none": no code.
58
+ */
48
59
  joinCodeKind = "none";
60
+ /**
61
+ * Direct-IP host only: the IP code (address and port, version 0), with `directCodeKind` "internet" or "same_network".
62
+ * Equal to `joinCode` when Nostr is off; otherwise the code to hand out for LAN play or when the router port is open.
63
+ */
64
+ directCode = "";
65
+ directCodeKind = "none";
49
66
  /** Direct-IP host only: this machine's addresses with the port, for an "Advanced" fold-out. */
50
67
  directAddresses = [];
51
68
  /** Direct-IP host only: how the router port-open went ("UPnP", "no UPnP gateway answered; ...", "off"). */
52
69
  portMapping = "";
70
+ /** Direct-IP host only: the one startup line (bound port, join code, port mapping); see `formatHostLog` in onelobby/direct. */
71
+ directHostLog = "";
72
+ /** Browser host only (`kit.hostNostr`): true while the host tab is hidden (see the `hostHiddenChanged` event). */
73
+ hostHidden = false;
74
+ /** @internal kit.hostNostr: the tab's visibility changed. */
75
+ setHostHidden(hidden) {
76
+ if (this.hostHidden === hidden)
77
+ return;
78
+ this.hostHidden = hidden;
79
+ this.emit("hostHiddenChanged", hidden);
80
+ }
81
+ /** @internal onelobby/direct: fires `joinCodeChanged` with the current code. */
82
+ notifyJoinCodeChanged() {
83
+ this.emit("joinCodeChanged", this.joinCode, this.joinCodeKind);
84
+ }
53
85
  signaling;
54
86
  cfg;
55
87
  roster = new Map();
56
88
  links = new Map();
57
89
  /** ICE from a peer that has no link yet: its candidates can overtake its offer (node-datachannel). */
58
90
  earlyIce = new Map();
91
+ /** Offers from roster members that arrived while the host link was open (see offerVerdict), by sender. */
92
+ heldOffers = new Map();
59
93
  connected = new Set(); // host: peers that said hi
60
94
  hiSentTo = new Set();
61
95
  epoch = 0;
@@ -77,6 +111,8 @@ export class Lobby extends Emitter {
77
111
  lastHeard = new Map();
78
112
  /** Host: links of kicked / disconnected peers, kept open briefly (removePeer `linger`). */
79
113
  lingering = new Set();
114
+ /** Host: joiners that joined but are not ready yet, with the cancel of their readyTimeoutMs timer (peerFailed). */
115
+ notReady = new Map();
80
116
  /** Host: per-peer fixed window for rateLimit. */
81
117
  rate = new Map();
82
118
  /**
@@ -107,6 +143,7 @@ export class Lobby extends Emitter {
107
143
  this.hostPeerId = st.hostId;
108
144
  this.phase = st.phase;
109
145
  this.meta = { ...st.meta };
146
+ this.name = st.name ?? "";
110
147
  this.resumeToken = st.resumeToken;
111
148
  for (const p of st.peers)
112
149
  this.roster.set(p.peerId, seatName(p));
@@ -131,6 +168,11 @@ export class Lobby extends Emitter {
131
168
  this.lockstep = new Lockstep(this.hub, { hashEveryTicks: cfg.hashEveryTicks });
132
169
  this.chat = new Chat(this.hub, cfg.wall, (id) => this.voice.isMutedPeer(id));
133
170
  this.voice = new Voice(this.hub, cfg.voice);
171
+ this.pause = new Pause(this.hub, {
172
+ phase: () => this.phase,
173
+ hold: (held) => this.lockstep.hold(held),
174
+ hasPeer: (id) => this.roster.has(id),
175
+ });
134
176
  this.lockstep.on("started", () => this.setPhase("in_game"));
135
177
  this.lockstep.on("error", (e) => this.reportError(e));
136
178
  this.signaling.onMessage = (m) => this.onSignaling(m);
@@ -162,14 +204,9 @@ export class Lobby extends Emitter {
162
204
  get connectedPeers() {
163
205
  return this.connectedPeerIds();
164
206
  }
165
- /**
166
- * Joiner: the link to the host as a WebSocket-shaped object (text by default), so WebSocket client code runs
167
- * unchanged. It opens when `ready` resolves and closes when the session ends (`host_left` gives code 1001).
168
- */
169
207
  asWebSocket(opts) {
170
208
  return joinerSocket(this, opts);
171
209
  }
172
- /** Host: `handler(conn)` once per joiner (a WebSocket-shaped connection), the server-side mirror of asWebSocket(). */
173
210
  onConnection(handler, opts) {
174
211
  return hostConnections(this, handler, opts);
175
212
  }
@@ -204,6 +241,17 @@ export class Lobby extends Emitter {
204
241
  if (this.mode === "lockstep")
205
242
  this.lockstep.hostStart();
206
243
  }
244
+ /**
245
+ * Changes your display name mid-session. The hub sanitises it (as at hello, at most 24 characters) and every member,
246
+ * you included, gets `peerRenamed`. Names need not be unique. At most one rename per second; a refusal
247
+ * (`bad_request` for an empty name, `rate_limited`) arrives on the kit's `error` event. Throws `unsupported` when
248
+ * the hub is too old to rename.
249
+ */
250
+ async rename(name) {
251
+ if (!(this.cfg.features ?? []).includes("set_name"))
252
+ throw new OneLobbyError("unsupported", "this lobby's hub cannot rename players (update it)");
253
+ this.signaling.send({ t: "set_name", name: String(name ?? "") });
254
+ }
207
255
  async setMeta(meta) {
208
256
  if (!this.isHost)
209
257
  throw new Error("only the host can set metadata");
@@ -368,7 +416,11 @@ export class Lobby extends Emitter {
368
416
  }, budget);
369
417
  }
370
418
  giveUpConnecting() {
371
- const err = new OneLobbyError("ice_failed", `could not open a connection to the host${this.relayRetry ? " (direct or relayed)" : ""}`);
419
+ // what the host link saw (states, signals, timings), so a test log of a failed connect says where it stalled
420
+ const hostLink = this.links.get(this.hostPeerId);
421
+ const diagnostics = hostLink ? hostLink.diagnostics?.() : (this.lastHostLinkEnd ?? "no offer from the host ever arrived");
422
+ // the trace holds ICE candidates (local addresses): keep it in data, out of the message a game may show
423
+ const err = new OneLobbyError("ice_failed", `could not open a connection to the host${this.relayRetry ? " (direct or relayed)" : ""}`, diagnostics ? { diagnostics } : undefined);
372
424
  this.failReady(err);
373
425
  this.reportError(err);
374
426
  if (this.ended)
@@ -377,6 +429,8 @@ export class Lobby extends Emitter {
377
429
  this.teardown("ice_failed");
378
430
  }
379
431
  // ---- links -----------------------------------------------------------------------------------------------------
432
+ /** @internal A direct host's hub learns where each joiner's link comes from (Nostr kick bans the address too). */
433
+ linkAddressSink = null;
380
434
  makeLink(peerId, relayOnly = this.cfg.relayOnly) {
381
435
  const old = this.links.get(peerId);
382
436
  if (old) {
@@ -391,6 +445,7 @@ export class Lobby extends Emitter {
391
445
  link.onClose = (reason) => this.onLinkClosed(peerId, link, reason);
392
446
  link.onMessage = (ch, bytes) => this.onData(peerId, ch, bytes);
393
447
  link.onPath = (path) => this.signaling.send({ t: "path", path });
448
+ link.onRemoteAddress = (ip) => this.linkAddressSink?.(peerId, ip);
394
449
  return link;
395
450
  }
396
451
  /** Host side: offer to a joiner. */
@@ -420,9 +475,13 @@ export class Lobby extends Emitter {
420
475
  this.emit("peerReady", this.roster.get(link.remoteId) ?? { peerId: link.remoteId, name: "" });
421
476
  this.flush(link.remoteId);
422
477
  }
478
+ /** Why and how the joiner's last link to the host ended (shown when the join gives up). */
479
+ lastHostLinkEnd = null;
423
480
  onLinkClosed(peerId, link, _reason) {
424
481
  if (this.links.get(peerId) !== link)
425
482
  return; // replaced
483
+ if (!this.isHost && peerId === this.hostPeerId)
484
+ this.lastHostLinkEnd = `link ended (${_reason}): ${link.diagnostics?.() ?? ""}`;
426
485
  this.links.delete(peerId);
427
486
  this.hiSentTo.delete(link);
428
487
  if (this.ended)
@@ -454,6 +513,10 @@ export class Lobby extends Emitter {
454
513
  }
455
514
  if (!d)
456
515
  return;
516
+ // The topology is a star: a joiner hears only its host. Anything else over some other link (a peer that offered to
517
+ // us, or a member that dialled us) is not host traffic and must not reach raw / lockstep / pause / chat handling.
518
+ if (!this.isHost && from !== this.hostPeerId && d.msg.type !== "host_claim")
519
+ return;
457
520
  if (!reassembled)
458
521
  this.lastHeard.set(from, this.cfg.clock.now());
459
522
  const { msg, route } = d;
@@ -505,6 +568,11 @@ export class Lobby extends Emitter {
505
568
  case "chat_reject":
506
569
  this.chat.handle(from, route, msg);
507
570
  break;
571
+ case "pause_request":
572
+ case "pause_state":
573
+ case "pause_refuse":
574
+ this.pause.handle(from, msg);
575
+ break;
508
576
  case "voice":
509
577
  if (channel === "voice")
510
578
  this.voice.handle(from, route, msg); // a voice frame on another channel is ignored
@@ -525,6 +593,8 @@ export class Lobby extends Emitter {
525
593
  }
526
594
  onHi(from, lastTick) {
527
595
  this.connected.add(from);
596
+ this.notReady.get(from)?.();
597
+ this.notReady.delete(from);
528
598
  this.sendTo(from, {
529
599
  type: "welcome_dc",
530
600
  // the tick the snapshot will carry (the game's applied tick), so welcome_dc.tick == snapshot.tick even when
@@ -535,6 +605,7 @@ export class Lobby extends Emitter {
535
605
  hostId: this.localPeerId,
536
606
  }, 0);
537
607
  this.chat.sendHistory(from);
608
+ this.pause.sendState(from);
538
609
  if (this.mode === "lockstep")
539
610
  this.lockstep.onPeerHi(from, lastTick);
540
611
  this.emit("peerReady", this.roster.get(from) ?? { peerId: from, name: "" });
@@ -583,6 +654,12 @@ export class Lobby extends Emitter {
583
654
  return;
584
655
  this.hostPeerId = hostId;
585
656
  this.emit("hostChanged", hostId);
657
+ const held = this.heldOffers.get(hostId);
658
+ if (held) {
659
+ this.heldOffers.delete(hostId);
660
+ if (this.cfg.clock.now() - held.at <= HELD_OFFER_MS)
661
+ void this.acceptOffer(hostId, held.payload);
662
+ }
586
663
  }
587
664
  /** The host is gone (link closed, no frames for 3 s, or the server said so): run the s.9 rule. */
588
665
  handleHostLost(reason) {
@@ -610,6 +687,16 @@ export class Lobby extends Emitter {
610
687
  if (candidate === this.localPeerId)
611
688
  this.becomeHost();
612
689
  // else: wait for the candidate's host_claim (or the server's host_changed); it offers to us and we answer with hi
690
+ this.replayHeldOffers();
691
+ }
692
+ /** Offers that came from roster members while the host link was still open: now the host is lost, answer them. */
693
+ replayHeldOffers() {
694
+ const now = this.cfg.clock.now();
695
+ for (const [from, held] of [...this.heldOffers]) {
696
+ this.heldOffers.delete(from);
697
+ if (now - held.at <= HELD_OFFER_MS && this.roster.has(from))
698
+ void this.acceptOffer(from, held.payload);
699
+ }
613
700
  }
614
701
  becomeHost() {
615
702
  this.epoch = (this.epoch + 1) & 0xffff;
@@ -619,6 +706,7 @@ export class Lobby extends Emitter {
619
706
  this.hiSentTo.clear();
620
707
  this.lastHeard.clear(); // joiners spoke to the old host; their clocks start when they say hi to us
621
708
  this.lockstep.becomeHost();
709
+ this.pause.onBecameHost();
622
710
  this.emit("hostChanged", this.localPeerId);
623
711
  if (this.serverUp)
624
712
  this.signaling.send({ t: "host_changed", hostId: this.localPeerId, epoch: this.epoch });
@@ -628,6 +716,8 @@ export class Lobby extends Emitter {
628
716
  }
629
717
  }
630
718
  onHostClaim(from, epoch, hostId) {
719
+ if (hostId !== from)
720
+ return; // a claim is a peer claiming itself; nobody claims for someone else
631
721
  const mine = { epoch: this.epoch, hostId: this.hostPeerId };
632
722
  const theirs = { epoch, hostId };
633
723
  if (hostId === this.hostPeerId && epoch === this.epoch && !this.isHost) {
@@ -667,8 +757,10 @@ export class Lobby extends Emitter {
667
757
  const peer = seatName(m.peer);
668
758
  this.roster.set(peer.peerId, peer);
669
759
  this.emit("peerJoined", peer);
670
- if (this.isHost)
760
+ if (this.isHost) {
761
+ this.watchReady(peer.peerId);
671
762
  this.offerTo(peer.peerId);
763
+ }
672
764
  break;
673
765
  }
674
766
  case "peer_left":
@@ -702,11 +794,19 @@ export class Lobby extends Emitter {
702
794
  case "lobby": {
703
795
  const st = m;
704
796
  this.meta = { ...st.meta };
797
+ if (st.name)
798
+ this.name = st.name;
705
799
  if (st.resumeToken && st.resumeToken !== this.resumeToken) {
706
800
  this.resumeToken = st.resumeToken;
707
801
  this.seatSink?.(this.resumeToken);
708
802
  }
803
+ const before = this.roster;
709
804
  this.roster = new Map(st.peers.map((p) => [p.peerId, seatName(p)]));
805
+ for (const [id, p] of this.roster) {
806
+ const old = before.get(id);
807
+ if (old && old.name !== p.name)
808
+ this.emit("peerRenamed", p, old.name);
809
+ }
710
810
  if (st.hostId !== this.hostPeerId && !this.isHost)
711
811
  this.setHost(st.hostId);
712
812
  this.setPhase(st.phase);
@@ -745,20 +845,21 @@ export class Lobby extends Emitter {
745
845
  if (s.ice)
746
846
  payload.ice = s.ice;
747
847
  if (s.sdp?.type === "offer") {
748
- // We are the answerer; an offer always comes from the (new) host.
749
- link = this.makeLink(from);
750
- const early = this.earlyIce.get(from) ?? [];
751
- this.earlyIce.delete(from);
752
- // the link holds candidates until the offer is set, so hand them over right behind it
753
- const done = link.handleSignal(payload);
754
- for (const e of early)
755
- void link.handleSignal(e);
756
- await done;
848
+ // We are the answerer. An offer comes from the (new) host, so a peer may dial us only as that rule allows.
849
+ const verdict = this.offerVerdict(from);
850
+ if (verdict === "hold") {
851
+ if (this.heldOffers.size < 64 || this.heldOffers.has(from))
852
+ this.heldOffers.set(from, { payload, at: this.cfg.clock.now() });
853
+ }
854
+ else if (verdict === "accept")
855
+ await this.acceptOffer(from, payload);
757
856
  return;
758
857
  }
759
858
  if (!link) {
760
859
  if (!payload.ice || this.ended || !this.roster.has(from))
761
860
  return;
861
+ if (this.cfg.direct && from !== this.hostPeerId)
862
+ return;
762
863
  const q = this.earlyIce.get(from) ?? [];
763
864
  if (q.length < 64)
764
865
  q.push(payload);
@@ -767,6 +868,35 @@ export class Lobby extends Emitter {
767
868
  }
768
869
  await link.handleSignal(payload);
769
870
  }
871
+ /**
872
+ * Whether an SDP offer from `from` may be answered. The host (or a joiner's host) always may. Direct lobbies have no
873
+ * migration, so nobody else. In a server lobby a roster member may dial us when we are the host (a claim race decides
874
+ * who wins) or when our own link to the host is gone (the migration window); while that link is open the offer is
875
+ * held, to be answered if the host turns out to be lost a moment later.
876
+ */
877
+ offerVerdict(from) {
878
+ if (this.ended || from === this.localPeerId)
879
+ return "drop";
880
+ if (!this.isHost && from === this.hostPeerId)
881
+ return "accept";
882
+ if (this.cfg.direct || !this.roster.has(from))
883
+ return "drop";
884
+ if (this.isHost)
885
+ return "accept";
886
+ if (!this.isReady)
887
+ return "drop"; // still joining: only the host's own offer is expected
888
+ return this.links.get(this.hostPeerId)?.isOpen ? "hold" : "accept";
889
+ }
890
+ async acceptOffer(from, payload) {
891
+ const link = this.makeLink(from);
892
+ const early = this.earlyIce.get(from) ?? [];
893
+ this.earlyIce.delete(from);
894
+ // the link holds candidates until the offer is set, so hand them over right behind it
895
+ const done = link.handleSignal(payload);
896
+ for (const e of early)
897
+ void link.handleSignal(e);
898
+ await done;
899
+ }
770
900
  /**
771
901
  * `linger` (host kick / disconnect): stop using the peer's link at once but leave it open until that peer closes it
772
902
  * or KICK_LINGER_MS pass. The hub tells the peer over its WebSocket; closing the data link first would make it look
@@ -781,6 +911,13 @@ export class Lobby extends Emitter {
781
911
  if (peer)
782
912
  this.roster.delete(peerId);
783
913
  this.earlyIce.delete(peerId);
914
+ const cancelWatch = this.notReady.get(peerId);
915
+ if (cancelWatch) {
916
+ cancelWatch();
917
+ this.notReady.delete(peerId);
918
+ if (peer && reason !== "kicked" && reason !== "disconnected")
919
+ this.emit("peerFailed", peer, reason);
920
+ }
784
921
  const link = this.links.get(peerId);
785
922
  if (link && peerId !== this.hostPeerId) {
786
923
  link.onMessage = null;
@@ -810,6 +947,7 @@ export class Lobby extends Emitter {
810
947
  this.lockstep.onPeerLeft(peerId);
811
948
  this.chat.onPeerLeft(peerId);
812
949
  this.voice.onPeerLeft(peerId);
950
+ this.pause.onPeerLeft(peerId);
813
951
  if (peer)
814
952
  this.emit("peerLeft", peer, reason, close);
815
953
  if (peerId === this.hostPeerId && !this.isHost) {
@@ -827,6 +965,8 @@ export class Lobby extends Emitter {
827
965
  if (this.phase === p)
828
966
  return;
829
967
  this.phase = p;
968
+ if (p === "lobby")
969
+ this.pause.onLobbyPhase();
830
970
  if (p === "in_game" && !this.lockstep.started)
831
971
  this.lockstep.markStarted();
832
972
  this.emit("phase", p);
@@ -882,11 +1022,29 @@ export class Lobby extends Emitter {
882
1022
  return;
883
1023
  this.sendTo(this.hostPeerId, { type: "ping", n: ++this.pingN, t: this.cfg.clock.now() >>> 0 }, 0, "fast");
884
1024
  }
1025
+ /** Host: report a joiner that is not ready `readyTimeoutMs` after joining as peerFailed("timeout"), once. */
1026
+ watchReady(peerId) {
1027
+ const ms = this.cfg.readyTimeoutMs ?? 0;
1028
+ let cancel = () => { };
1029
+ if (ms > 0) {
1030
+ cancel = this.cfg.clock.setTimeout(() => {
1031
+ if (!this.notReady.delete(peerId))
1032
+ return;
1033
+ const peer = this.roster.get(peerId);
1034
+ if (peer)
1035
+ this.emit("peerFailed", peer, "timeout");
1036
+ }, ms);
1037
+ }
1038
+ this.notReady.set(peerId, cancel);
1039
+ }
885
1040
  teardown(reason, close) {
886
1041
  if (this.ended)
887
1042
  return;
888
1043
  this.ended = true;
889
1044
  this.cancelReadyTimer?.();
1045
+ for (const cancel of this.notReady.values())
1046
+ cancel();
1047
+ this.notReady.clear();
890
1048
  if (!this.isReady) {
891
1049
  const code = reason === "ice_failed" || reason === "kicked" ? reason : "closed";
892
1050
  this.failReady(new OneLobbyError(code, `the lobby ended before connecting (${reason})`));
@@ -902,6 +1060,7 @@ export class Lobby extends Emitter {
902
1060
  }
903
1061
  this.links.clear();
904
1062
  this.earlyIce.clear();
1063
+ this.heldOffers.clear();
905
1064
  this.lingering.clear();
906
1065
  this.signaling.onClose = null;
907
1066
  this.signaling.onMessage = null;