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.
- package/LICENSE +21 -0
- package/README.md +77 -2
- package/dist/chat.d.ts +60 -0
- package/dist/chat.js +163 -0
- package/dist/chat.js.map +1 -0
- package/dist/chunking.d.ts +17 -0
- package/dist/chunking.js +59 -0
- package/dist/chunking.js.map +1 -0
- package/dist/clock.d.ts +8 -0
- package/dist/clock.js +39 -0
- package/dist/clock.js.map +1 -0
- package/dist/codec.d.ts +141 -0
- package/dist/codec.js +308 -0
- package/dist/codec.js.map +1 -0
- package/dist/crockford.d.ts +14 -0
- package/dist/crockford.js +55 -0
- package/dist/crockford.js.map +1 -0
- package/dist/deflate.d.ts +4 -0
- package/dist/deflate.js +32 -0
- package/dist/deflate.js.map +1 -0
- package/dist/deprecated.d.ts +18 -0
- package/dist/deprecated.js +11 -0
- package/dist/deprecated.js.map +1 -0
- package/dist/direct/hub-host.d.ts +82 -0
- package/dist/direct/hub-host.js +393 -0
- package/dist/direct/hub-host.js.map +1 -0
- package/dist/direct/hub.d.ts +178 -0
- package/dist/direct/hub.js +862 -0
- package/dist/direct/hub.js.map +1 -0
- package/dist/direct/index.d.ts +57 -0
- package/dist/direct/index.js +71 -0
- package/dist/direct/index.js.map +1 -0
- package/dist/direct/portmap.d.ts +84 -0
- package/dist/direct/portmap.js +465 -0
- package/dist/direct/portmap.js.map +1 -0
- package/dist/direct-address.d.ts +20 -0
- package/dist/direct-address.js +61 -0
- package/dist/direct-address.js.map +1 -0
- package/dist/emitter.d.ts +9 -0
- package/dist/emitter.js +36 -0
- package/dist/emitter.js.map +1 -0
- package/dist/friendly.d.ts +20 -0
- package/dist/friendly.js +120 -0
- package/dist/friendly.js.map +1 -0
- package/dist/global.d.ts +8 -0
- package/dist/global.js +9 -0
- package/dist/global.js.map +1 -0
- package/dist/gridpaint.d.ts +27 -0
- package/dist/gridpaint.js +106 -0
- package/dist/gridpaint.js.map +1 -0
- package/dist/hub.d.ts +25 -0
- package/dist/hub.js +2 -0
- package/dist/hub.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/internals.d.ts +28 -0
- package/dist/internals.js +24 -0
- package/dist/internals.js.map +1 -0
- package/dist/joincode.d.ts +21 -0
- package/dist/joincode.js +84 -0
- package/dist/joincode.js.map +1 -0
- package/dist/lobby.d.ts +203 -0
- package/dist/lobby.js +840 -0
- package/dist/lobby.js.map +1 -0
- package/dist/lockstep.d.ts +132 -0
- package/dist/lockstep.js +471 -0
- package/dist/lockstep.js.map +1 -0
- package/dist/migration.d.ts +17 -0
- package/dist/migration.js +34 -0
- package/dist/migration.js.map +1 -0
- package/dist/onelobby.bundle.mjs +140 -0
- package/dist/onelobby.bundle.mjs.map +7 -0
- package/dist/onelobby.d.ts +151 -0
- package/dist/onelobby.global.js +3897 -0
- package/dist/onelobby.global.js.map +7 -0
- package/dist/onelobby.global.min.js +140 -0
- package/dist/onelobby.global.min.js.map +7 -0
- package/dist/onelobby.js +337 -0
- package/dist/onelobby.js.map +1 -0
- package/dist/signaling.d.ts +94 -0
- package/dist/signaling.js +134 -0
- package/dist/signaling.js.map +1 -0
- package/dist/socket.d.ts +63 -0
- package/dist/socket.js +142 -0
- package/dist/socket.js.map +1 -0
- package/dist/testing.d.ts +135 -0
- package/dist/testing.js +438 -0
- package/dist/testing.js.map +1 -0
- package/dist/transport.d.ts +80 -0
- package/dist/transport.js +7 -0
- package/dist/transport.js.map +1 -0
- package/dist/ui/controller.d.ts +137 -0
- package/dist/ui/controller.js +337 -0
- package/dist/ui/controller.js.map +1 -0
- package/dist/ui/element.d.ts +22 -0
- package/dist/ui/element.js +284 -0
- package/dist/ui/element.js.map +1 -0
- package/dist/ui/index.d.ts +5 -0
- package/dist/ui/index.js +9 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/server-url.d.ts +9 -0
- package/dist/ui/server-url.js +39 -0
- package/dist/ui/server-url.js.map +1 -0
- package/dist/ui/styles.d.ts +2 -0
- package/dist/ui/styles.js +70 -0
- package/dist/ui/styles.js.map +1 -0
- package/dist/voice-browser.d.ts +19 -0
- package/dist/voice-browser.js +151 -0
- package/dist/voice-browser.js.map +1 -0
- package/dist/voice-core.d.ts +98 -0
- package/dist/voice-core.js +233 -0
- package/dist/voice-core.js.map +1 -0
- package/dist/voice-types.d.ts +35 -0
- package/dist/voice-types.js +3 -0
- package/dist/voice-types.js.map +1 -0
- package/dist/voice.d.ts +68 -0
- package/dist/voice.js +223 -0
- package/dist/voice.js.map +1 -0
- package/dist/webrtc.d.ts +29 -0
- package/dist/webrtc.js +169 -0
- package/dist/webrtc.js.map +1 -0
- package/package.json +93 -4
|
@@ -0,0 +1,3897 @@
|
|
|
1
|
+
/*! onelobby 0.2.0 | MIT license */
|
|
2
|
+
"use strict";
|
|
3
|
+
var OneLobby = (() => {
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
21
|
+
|
|
22
|
+
// src/global.ts
|
|
23
|
+
var global_exports = {};
|
|
24
|
+
__export(global_exports, {
|
|
25
|
+
CHAT_MAX_BYTES: () => CHAT_MAX_BYTES,
|
|
26
|
+
Chat: () => Chat,
|
|
27
|
+
DEFAULT_READY_TIMEOUT_MS: () => DEFAULT_READY_TIMEOUT_MS,
|
|
28
|
+
Keepalive: () => Keepalive,
|
|
29
|
+
KeepaliveError: () => KeepaliveError,
|
|
30
|
+
LIB_NAME: () => LIB_NAME,
|
|
31
|
+
LIB_VERSION: () => LIB_VERSION,
|
|
32
|
+
LOBBY_TAG: () => LOBBY_TAG,
|
|
33
|
+
Lobby: () => Lobby,
|
|
34
|
+
LobbyController: () => LobbyController,
|
|
35
|
+
Lockstep: () => Lockstep,
|
|
36
|
+
MIN_PEER_TIMEOUT_MS: () => MIN_PEER_TIMEOUT_MS,
|
|
37
|
+
OneLobby: () => OneLobby,
|
|
38
|
+
OneLobbyError: () => OneLobbyError,
|
|
39
|
+
SharedState: () => SharedState,
|
|
40
|
+
Voice: () => Voice,
|
|
41
|
+
closeCodeFor: () => closeCodeFor,
|
|
42
|
+
codeFromUrl: () => codeFromUrl,
|
|
43
|
+
decodeJoinCode: () => decodeJoinCode,
|
|
44
|
+
defineLobbyElement: () => defineLobbyElement,
|
|
45
|
+
describeError: () => describeError,
|
|
46
|
+
formatCode: () => formatCode,
|
|
47
|
+
lobbyServerUrl: () => lobbyServerUrl,
|
|
48
|
+
looksLikeJoinCode: () => looksLikeJoinCode,
|
|
49
|
+
normalizeCode: () => normalizeCode,
|
|
50
|
+
shareUrl: () => shareUrl,
|
|
51
|
+
webCodecsOpusAvailable: () => webCodecsOpusAvailable,
|
|
52
|
+
webRtcLinkFactoryFor: () => webRtcLinkFactoryFor
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// src/clock.ts
|
|
56
|
+
function workerInterval(fn, ms) {
|
|
57
|
+
if (typeof Worker === "undefined" || typeof Blob === "undefined" || typeof URL === "undefined" || !URL.createObjectURL) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const src = `let h=0;onmessage=e=>{if(e.data&&e.data.start){h=setInterval(()=>postMessage(0),e.data.ms)}else{clearInterval(h);close()}}`;
|
|
62
|
+
const url = URL.createObjectURL(new Blob([src], { type: "text/javascript" }));
|
|
63
|
+
const w = new Worker(url);
|
|
64
|
+
URL.revokeObjectURL(url);
|
|
65
|
+
w.onmessage = () => fn();
|
|
66
|
+
w.postMessage({ start: true, ms });
|
|
67
|
+
return () => {
|
|
68
|
+
w.postMessage({ stop: true });
|
|
69
|
+
w.terminate();
|
|
70
|
+
};
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
var systemClock = {
|
|
76
|
+
now: () => typeof performance !== "undefined" ? performance.now() : Date.now(),
|
|
77
|
+
setInterval(fn, ms) {
|
|
78
|
+
const w = workerInterval(fn, ms);
|
|
79
|
+
if (w) return w;
|
|
80
|
+
const h = setInterval(fn, ms);
|
|
81
|
+
return () => clearInterval(h);
|
|
82
|
+
},
|
|
83
|
+
setTimeout(fn, ms) {
|
|
84
|
+
const h = setTimeout(fn, ms);
|
|
85
|
+
return () => clearTimeout(h);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// src/emitter.ts
|
|
90
|
+
var Emitter = class {
|
|
91
|
+
handlers = /* @__PURE__ */ new Map();
|
|
92
|
+
on(event, fn) {
|
|
93
|
+
let list = this.handlers.get(event);
|
|
94
|
+
if (!list) this.handlers.set(event, list = []);
|
|
95
|
+
list.push(fn);
|
|
96
|
+
return () => this.off(event, fn);
|
|
97
|
+
}
|
|
98
|
+
off(event, fn) {
|
|
99
|
+
const list = this.handlers.get(event);
|
|
100
|
+
if (!list) return;
|
|
101
|
+
const i = list.indexOf(fn);
|
|
102
|
+
if (i >= 0) list.splice(i, 1);
|
|
103
|
+
}
|
|
104
|
+
emit(event, ...args) {
|
|
105
|
+
const list = this.handlers.get(event);
|
|
106
|
+
if (!list) return;
|
|
107
|
+
for (const fn of [...list]) {
|
|
108
|
+
try {
|
|
109
|
+
fn(...args);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
queueMicrotask(() => {
|
|
112
|
+
throw e;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// src/crockford.ts
|
|
120
|
+
var CROCKFORD_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
121
|
+
var CODE_LENGTH = 6;
|
|
122
|
+
function asciiUpper(s) {
|
|
123
|
+
return s.replace(/[a-z]/g, (c) => c.toUpperCase());
|
|
124
|
+
}
|
|
125
|
+
function normalizeCode(input) {
|
|
126
|
+
const s = asciiUpper(input).replace(/[ \-]/g, "").replace(/[IL]/g, "1").replace(/O/g, "0");
|
|
127
|
+
if (s.length !== CODE_LENGTH) return null;
|
|
128
|
+
for (const ch of s) if (CROCKFORD_ALPHABET.indexOf(ch) < 0) return null;
|
|
129
|
+
return s;
|
|
130
|
+
}
|
|
131
|
+
function formatCode(code) {
|
|
132
|
+
const c = normalizeCode(code) ?? code.toUpperCase();
|
|
133
|
+
return c.length === CODE_LENGTH ? `${c.slice(0, 3)} ${c.slice(3)}` : c;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// src/codec.ts
|
|
137
|
+
var MAX_FRAME_BYTES = 16 * 1024;
|
|
138
|
+
var MAX_REASSEMBLED_BYTES = 64 * 1024 * 1024;
|
|
139
|
+
var HEADER_BYTES = 3;
|
|
140
|
+
var PROTO = 1;
|
|
141
|
+
var MsgType = {
|
|
142
|
+
hi: 1,
|
|
143
|
+
welcome_dc: 2,
|
|
144
|
+
cmd: 16,
|
|
145
|
+
frame: 17,
|
|
146
|
+
hash: 18,
|
|
147
|
+
desync: 19,
|
|
148
|
+
snapshot_req: 20,
|
|
149
|
+
snapshot: 21,
|
|
150
|
+
chunk: 22,
|
|
151
|
+
raw: 32,
|
|
152
|
+
ping: 48,
|
|
153
|
+
pong: 49,
|
|
154
|
+
host_claim: 64,
|
|
155
|
+
voice: 80,
|
|
156
|
+
chat: 96,
|
|
157
|
+
chat_history: 97,
|
|
158
|
+
chat_reject: 98
|
|
159
|
+
};
|
|
160
|
+
var MODE_LOCKSTEP = 1;
|
|
161
|
+
var MODE_RAW = 2;
|
|
162
|
+
var CodecError = class extends Error {
|
|
163
|
+
constructor(code, detail = "") {
|
|
164
|
+
super(detail ? `${code}: ${detail}` : code);
|
|
165
|
+
this.code = code;
|
|
166
|
+
this.name = "CodecError";
|
|
167
|
+
}
|
|
168
|
+
code;
|
|
169
|
+
};
|
|
170
|
+
var enc = new TextEncoder();
|
|
171
|
+
var dec = new TextDecoder("utf-8", { fatal: true });
|
|
172
|
+
function decodeText(b) {
|
|
173
|
+
try {
|
|
174
|
+
return dec.decode(b);
|
|
175
|
+
} catch {
|
|
176
|
+
throw new CodecError("bad_utf8");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
var Writer = class {
|
|
180
|
+
buf;
|
|
181
|
+
dv;
|
|
182
|
+
pos = 0;
|
|
183
|
+
constructor(size) {
|
|
184
|
+
this.buf = new Uint8Array(size);
|
|
185
|
+
this.dv = new DataView(this.buf.buffer);
|
|
186
|
+
}
|
|
187
|
+
ensure(n) {
|
|
188
|
+
if (this.pos + n <= this.buf.length) return;
|
|
189
|
+
const nb = new Uint8Array(Math.max(this.buf.length * 2, this.pos + n));
|
|
190
|
+
nb.set(this.buf);
|
|
191
|
+
this.buf = nb;
|
|
192
|
+
this.dv = new DataView(nb.buffer);
|
|
193
|
+
}
|
|
194
|
+
u8(v) {
|
|
195
|
+
this.ensure(1);
|
|
196
|
+
this.dv.setUint8(this.pos, v);
|
|
197
|
+
this.pos += 1;
|
|
198
|
+
}
|
|
199
|
+
u16(v) {
|
|
200
|
+
this.ensure(2);
|
|
201
|
+
this.dv.setUint16(this.pos, v, true);
|
|
202
|
+
this.pos += 2;
|
|
203
|
+
}
|
|
204
|
+
u32(v) {
|
|
205
|
+
this.ensure(4);
|
|
206
|
+
this.dv.setUint32(this.pos, v >>> 0, true);
|
|
207
|
+
this.pos += 4;
|
|
208
|
+
}
|
|
209
|
+
i64(v) {
|
|
210
|
+
this.ensure(8);
|
|
211
|
+
this.dv.setBigInt64(this.pos, BigInt(v), true);
|
|
212
|
+
this.pos += 8;
|
|
213
|
+
}
|
|
214
|
+
u64(v) {
|
|
215
|
+
this.ensure(8);
|
|
216
|
+
this.dv.setBigUint64(this.pos, BigInt.asUintN(64, v), true);
|
|
217
|
+
this.pos += 8;
|
|
218
|
+
}
|
|
219
|
+
bytes(b) {
|
|
220
|
+
this.ensure(b.length);
|
|
221
|
+
this.buf.set(b, this.pos);
|
|
222
|
+
this.pos += b.length;
|
|
223
|
+
}
|
|
224
|
+
done() {
|
|
225
|
+
return this.buf.slice(0, this.pos);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
var Reader = class {
|
|
229
|
+
constructor(buf) {
|
|
230
|
+
this.buf = buf;
|
|
231
|
+
this.dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
232
|
+
}
|
|
233
|
+
buf;
|
|
234
|
+
dv;
|
|
235
|
+
pos = 0;
|
|
236
|
+
need(n) {
|
|
237
|
+
if (this.pos + n > this.buf.length) throw new CodecError("too_short", "truncated frame");
|
|
238
|
+
}
|
|
239
|
+
u8() {
|
|
240
|
+
this.need(1);
|
|
241
|
+
const v = this.dv.getUint8(this.pos);
|
|
242
|
+
this.pos += 1;
|
|
243
|
+
return v;
|
|
244
|
+
}
|
|
245
|
+
u16() {
|
|
246
|
+
this.need(2);
|
|
247
|
+
const v = this.dv.getUint16(this.pos, true);
|
|
248
|
+
this.pos += 2;
|
|
249
|
+
return v;
|
|
250
|
+
}
|
|
251
|
+
u32() {
|
|
252
|
+
this.need(4);
|
|
253
|
+
const v = this.dv.getUint32(this.pos, true);
|
|
254
|
+
this.pos += 4;
|
|
255
|
+
return v;
|
|
256
|
+
}
|
|
257
|
+
i64() {
|
|
258
|
+
this.need(8);
|
|
259
|
+
const v = this.dv.getBigInt64(this.pos, true);
|
|
260
|
+
this.pos += 8;
|
|
261
|
+
return Number(v);
|
|
262
|
+
}
|
|
263
|
+
u64() {
|
|
264
|
+
this.need(8);
|
|
265
|
+
const v = this.dv.getBigUint64(this.pos, true);
|
|
266
|
+
this.pos += 8;
|
|
267
|
+
return v;
|
|
268
|
+
}
|
|
269
|
+
bytes(n) {
|
|
270
|
+
this.need(n);
|
|
271
|
+
const v = this.buf.slice(this.pos, this.pos + n);
|
|
272
|
+
this.pos += n;
|
|
273
|
+
return v;
|
|
274
|
+
}
|
|
275
|
+
rest() {
|
|
276
|
+
const v = this.buf.slice(this.pos);
|
|
277
|
+
this.pos = this.buf.length;
|
|
278
|
+
return v;
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
function encodeMessage(msg, route = 0, allowOversize = false) {
|
|
282
|
+
const w = new Writer(64);
|
|
283
|
+
w.u8(MsgType[msg.type]);
|
|
284
|
+
w.u16(route);
|
|
285
|
+
switch (msg.type) {
|
|
286
|
+
case "hi":
|
|
287
|
+
w.u8(msg.proto);
|
|
288
|
+
w.u16(msg.peerId);
|
|
289
|
+
w.i64(msg.lastTick);
|
|
290
|
+
break;
|
|
291
|
+
case "welcome_dc":
|
|
292
|
+
w.i64(msg.tick);
|
|
293
|
+
w.u8(msg.tickRate);
|
|
294
|
+
w.u8(msg.mode);
|
|
295
|
+
w.u16(msg.hostId);
|
|
296
|
+
break;
|
|
297
|
+
case "cmd":
|
|
298
|
+
w.u32(msg.seq);
|
|
299
|
+
w.u16(msg.bytes.length);
|
|
300
|
+
w.bytes(msg.bytes);
|
|
301
|
+
break;
|
|
302
|
+
case "frame":
|
|
303
|
+
w.i64(msg.tick);
|
|
304
|
+
w.u16(msg.commands.length);
|
|
305
|
+
for (const c of msg.commands) {
|
|
306
|
+
w.u16(c.peerId);
|
|
307
|
+
w.u16(c.bytes.length);
|
|
308
|
+
w.bytes(c.bytes);
|
|
309
|
+
}
|
|
310
|
+
break;
|
|
311
|
+
case "hash":
|
|
312
|
+
w.i64(msg.tick);
|
|
313
|
+
w.u64(msg.hash);
|
|
314
|
+
break;
|
|
315
|
+
case "desync":
|
|
316
|
+
w.i64(msg.tick);
|
|
317
|
+
w.u64(msg.hostHash);
|
|
318
|
+
w.u64(msg.yourHash);
|
|
319
|
+
break;
|
|
320
|
+
case "snapshot_req":
|
|
321
|
+
w.i64(msg.sinceTick);
|
|
322
|
+
break;
|
|
323
|
+
case "snapshot":
|
|
324
|
+
w.i64(msg.tick);
|
|
325
|
+
w.u32(msg.total);
|
|
326
|
+
w.u8(msg.flags);
|
|
327
|
+
break;
|
|
328
|
+
case "chunk":
|
|
329
|
+
w.u32(msg.id);
|
|
330
|
+
w.u16(msg.index);
|
|
331
|
+
w.u16(msg.count);
|
|
332
|
+
w.bytes(msg.bytes);
|
|
333
|
+
break;
|
|
334
|
+
case "raw":
|
|
335
|
+
w.bytes(msg.bytes);
|
|
336
|
+
break;
|
|
337
|
+
case "ping":
|
|
338
|
+
case "pong":
|
|
339
|
+
w.u32(msg.n);
|
|
340
|
+
w.u32(msg.t);
|
|
341
|
+
break;
|
|
342
|
+
case "host_claim":
|
|
343
|
+
w.u16(msg.epoch);
|
|
344
|
+
w.u16(msg.hostId);
|
|
345
|
+
w.i64(msg.tick);
|
|
346
|
+
break;
|
|
347
|
+
case "voice":
|
|
348
|
+
w.u16(msg.seq);
|
|
349
|
+
w.u8(msg.flags);
|
|
350
|
+
w.u8(msg.frames.length);
|
|
351
|
+
for (const f of msg.frames) {
|
|
352
|
+
w.u16(f.length);
|
|
353
|
+
w.bytes(f);
|
|
354
|
+
}
|
|
355
|
+
break;
|
|
356
|
+
case "chat": {
|
|
357
|
+
const t = enc.encode(msg.text);
|
|
358
|
+
w.u32(msg.id);
|
|
359
|
+
w.i64(msg.ts);
|
|
360
|
+
w.u16(t.length);
|
|
361
|
+
w.bytes(t);
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
case "chat_history":
|
|
365
|
+
w.u16(msg.lines.length);
|
|
366
|
+
for (const l of msg.lines) {
|
|
367
|
+
const t = enc.encode(l.text);
|
|
368
|
+
w.u16(l.peerId);
|
|
369
|
+
w.u32(l.id);
|
|
370
|
+
w.i64(l.ts);
|
|
371
|
+
w.u16(t.length);
|
|
372
|
+
w.bytes(t);
|
|
373
|
+
}
|
|
374
|
+
break;
|
|
375
|
+
case "chat_reject":
|
|
376
|
+
w.u32(msg.id);
|
|
377
|
+
w.u8(msg.reason);
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
const out = w.done();
|
|
381
|
+
if (!allowOversize && out.length > MAX_FRAME_BYTES) throw new CodecError("too_long", `${out.length} > ${MAX_FRAME_BYTES}`);
|
|
382
|
+
return out;
|
|
383
|
+
}
|
|
384
|
+
var TYPE_BY_ID = new Map(
|
|
385
|
+
Object.keys(MsgType).map((k) => [MsgType[k], k])
|
|
386
|
+
);
|
|
387
|
+
function decodeMessage(buf, reassembled = false) {
|
|
388
|
+
const max = reassembled ? MAX_REASSEMBLED_BYTES : MAX_FRAME_BYTES;
|
|
389
|
+
if (buf.length > max) throw new CodecError("too_long", `${buf.length} > ${max}`);
|
|
390
|
+
if (buf.length < HEADER_BYTES) throw new CodecError("too_short", "header");
|
|
391
|
+
const r = new Reader(buf);
|
|
392
|
+
const type = TYPE_BY_ID.get(r.u8());
|
|
393
|
+
const route = r.u16();
|
|
394
|
+
if (!type) return null;
|
|
395
|
+
let msg;
|
|
396
|
+
switch (type) {
|
|
397
|
+
case "hi":
|
|
398
|
+
msg = { type, proto: r.u8(), peerId: r.u16(), lastTick: r.i64() };
|
|
399
|
+
break;
|
|
400
|
+
case "welcome_dc":
|
|
401
|
+
msg = { type, tick: r.i64(), tickRate: r.u8(), mode: r.u8(), hostId: r.u16() };
|
|
402
|
+
break;
|
|
403
|
+
case "cmd": {
|
|
404
|
+
const seq = r.u32();
|
|
405
|
+
msg = { type, seq, bytes: r.bytes(r.u16()) };
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
case "frame": {
|
|
409
|
+
const tick = r.i64();
|
|
410
|
+
const n = r.u16();
|
|
411
|
+
const commands = [];
|
|
412
|
+
for (let i = 0; i < n; i++) {
|
|
413
|
+
const peerId = r.u16();
|
|
414
|
+
commands.push({ peerId, bytes: r.bytes(r.u16()) });
|
|
415
|
+
}
|
|
416
|
+
msg = { type, tick, commands };
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
case "hash":
|
|
420
|
+
msg = { type, tick: r.i64(), hash: r.u64() };
|
|
421
|
+
break;
|
|
422
|
+
case "desync":
|
|
423
|
+
msg = { type, tick: r.i64(), hostHash: r.u64(), yourHash: r.u64() };
|
|
424
|
+
break;
|
|
425
|
+
case "snapshot_req":
|
|
426
|
+
msg = { type, sinceTick: r.i64() };
|
|
427
|
+
break;
|
|
428
|
+
case "snapshot":
|
|
429
|
+
msg = { type, tick: r.i64(), total: r.u32(), flags: r.u8() };
|
|
430
|
+
break;
|
|
431
|
+
case "chunk":
|
|
432
|
+
msg = { type, id: r.u32(), index: r.u16(), count: r.u16(), bytes: r.rest() };
|
|
433
|
+
break;
|
|
434
|
+
case "raw":
|
|
435
|
+
msg = { type, bytes: r.rest() };
|
|
436
|
+
break;
|
|
437
|
+
case "ping":
|
|
438
|
+
case "pong":
|
|
439
|
+
msg = { type, n: r.u32(), t: r.u32() };
|
|
440
|
+
break;
|
|
441
|
+
case "host_claim":
|
|
442
|
+
msg = { type, epoch: r.u16(), hostId: r.u16(), tick: r.i64() };
|
|
443
|
+
break;
|
|
444
|
+
case "voice": {
|
|
445
|
+
const seq = r.u16();
|
|
446
|
+
const flags = r.u8();
|
|
447
|
+
const n = r.u8();
|
|
448
|
+
const frames = [];
|
|
449
|
+
for (let i = 0; i < n; i++) frames.push(r.bytes(r.u16()));
|
|
450
|
+
msg = { type, seq, flags, frames };
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
case "chat": {
|
|
454
|
+
const id = r.u32();
|
|
455
|
+
const ts = r.i64();
|
|
456
|
+
msg = { type, id, ts, text: decodeText(r.bytes(r.u16())) };
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
case "chat_history": {
|
|
460
|
+
const n = r.u16();
|
|
461
|
+
const lines = [];
|
|
462
|
+
for (let i = 0; i < n; i++) {
|
|
463
|
+
const peerId = r.u16();
|
|
464
|
+
const id = r.u32();
|
|
465
|
+
const ts = r.i64();
|
|
466
|
+
lines.push({ peerId, id, ts, text: decodeText(r.bytes(r.u16())) });
|
|
467
|
+
}
|
|
468
|
+
msg = { type, lines };
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
case "chat_reject":
|
|
472
|
+
msg = { type, id: r.u32(), reason: r.u8() };
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
if (r.pos !== buf.length) throw new CodecError("trailing", `${buf.length - r.pos} extra bytes`);
|
|
476
|
+
return { route, msg };
|
|
477
|
+
}
|
|
478
|
+
var CHAT_REJECT = { rate: 1, length: 2, mutedByHost: 3 };
|
|
479
|
+
function chatRejectName(code) {
|
|
480
|
+
return code === 1 ? "rate" : code === 2 ? "length" : code === 3 ? "muted_by_host" : "unknown";
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// src/chat.ts
|
|
484
|
+
var CHAT_MAX_BYTES = 256;
|
|
485
|
+
var CHAT_BURST = 3;
|
|
486
|
+
var CHAT_WINDOW_MS = 5e3;
|
|
487
|
+
var CHAT_PER_WINDOW = 5;
|
|
488
|
+
var CHAT_HISTORY_LINES = 100;
|
|
489
|
+
var STRIP = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g;
|
|
490
|
+
function sanitizeChat(text) {
|
|
491
|
+
return text.replace(STRIP, "");
|
|
492
|
+
}
|
|
493
|
+
function utf8Length(text) {
|
|
494
|
+
return new TextEncoder().encode(text).length;
|
|
495
|
+
}
|
|
496
|
+
var ChatLimiter = class {
|
|
497
|
+
buckets = /* @__PURE__ */ new Map();
|
|
498
|
+
refillMs = CHAT_WINDOW_MS / CHAT_PER_WINDOW;
|
|
499
|
+
tryTake(peerId, now) {
|
|
500
|
+
let b = this.buckets.get(peerId);
|
|
501
|
+
if (!b) this.buckets.set(peerId, b = { tokens: CHAT_BURST, at: now });
|
|
502
|
+
b.tokens = Math.min(CHAT_BURST, b.tokens + (now - b.at) / this.refillMs);
|
|
503
|
+
b.at = now;
|
|
504
|
+
if (b.tokens < 1) return false;
|
|
505
|
+
b.tokens -= 1;
|
|
506
|
+
return true;
|
|
507
|
+
}
|
|
508
|
+
forget(peerId) {
|
|
509
|
+
this.buckets.delete(peerId);
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
var Chat = class extends Emitter {
|
|
513
|
+
constructor(hub, wall, isLocallyMuted) {
|
|
514
|
+
super();
|
|
515
|
+
this.hub = hub;
|
|
516
|
+
this.wall = wall;
|
|
517
|
+
this.isLocallyMuted = isLocallyMuted;
|
|
518
|
+
}
|
|
519
|
+
hub;
|
|
520
|
+
wall;
|
|
521
|
+
isLocallyMuted;
|
|
522
|
+
/** Game-provided profanity hook; applied to every line a player is shown. */
|
|
523
|
+
filter = (t) => t;
|
|
524
|
+
lines = [];
|
|
525
|
+
limiter = new ChatLimiter();
|
|
526
|
+
hostMuted = /* @__PURE__ */ new Set();
|
|
527
|
+
localId = 0;
|
|
528
|
+
stampId = 0;
|
|
529
|
+
get history() {
|
|
530
|
+
return this.lines;
|
|
531
|
+
}
|
|
532
|
+
/** Returns the local message id (what `rejected` will refer to). */
|
|
533
|
+
send(text) {
|
|
534
|
+
const id = ++this.localId >>> 0;
|
|
535
|
+
if (utf8Length(text) > CHAT_MAX_BYTES * 4) {
|
|
536
|
+
queueMicrotask(() => this.emit("rejected", id, "length"));
|
|
537
|
+
return id;
|
|
538
|
+
}
|
|
539
|
+
if (this.hub.isHost) this.hostProcess(this.hub.localPeerId, id, text);
|
|
540
|
+
else this.hub.sendToHost({ type: "chat", id, ts: 0, text });
|
|
541
|
+
return id;
|
|
542
|
+
}
|
|
543
|
+
/** Host only: drop a peer's chat for everyone (`chat_reject` reason 3). */
|
|
544
|
+
hostMute(peerId, muted) {
|
|
545
|
+
if (!this.hub.isHost) throw new Error("only the host can mute chat");
|
|
546
|
+
if (muted) this.hostMuted.add(peerId);
|
|
547
|
+
else this.hostMuted.delete(peerId);
|
|
548
|
+
}
|
|
549
|
+
isHostMuted(peerId) {
|
|
550
|
+
return this.hostMuted.has(peerId);
|
|
551
|
+
}
|
|
552
|
+
onPeerLeft(peerId) {
|
|
553
|
+
this.limiter.forget(peerId);
|
|
554
|
+
this.hostMuted.delete(peerId);
|
|
555
|
+
}
|
|
556
|
+
/** Host: send recent lines to a freshly connected peer. */
|
|
557
|
+
sendHistory(peerId) {
|
|
558
|
+
const lines = this.lines.slice(-CHAT_HISTORY_LINES).map((l) => ({ ...l }));
|
|
559
|
+
this.hub.sendTo(peerId, { type: "chat_history", lines }, 0);
|
|
560
|
+
}
|
|
561
|
+
handle(from, route, msg) {
|
|
562
|
+
switch (msg.type) {
|
|
563
|
+
case "chat":
|
|
564
|
+
if (this.hub.isHost) this.hostProcess(from, msg.id, msg.text);
|
|
565
|
+
else this.deliver({ peerId: route, id: msg.id, ts: msg.ts, text: msg.text });
|
|
566
|
+
break;
|
|
567
|
+
case "chat_history":
|
|
568
|
+
if (!this.hub.isHost) {
|
|
569
|
+
this.lines = [];
|
|
570
|
+
for (const l of msg.lines.slice(-CHAT_HISTORY_LINES)) this.push({ ...l, text: this.safeFilter(l.text) });
|
|
571
|
+
this.emit("history", this.lines);
|
|
572
|
+
}
|
|
573
|
+
break;
|
|
574
|
+
case "chat_reject":
|
|
575
|
+
this.emit("rejected", msg.id, chatRejectName(msg.reason));
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
// ---- host path ----------------------------------------------------------------------------------------------
|
|
580
|
+
hostProcess(from, id, rawText) {
|
|
581
|
+
const reject = (code) => {
|
|
582
|
+
if (from === this.hub.localPeerId) this.emit("rejected", id, chatRejectName(code));
|
|
583
|
+
else this.hub.sendTo(from, { type: "chat_reject", id, reason: code }, 0);
|
|
584
|
+
};
|
|
585
|
+
if (this.hostMuted.has(from)) return reject(CHAT_REJECT.mutedByHost);
|
|
586
|
+
if (utf8Length(rawText) > CHAT_MAX_BYTES) return reject(CHAT_REJECT.length);
|
|
587
|
+
if (!this.limiter.tryTake(from, this.hub.clock.now())) return reject(CHAT_REJECT.rate);
|
|
588
|
+
const text = sanitizeChat(rawText);
|
|
589
|
+
if (text.length === 0) return;
|
|
590
|
+
const line = { peerId: from, id: ++this.stampId >>> 0, ts: this.wall(), text };
|
|
591
|
+
const out = { type: "chat", id: line.id, ts: line.ts, text: line.text };
|
|
592
|
+
for (const p of this.hub.connectedPeers()) this.hub.sendTo(p, out, from);
|
|
593
|
+
this.deliver(line);
|
|
594
|
+
}
|
|
595
|
+
deliver(line) {
|
|
596
|
+
const shown = { ...line, text: this.safeFilter(line.text) };
|
|
597
|
+
this.push(shown);
|
|
598
|
+
if (this.isLocallyMuted(line.peerId)) return;
|
|
599
|
+
this.emit("message", shown);
|
|
600
|
+
}
|
|
601
|
+
push(line) {
|
|
602
|
+
if (this.isLocallyMuted(line.peerId)) return;
|
|
603
|
+
this.lines.push(line);
|
|
604
|
+
if (this.lines.length > CHAT_HISTORY_LINES) this.lines.splice(0, this.lines.length - CHAT_HISTORY_LINES);
|
|
605
|
+
}
|
|
606
|
+
safeFilter(text) {
|
|
607
|
+
try {
|
|
608
|
+
return this.filter(text);
|
|
609
|
+
} catch {
|
|
610
|
+
return text;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
// src/chunking.ts
|
|
616
|
+
var CHUNK_PAYLOAD = 16e3;
|
|
617
|
+
function splitChunks(id, bytes, size = CHUNK_PAYLOAD) {
|
|
618
|
+
const count = Math.max(1, Math.ceil(bytes.length / size));
|
|
619
|
+
if (count > 65535) throw new RangeError("payload needs more than 65535 chunks");
|
|
620
|
+
const out = [];
|
|
621
|
+
for (let i = 0; i < count; i++) {
|
|
622
|
+
out.push({
|
|
623
|
+
type: "chunk",
|
|
624
|
+
id,
|
|
625
|
+
index: i,
|
|
626
|
+
count,
|
|
627
|
+
bytes: bytes.subarray(i * size, Math.min(bytes.length, (i + 1) * size))
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
return out;
|
|
631
|
+
}
|
|
632
|
+
var Reassembler = class {
|
|
633
|
+
parts = /* @__PURE__ */ new Map();
|
|
634
|
+
/** Returns the full payload when the last chunk arrives, otherwise null. */
|
|
635
|
+
push(chunk) {
|
|
636
|
+
if (chunk.count === 0 || chunk.index >= chunk.count) return null;
|
|
637
|
+
let e = this.parts.get(chunk.id);
|
|
638
|
+
if (!e || e.count !== chunk.count) {
|
|
639
|
+
e = { count: chunk.count, got: 0, parts: new Array(chunk.count), bytes: 0 };
|
|
640
|
+
this.parts.set(chunk.id, e);
|
|
641
|
+
}
|
|
642
|
+
if (e.parts[chunk.index]) return null;
|
|
643
|
+
e.parts[chunk.index] = chunk.bytes;
|
|
644
|
+
e.got++;
|
|
645
|
+
e.bytes += chunk.bytes.length;
|
|
646
|
+
if (e.bytes > MAX_REASSEMBLED_BYTES) {
|
|
647
|
+
this.parts.delete(chunk.id);
|
|
648
|
+
throw new CodecError("too_long", `chunk set over ${MAX_REASSEMBLED_BYTES} bytes`);
|
|
649
|
+
}
|
|
650
|
+
if (e.got < e.count) return null;
|
|
651
|
+
this.parts.delete(chunk.id);
|
|
652
|
+
const out = new Uint8Array(e.bytes);
|
|
653
|
+
let pos = 0;
|
|
654
|
+
for (const p of e.parts) {
|
|
655
|
+
out.set(p, pos);
|
|
656
|
+
pos += p.length;
|
|
657
|
+
}
|
|
658
|
+
return out;
|
|
659
|
+
}
|
|
660
|
+
get pending() {
|
|
661
|
+
return this.parts.size;
|
|
662
|
+
}
|
|
663
|
+
clear() {
|
|
664
|
+
this.parts.clear();
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
// src/deflate.ts
|
|
669
|
+
function deflateAvailable() {
|
|
670
|
+
return typeof CompressionStream !== "undefined" && typeof DecompressionStream !== "undefined";
|
|
671
|
+
}
|
|
672
|
+
async function pipe(bytes, stream) {
|
|
673
|
+
const w = stream.writable.getWriter();
|
|
674
|
+
void w.write(bytes).then(() => w.close());
|
|
675
|
+
const chunks = [];
|
|
676
|
+
const r = stream.readable.getReader();
|
|
677
|
+
let total = 0;
|
|
678
|
+
for (; ; ) {
|
|
679
|
+
const { done, value } = await r.read();
|
|
680
|
+
if (done) break;
|
|
681
|
+
chunks.push(value);
|
|
682
|
+
total += value.length;
|
|
683
|
+
}
|
|
684
|
+
const out = new Uint8Array(total);
|
|
685
|
+
let pos = 0;
|
|
686
|
+
for (const c of chunks) {
|
|
687
|
+
out.set(c, pos);
|
|
688
|
+
pos += c.length;
|
|
689
|
+
}
|
|
690
|
+
return out;
|
|
691
|
+
}
|
|
692
|
+
function deflate(bytes) {
|
|
693
|
+
return pipe(bytes, new CompressionStream("deflate"));
|
|
694
|
+
}
|
|
695
|
+
function inflate(bytes) {
|
|
696
|
+
return pipe(bytes, new DecompressionStream("deflate"));
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// src/lockstep.ts
|
|
700
|
+
var MAX_DESYNCS = 3;
|
|
701
|
+
var MAX_RESTORE_RETRIES = 3;
|
|
702
|
+
var HOST_SILENCE_MS = 3e3;
|
|
703
|
+
var PEER_HASH_SILENCE_MS = 1e4;
|
|
704
|
+
var SNAPSHOT_COMPRESS_MIN = 1024;
|
|
705
|
+
var Lockstep = class extends Emitter {
|
|
706
|
+
constructor(hub, opts) {
|
|
707
|
+
super();
|
|
708
|
+
this.hub = hub;
|
|
709
|
+
this.hashEveryTicks = Math.max(1, opts.hashEveryTicks);
|
|
710
|
+
this.historyMax = opts.historyFrames ?? Math.max(60, hub.tickRate * 30);
|
|
711
|
+
this.lastFrameRx = hub.clock.now();
|
|
712
|
+
}
|
|
713
|
+
hub;
|
|
714
|
+
snapshot = null;
|
|
715
|
+
restore = null;
|
|
716
|
+
hashEveryTicks;
|
|
717
|
+
/** Chunk body size used when serving snapshots. */
|
|
718
|
+
chunkSize = CHUNK_PAYLOAD;
|
|
719
|
+
historyMax;
|
|
720
|
+
queue = [];
|
|
721
|
+
history = [];
|
|
722
|
+
/** Highest frame tick received/emitted; -1 = no state (a fresh joiner). */
|
|
723
|
+
lastFrameTick = -1;
|
|
724
|
+
/** Tick of the state the game holds (last frame returned by nextFrame, or a restored snapshot). */
|
|
725
|
+
consumedTick = 0;
|
|
726
|
+
cmdSeq = 1;
|
|
727
|
+
awaitingSnapshot = false;
|
|
728
|
+
lastFrameRx = 0;
|
|
729
|
+
lastSnapshotReq = -Infinity;
|
|
730
|
+
restoreFailures = 0;
|
|
731
|
+
// host side
|
|
732
|
+
hosting = false;
|
|
733
|
+
_started = false;
|
|
734
|
+
pending = [];
|
|
735
|
+
ready = /* @__PURE__ */ new Set();
|
|
736
|
+
snapshotting = /* @__PURE__ */ new Set();
|
|
737
|
+
cancelTicker = null;
|
|
738
|
+
baseTime = 0;
|
|
739
|
+
emitted = 0;
|
|
740
|
+
ownHashes = /* @__PURE__ */ new Map();
|
|
741
|
+
peerHashes = /* @__PURE__ */ new Map();
|
|
742
|
+
desyncs = /* @__PURE__ */ new Map();
|
|
743
|
+
lastHashRx = /* @__PURE__ */ new Map();
|
|
744
|
+
/** Hashes a peer sent for ticks up to this were computed before it restored a snapshot: stale, not evidence. */
|
|
745
|
+
staleUpTo = /* @__PURE__ */ new Map();
|
|
746
|
+
// joiner side
|
|
747
|
+
pendingSnapshot = null;
|
|
748
|
+
// ---- public game-facing surface -------------------------------------------------------------------------------
|
|
749
|
+
get started() {
|
|
750
|
+
return this._started;
|
|
751
|
+
}
|
|
752
|
+
get framesBuffered() {
|
|
753
|
+
return this.queue.length;
|
|
754
|
+
}
|
|
755
|
+
/** True when the game should run ticks without rendering (> 2 frames behind). */
|
|
756
|
+
get catchingUp() {
|
|
757
|
+
return this.queue.length > 2;
|
|
758
|
+
}
|
|
759
|
+
/** Tick of the game's current state. */
|
|
760
|
+
get currentTick() {
|
|
761
|
+
return this.consumedTick;
|
|
762
|
+
}
|
|
763
|
+
/** Highest frame tick known to this peer (0 when none yet; the host's `welcome_dc.tick`). */
|
|
764
|
+
get lastTick() {
|
|
765
|
+
return Math.max(0, this.lastFrameTick);
|
|
766
|
+
}
|
|
767
|
+
/** What `hi.lastTick` carries: -1 until the first frame/snapshot. */
|
|
768
|
+
get hiTick() {
|
|
769
|
+
return this.lastFrameTick;
|
|
770
|
+
}
|
|
771
|
+
/** Queue a command. Host: into its own next frame. Joiner: to the host (reliable). */
|
|
772
|
+
submit(bytes) {
|
|
773
|
+
if (this.hosting) {
|
|
774
|
+
this.pending.push({ peerId: this.hub.localPeerId, bytes: bytes.slice() });
|
|
775
|
+
} else {
|
|
776
|
+
this.hub.sendToHost({ type: "cmd", seq: this.cmdSeq++ >>> 0, bytes });
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
nextFrame() {
|
|
780
|
+
const f = this.queue.shift() ?? null;
|
|
781
|
+
if (f) this.consumedTick = f.tick;
|
|
782
|
+
return f;
|
|
783
|
+
}
|
|
784
|
+
reportHash(tick, hash) {
|
|
785
|
+
if (this.awaitingSnapshot || tick <= 0 || tick % this.hashEveryTicks !== 0) return;
|
|
786
|
+
if (this.hosting) {
|
|
787
|
+
this.ownHashes.set(tick, hash);
|
|
788
|
+
this.prune(this.ownHashes);
|
|
789
|
+
const theirs = this.peerHashes.get(tick);
|
|
790
|
+
if (theirs) {
|
|
791
|
+
this.peerHashes.delete(tick);
|
|
792
|
+
for (const [peer, h] of [...theirs].sort((a, b) => a[0] - b[0])) this.compare(peer, tick, hash, h);
|
|
793
|
+
}
|
|
794
|
+
} else {
|
|
795
|
+
this.hub.sendToHost({ type: "hash", tick, hash });
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
// ---- role control (called by Lobby) ---------------------------------------------------------------------------
|
|
799
|
+
/** Host role without emitting yet (lobby phase): peers may already say hi. */
|
|
800
|
+
hostIdle() {
|
|
801
|
+
this.hosting = true;
|
|
802
|
+
if (this.lastFrameTick < 0) this.lastFrameTick = 0;
|
|
803
|
+
}
|
|
804
|
+
/** Host begins emitting frames (game start, or after winning a migration), continuing after the last known tick. */
|
|
805
|
+
hostStart() {
|
|
806
|
+
this.hostIdle();
|
|
807
|
+
this._started = true;
|
|
808
|
+
this.emitted = 0;
|
|
809
|
+
this.baseTime = this.hub.clock.now();
|
|
810
|
+
this.cancelTicker?.();
|
|
811
|
+
const interval = 1e3 / this.hub.tickRate;
|
|
812
|
+
this.cancelTicker = this.hub.clock.setInterval(() => this.pump(interval), Math.max(1, Math.floor(interval / 2)));
|
|
813
|
+
}
|
|
814
|
+
/** Winning a migration: continue from our last received frame. Pending commands are lost by design. */
|
|
815
|
+
becomeHost() {
|
|
816
|
+
this.pending = [];
|
|
817
|
+
this.ready.clear();
|
|
818
|
+
this.snapshotting.clear();
|
|
819
|
+
this.ownHashes.clear();
|
|
820
|
+
this.peerHashes.clear();
|
|
821
|
+
this.desyncs.clear();
|
|
822
|
+
this.awaitingSnapshot = false;
|
|
823
|
+
this.hostIdle();
|
|
824
|
+
if (this._started) this.hostStart();
|
|
825
|
+
}
|
|
826
|
+
/** Losing a claim, or a new host appeared: stop emitting. Keep history; the new host decides replay vs snapshot. */
|
|
827
|
+
becomeJoiner() {
|
|
828
|
+
this.hosting = false;
|
|
829
|
+
this.cancelTicker?.();
|
|
830
|
+
this.cancelTicker = null;
|
|
831
|
+
this.pending = [];
|
|
832
|
+
this.ready.clear();
|
|
833
|
+
this.lastFrameRx = this.hub.clock.now();
|
|
834
|
+
}
|
|
835
|
+
/** A joiner just learned the session is in game (first frame, snapshot, or phase from the server). */
|
|
836
|
+
markStarted() {
|
|
837
|
+
if (this._started) return;
|
|
838
|
+
this._started = true;
|
|
839
|
+
this.emit("started");
|
|
840
|
+
}
|
|
841
|
+
stop() {
|
|
842
|
+
this.cancelTicker?.();
|
|
843
|
+
this.cancelTicker = null;
|
|
844
|
+
}
|
|
845
|
+
onPeerLeft(peerId) {
|
|
846
|
+
this.ready.delete(peerId);
|
|
847
|
+
this.snapshotting.delete(peerId);
|
|
848
|
+
this.desyncs.delete(peerId);
|
|
849
|
+
this.lastHashRx.delete(peerId);
|
|
850
|
+
this.staleUpTo.delete(peerId);
|
|
851
|
+
for (const m of this.peerHashes.values()) m.delete(peerId);
|
|
852
|
+
this.pending = this.pending.filter((c) => c.peerId !== peerId);
|
|
853
|
+
}
|
|
854
|
+
// ---- host: frame emission ---------------------------------------------------------------------------------------
|
|
855
|
+
pump(interval) {
|
|
856
|
+
if (!this.hosting) return;
|
|
857
|
+
const now = this.hub.clock.now();
|
|
858
|
+
let due = Math.floor((now - this.baseTime + 1) / interval) - this.emitted;
|
|
859
|
+
if (due > 10) {
|
|
860
|
+
this.baseTime = now - this.emitted * interval;
|
|
861
|
+
due = 1;
|
|
862
|
+
}
|
|
863
|
+
for (let i = 0; i < due; i++) this.emitFrame();
|
|
864
|
+
}
|
|
865
|
+
emitFrame() {
|
|
866
|
+
const commands = this.pending.splice(0).sort((a, b) => a.peerId - b.peerId);
|
|
867
|
+
const frame = { tick: this.lastFrameTick + 1, commands };
|
|
868
|
+
this.emitted++;
|
|
869
|
+
this.lastFrameTick = frame.tick;
|
|
870
|
+
this.record(frame);
|
|
871
|
+
this.queue.push(frame);
|
|
872
|
+
const msg = { type: "frame", tick: frame.tick, commands };
|
|
873
|
+
for (const p of this.ready) this.hub.sendTo(p, msg, 0);
|
|
874
|
+
this.checkPeerSilence();
|
|
875
|
+
}
|
|
876
|
+
record(f) {
|
|
877
|
+
this.history.push(f);
|
|
878
|
+
if (this.history.length > this.historyMax) this.history.splice(0, this.history.length - this.historyMax);
|
|
879
|
+
}
|
|
880
|
+
/** The host drops a peer it has had no `hash` from for 10 s (scaled up when hashes are rare). */
|
|
881
|
+
checkPeerSilence() {
|
|
882
|
+
if (this.ownHashes.size === 0) return;
|
|
883
|
+
const now = this.hub.clock.now();
|
|
884
|
+
const limit = Math.max(PEER_HASH_SILENCE_MS, this.hashEveryTicks / this.hub.tickRate * 1e3 * 3);
|
|
885
|
+
for (const p of [...this.ready]) {
|
|
886
|
+
const last = this.lastHashRx.get(p) ?? now;
|
|
887
|
+
if (now - last > limit) this.hub.dropPeer(p, "timeout");
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
// ---- message handling -------------------------------------------------------------------------------------------
|
|
891
|
+
handle(from, msg) {
|
|
892
|
+
switch (msg.type) {
|
|
893
|
+
case "cmd":
|
|
894
|
+
if (this.hosting) this.pending.push({ peerId: from, bytes: msg.bytes });
|
|
895
|
+
break;
|
|
896
|
+
case "frame":
|
|
897
|
+
if (!this.hosting) this.onFrame(msg.tick, msg.commands);
|
|
898
|
+
break;
|
|
899
|
+
case "hash":
|
|
900
|
+
if (this.hosting) this.onPeerHash(from, msg.tick, msg.hash);
|
|
901
|
+
break;
|
|
902
|
+
case "desync":
|
|
903
|
+
if (!this.hosting) {
|
|
904
|
+
this.awaitingSnapshot = true;
|
|
905
|
+
this.queue = [];
|
|
906
|
+
this.emit("desync", { tick: msg.tick, hostHash: msg.hostHash, yourHash: msg.yourHash });
|
|
907
|
+
}
|
|
908
|
+
break;
|
|
909
|
+
case "snapshot_req":
|
|
910
|
+
if (this.hosting) void this.sendSnapshot(from);
|
|
911
|
+
break;
|
|
912
|
+
case "snapshot":
|
|
913
|
+
if (!this.hosting) {
|
|
914
|
+
this.markStarted();
|
|
915
|
+
this.pendingSnapshot = { tick: msg.tick, total: msg.total, flags: msg.flags };
|
|
916
|
+
}
|
|
917
|
+
break;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
/** True when a `snapshot` header was seen and its chunk set has not completed yet. */
|
|
921
|
+
get snapshotPending() {
|
|
922
|
+
return this.pendingSnapshot !== null;
|
|
923
|
+
}
|
|
924
|
+
/** The reassembled chunk set that follows a `snapshot` header. */
|
|
925
|
+
onSnapshotPayload(payload) {
|
|
926
|
+
const ps = this.pendingSnapshot;
|
|
927
|
+
this.pendingSnapshot = null;
|
|
928
|
+
if (!ps) return;
|
|
929
|
+
if (payload.length !== ps.total) {
|
|
930
|
+
this.awaitingSnapshot = false;
|
|
931
|
+
this.requestSnapshot();
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
void this.applySnapshot(ps, payload);
|
|
935
|
+
}
|
|
936
|
+
onFrame(tick, commands) {
|
|
937
|
+
this.lastFrameRx = this.hub.clock.now();
|
|
938
|
+
this.markStarted();
|
|
939
|
+
if (this.awaitingSnapshot) return;
|
|
940
|
+
const expected = Math.max(0, this.lastFrameTick) + 1;
|
|
941
|
+
if (tick < expected) return;
|
|
942
|
+
if (tick > expected) {
|
|
943
|
+
this.requestSnapshot();
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
const f = { tick, commands };
|
|
947
|
+
this.lastFrameTick = tick;
|
|
948
|
+
this.record(f);
|
|
949
|
+
this.queue.push(f);
|
|
950
|
+
if (this.queue.length > this.hub.tickRate * 3) this.requestSnapshot();
|
|
951
|
+
}
|
|
952
|
+
/** Joiner asks the host for a snapshot (far behind, gap, silent host). Throttled while one is outstanding. */
|
|
953
|
+
requestSnapshot() {
|
|
954
|
+
const now = this.hub.clock.now();
|
|
955
|
+
if (this.awaitingSnapshot && now - this.lastSnapshotReq < 2e3) return;
|
|
956
|
+
this.lastSnapshotReq = now;
|
|
957
|
+
this.awaitingSnapshot = true;
|
|
958
|
+
this.queue = [];
|
|
959
|
+
this.hub.sendToHost({ type: "snapshot_req", sinceTick: this.lastFrameTick });
|
|
960
|
+
}
|
|
961
|
+
/** 3 s without frames: ask the host for a snapshot without discarding anything (a live host answers). */
|
|
962
|
+
pokeHost() {
|
|
963
|
+
this.hub.sendToHost({ type: "snapshot_req", sinceTick: this.lastFrameTick });
|
|
964
|
+
}
|
|
965
|
+
async applySnapshot(ps, payload) {
|
|
966
|
+
try {
|
|
967
|
+
let bytes = payload;
|
|
968
|
+
if (ps.flags & 1) bytes = await inflate(payload);
|
|
969
|
+
if (!this.restore) throw new Error("lockstep.restore is not set; cannot apply a snapshot");
|
|
970
|
+
this.restore(ps.tick, bytes);
|
|
971
|
+
this.queue = [];
|
|
972
|
+
this.history = [];
|
|
973
|
+
this.lastFrameTick = ps.tick;
|
|
974
|
+
this.consumedTick = ps.tick;
|
|
975
|
+
this.awaitingSnapshot = false;
|
|
976
|
+
this.restoreFailures = 0;
|
|
977
|
+
this.markStarted();
|
|
978
|
+
this.lastFrameRx = this.hub.clock.now();
|
|
979
|
+
this.emit("restored", ps.tick);
|
|
980
|
+
} catch (e) {
|
|
981
|
+
this.hub.reportError(e instanceof Error ? e : new Error(String(e)));
|
|
982
|
+
if (++this.restoreFailures <= MAX_RESTORE_RETRIES) {
|
|
983
|
+
this.awaitingSnapshot = false;
|
|
984
|
+
this.lastSnapshotReq = -Infinity;
|
|
985
|
+
this.requestSnapshot();
|
|
986
|
+
} else {
|
|
987
|
+
this.hub.reportError(new Error(`snapshot restore failed ${this.restoreFailures} times; giving up`));
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
// ---- host: hello, hashes, snapshots -----------------------------------------------------------------------------
|
|
992
|
+
/** Host: a peer said hi (welcome_dc and chat_history already sent). Decide: nothing, replay, or snapshot. */
|
|
993
|
+
onPeerHi(peerId, lastTick) {
|
|
994
|
+
this.lastHashRx.set(peerId, this.hub.clock.now());
|
|
995
|
+
const hostTick = this.lastFrameTick;
|
|
996
|
+
const first = this.history.length ? this.history[0].tick : hostTick + 1;
|
|
997
|
+
if (lastTick < 0) {
|
|
998
|
+
if (this._started && hostTick > 0) void this.sendSnapshot(peerId);
|
|
999
|
+
else this.ready.add(peerId);
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
if (lastTick <= hostTick && lastTick + 1 >= first) {
|
|
1003
|
+
for (const f of this.history) {
|
|
1004
|
+
if (f.tick > lastTick) this.hub.sendTo(peerId, { type: "frame", tick: f.tick, commands: f.commands }, 0);
|
|
1005
|
+
}
|
|
1006
|
+
this.ready.add(peerId);
|
|
1007
|
+
} else {
|
|
1008
|
+
void this.sendSnapshot(peerId);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
onPeerHash(peerId, tick, hash) {
|
|
1012
|
+
this.lastHashRx.set(peerId, this.hub.clock.now());
|
|
1013
|
+
if (this.isStale(peerId, tick)) return;
|
|
1014
|
+
const own = this.ownHashes.get(tick);
|
|
1015
|
+
if (own === void 0) {
|
|
1016
|
+
let m = this.peerHashes.get(tick);
|
|
1017
|
+
if (!m) this.peerHashes.set(tick, m = /* @__PURE__ */ new Map());
|
|
1018
|
+
m.set(peerId, hash);
|
|
1019
|
+
this.prune(this.peerHashes);
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
this.compare(peerId, tick, own, hash);
|
|
1023
|
+
}
|
|
1024
|
+
compare(peerId, tick, hostHash, yourHash) {
|
|
1025
|
+
if (hostHash === yourHash) return;
|
|
1026
|
+
if (this.isStale(peerId, tick)) return;
|
|
1027
|
+
if (!this.ready.has(peerId) || this.snapshotting.has(peerId)) return;
|
|
1028
|
+
const n = (this.desyncs.get(peerId) ?? 0) + 1;
|
|
1029
|
+
this.desyncs.set(peerId, n);
|
|
1030
|
+
if (n >= MAX_DESYNCS) {
|
|
1031
|
+
this.hub.dropPeer(peerId, "desync");
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
this.hub.sendTo(peerId, { type: "desync", tick, hostHash, yourHash }, 0);
|
|
1035
|
+
void this.sendSnapshot(peerId, tick);
|
|
1036
|
+
}
|
|
1037
|
+
/** Hashes computed before a peer restored its snapshot (ticks after the strike, up to what we had emitted) are not evidence. */
|
|
1038
|
+
isStale(peerId, tick) {
|
|
1039
|
+
const w = this.staleUpTo.get(peerId);
|
|
1040
|
+
return !!w && tick > w.from && tick <= w.to;
|
|
1041
|
+
}
|
|
1042
|
+
async sendSnapshot(peerId, strikeTick = Infinity) {
|
|
1043
|
+
if (this.snapshotting.has(peerId)) return;
|
|
1044
|
+
if (!this.snapshot) {
|
|
1045
|
+
this.hub.reportError(new Error("lockstep.snapshot is not set; cannot serve a snapshot"));
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
this.snapshotting.add(peerId);
|
|
1049
|
+
this.ready.delete(peerId);
|
|
1050
|
+
try {
|
|
1051
|
+
const S = this.consumedTick;
|
|
1052
|
+
const raw = this.snapshot();
|
|
1053
|
+
let payload = raw;
|
|
1054
|
+
let flags = 0;
|
|
1055
|
+
if (raw.length >= SNAPSHOT_COMPRESS_MIN && deflateAvailable()) {
|
|
1056
|
+
payload = await deflate(raw);
|
|
1057
|
+
flags = 1;
|
|
1058
|
+
}
|
|
1059
|
+
if (!this.snapshotting.has(peerId)) return;
|
|
1060
|
+
this.hub.sendTo(peerId, { type: "snapshot", tick: S, total: payload.length, flags }, 0);
|
|
1061
|
+
for (const c of splitChunks(this.hub.nextChunkId(), payload, this.chunkSize)) this.hub.sendTo(peerId, c, 0);
|
|
1062
|
+
for (const f of this.history) {
|
|
1063
|
+
if (f.tick > S) this.hub.sendTo(peerId, { type: "frame", tick: f.tick, commands: f.commands }, 0);
|
|
1064
|
+
}
|
|
1065
|
+
if (strikeTick !== Infinity) this.staleUpTo.set(peerId, { from: strikeTick, to: this.lastFrameTick });
|
|
1066
|
+
else this.staleUpTo.delete(peerId);
|
|
1067
|
+
for (const m of this.peerHashes.values()) m.delete(peerId);
|
|
1068
|
+
this.ready.add(peerId);
|
|
1069
|
+
this.lastHashRx.set(peerId, this.hub.clock.now());
|
|
1070
|
+
} catch (e) {
|
|
1071
|
+
this.hub.reportError(e instanceof Error ? e : new Error(String(e)));
|
|
1072
|
+
} finally {
|
|
1073
|
+
this.snapshotting.delete(peerId);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
prune(m) {
|
|
1077
|
+
if (m.size <= 64) return;
|
|
1078
|
+
const keys = [...m.keys()].sort((a, b) => a - b);
|
|
1079
|
+
for (const k of keys.slice(0, keys.length - 64)) m.delete(k);
|
|
1080
|
+
}
|
|
1081
|
+
// ---- joiner watchdog --------------------------------------------------------------------------------------------
|
|
1082
|
+
/** Joiner: ms since the last frame from the host (only meaningful once in game). */
|
|
1083
|
+
get msSinceFrame() {
|
|
1084
|
+
return this.hub.clock.now() - this.lastFrameRx;
|
|
1085
|
+
}
|
|
1086
|
+
/** True when a started joiner has heard no frames for 3 s: the Lobby then runs the host-loss rule (s.9). */
|
|
1087
|
+
hostSilent() {
|
|
1088
|
+
return !this.hosting && this._started && this.msSinceFrame > HOST_SILENCE_MS;
|
|
1089
|
+
}
|
|
1090
|
+
/** Reset the silence timer, e.g. after the link to a new host opened. */
|
|
1091
|
+
touch() {
|
|
1092
|
+
this.lastFrameRx = this.hub.clock.now();
|
|
1093
|
+
}
|
|
1094
|
+
hiMessage(peerId) {
|
|
1095
|
+
return { type: "hi", proto: PROTO, peerId, lastTick: this.lastFrameTick };
|
|
1096
|
+
}
|
|
1097
|
+
get resyncCount() {
|
|
1098
|
+
return this.desyncs;
|
|
1099
|
+
}
|
|
1100
|
+
};
|
|
1101
|
+
|
|
1102
|
+
// src/migration.ts
|
|
1103
|
+
function pickNewHost(connected, failedHost) {
|
|
1104
|
+
let best = null;
|
|
1105
|
+
for (const id of connected) {
|
|
1106
|
+
if (id === failedHost) continue;
|
|
1107
|
+
if (best === null || id < best) best = id;
|
|
1108
|
+
}
|
|
1109
|
+
return best;
|
|
1110
|
+
}
|
|
1111
|
+
function epochAfter(a, b) {
|
|
1112
|
+
const d = a - b & 65535;
|
|
1113
|
+
return d !== 0 && d < 32768;
|
|
1114
|
+
}
|
|
1115
|
+
function claimWinsWrapped(a, b) {
|
|
1116
|
+
if (a.epoch !== b.epoch) return epochAfter(a.epoch, b.epoch);
|
|
1117
|
+
return a.hostId < b.hostId;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// src/signaling.ts
|
|
1121
|
+
var OneLobbyError = class extends Error {
|
|
1122
|
+
constructor(code, message, data) {
|
|
1123
|
+
super(message ? `${code}: ${message}` : code);
|
|
1124
|
+
this.code = code;
|
|
1125
|
+
this.data = data;
|
|
1126
|
+
this.name = "OneLobbyError";
|
|
1127
|
+
}
|
|
1128
|
+
code;
|
|
1129
|
+
data;
|
|
1130
|
+
};
|
|
1131
|
+
var PROTO_ID = "lk1";
|
|
1132
|
+
var PING_EVERY_MS = 1e4;
|
|
1133
|
+
var SILENCE_MS = 3e4;
|
|
1134
|
+
var SignalingClient = class {
|
|
1135
|
+
constructor(url, factory, clock) {
|
|
1136
|
+
this.url = url;
|
|
1137
|
+
this.factory = factory;
|
|
1138
|
+
this.clock = clock;
|
|
1139
|
+
}
|
|
1140
|
+
url;
|
|
1141
|
+
factory;
|
|
1142
|
+
clock;
|
|
1143
|
+
socket = null;
|
|
1144
|
+
waiters = [];
|
|
1145
|
+
lastRx = 0;
|
|
1146
|
+
cancelPing = null;
|
|
1147
|
+
pingN = 0;
|
|
1148
|
+
closed = false;
|
|
1149
|
+
onMessage = null;
|
|
1150
|
+
onClose = null;
|
|
1151
|
+
connect() {
|
|
1152
|
+
return new Promise((resolve, reject) => {
|
|
1153
|
+
let s;
|
|
1154
|
+
try {
|
|
1155
|
+
s = this.factory(this.url);
|
|
1156
|
+
} catch (e) {
|
|
1157
|
+
reject(new OneLobbyError("connect_failed", String(e)));
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
this.socket = s;
|
|
1161
|
+
s.onopen = () => {
|
|
1162
|
+
this.lastRx = this.clock.now();
|
|
1163
|
+
this.cancelPing = this.clock.setInterval(() => this.tick(), PING_EVERY_MS);
|
|
1164
|
+
resolve();
|
|
1165
|
+
};
|
|
1166
|
+
s.onerror = () => {
|
|
1167
|
+
if (!this.lastRx) reject(new OneLobbyError("connect_failed", `cannot reach ${this.url}`));
|
|
1168
|
+
};
|
|
1169
|
+
s.onmessage = (ev) => {
|
|
1170
|
+
this.lastRx = this.clock.now();
|
|
1171
|
+
let m;
|
|
1172
|
+
try {
|
|
1173
|
+
m = JSON.parse(String(ev.data));
|
|
1174
|
+
} catch {
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
if (!m || typeof m.t !== "string") return;
|
|
1178
|
+
if (m.t === "ping") {
|
|
1179
|
+
this.send({ t: "pong", n: m.n });
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
if (m.t === "pong") return;
|
|
1183
|
+
const i = this.waiters.findIndex((w) => w.types.includes(m.t) || m.t === "error");
|
|
1184
|
+
if (i >= 0) {
|
|
1185
|
+
const [w] = this.waiters.splice(i, 1);
|
|
1186
|
+
if (m.t === "error") w.reject(errorFrom(m));
|
|
1187
|
+
else w.resolve(m);
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
this.onMessage?.(m);
|
|
1191
|
+
};
|
|
1192
|
+
s.onclose = () => {
|
|
1193
|
+
if (!this.lastRx) reject(new OneLobbyError("connect_failed", `cannot reach ${this.url}`));
|
|
1194
|
+
this.shutdown();
|
|
1195
|
+
};
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
tick() {
|
|
1199
|
+
if (this.clock.now() - this.lastRx > SILENCE_MS) {
|
|
1200
|
+
this.socket?.close(4e3, "silence");
|
|
1201
|
+
this.shutdown();
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
this.send({ t: "ping", n: ++this.pingN });
|
|
1205
|
+
}
|
|
1206
|
+
shutdown() {
|
|
1207
|
+
if (this.closed) return;
|
|
1208
|
+
this.closed = true;
|
|
1209
|
+
this.cancelPing?.();
|
|
1210
|
+
const err = new OneLobbyError("closed", "signaling connection closed");
|
|
1211
|
+
for (const w of this.waiters.splice(0)) w.reject(err);
|
|
1212
|
+
this.onClose?.();
|
|
1213
|
+
}
|
|
1214
|
+
get isOpen() {
|
|
1215
|
+
return !this.closed && this.socket !== null && this.socket.readyState === 1;
|
|
1216
|
+
}
|
|
1217
|
+
send(m) {
|
|
1218
|
+
if (!this.isOpen) return;
|
|
1219
|
+
this.socket.send(JSON.stringify(m));
|
|
1220
|
+
}
|
|
1221
|
+
/** Wait for the next message of one of `types`, or reject on `error`/close. */
|
|
1222
|
+
expect(...types) {
|
|
1223
|
+
if (this.closed) return Promise.reject(new OneLobbyError("closed", "signaling connection closed"));
|
|
1224
|
+
return new Promise((resolve, reject) => {
|
|
1225
|
+
this.waiters.push({ types, resolve, reject });
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
async request(m, ...types) {
|
|
1229
|
+
const p = this.expect(...types);
|
|
1230
|
+
this.send(m);
|
|
1231
|
+
return p;
|
|
1232
|
+
}
|
|
1233
|
+
close() {
|
|
1234
|
+
this.socket?.close(1e3, "bye");
|
|
1235
|
+
this.shutdown();
|
|
1236
|
+
}
|
|
1237
|
+
};
|
|
1238
|
+
function errorFrom(m) {
|
|
1239
|
+
return new OneLobbyError(String(m.code ?? "error"), m.msg === void 0 ? void 0 : String(m.msg), m.data);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// src/voice-core.ts
|
|
1243
|
+
var SAMPLE_RATE = 48e3;
|
|
1244
|
+
var FRAME_MS = 20;
|
|
1245
|
+
var FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1e3;
|
|
1246
|
+
var FRAMES_PER_PACKET = 2;
|
|
1247
|
+
var MAX_VOICE_PACKET_BYTES = 1024;
|
|
1248
|
+
var MAX_SPEAKERS = 8;
|
|
1249
|
+
var MAX_PACKETS_PER_SEC = 30;
|
|
1250
|
+
var JITTER_MS = 60;
|
|
1251
|
+
var VAD_HANGOVER_MS = 300;
|
|
1252
|
+
var FLAG_END_OF_BURST = 1;
|
|
1253
|
+
var VoicePacketizer = class {
|
|
1254
|
+
constructor(framesPerPacket = FRAMES_PER_PACKET) {
|
|
1255
|
+
this.framesPerPacket = framesPerPacket;
|
|
1256
|
+
}
|
|
1257
|
+
framesPerPacket;
|
|
1258
|
+
seq = 0;
|
|
1259
|
+
buf = [];
|
|
1260
|
+
push(frame) {
|
|
1261
|
+
this.buf.push(frame);
|
|
1262
|
+
if (this.buf.length < this.framesPerPacket) return null;
|
|
1263
|
+
return this.take(0);
|
|
1264
|
+
}
|
|
1265
|
+
/** Wrap already-encoded frames in the next packet (no batching). */
|
|
1266
|
+
packet(frames, flags) {
|
|
1267
|
+
const p = { seq: this.seq, flags, frames };
|
|
1268
|
+
this.seq = this.seq + 1 & 65535;
|
|
1269
|
+
return p;
|
|
1270
|
+
}
|
|
1271
|
+
/** Always emits a packet (possibly with zero frames) carrying the end-of-burst flag. */
|
|
1272
|
+
endBurst() {
|
|
1273
|
+
return this.take(FLAG_END_OF_BURST);
|
|
1274
|
+
}
|
|
1275
|
+
get buffered() {
|
|
1276
|
+
return this.buf.length;
|
|
1277
|
+
}
|
|
1278
|
+
take(flags) {
|
|
1279
|
+
const p = { seq: this.seq, flags, frames: this.buf };
|
|
1280
|
+
this.seq = this.seq + 1 & 65535;
|
|
1281
|
+
this.buf = [];
|
|
1282
|
+
return p;
|
|
1283
|
+
}
|
|
1284
|
+
};
|
|
1285
|
+
function rms(pcm) {
|
|
1286
|
+
if (pcm.length === 0) return 0;
|
|
1287
|
+
let s = 0;
|
|
1288
|
+
for (let i = 0; i < pcm.length; i++) s += pcm[i] * pcm[i];
|
|
1289
|
+
return Math.sqrt(s / pcm.length);
|
|
1290
|
+
}
|
|
1291
|
+
var VoiceGate = class {
|
|
1292
|
+
mode = "pushToTalk";
|
|
1293
|
+
ptt = false;
|
|
1294
|
+
vadThreshold = 0.02;
|
|
1295
|
+
lastLoud = -Infinity;
|
|
1296
|
+
open = false;
|
|
1297
|
+
/** Returns { send, edge } where edge is 'open' | 'close' | null on a transition. */
|
|
1298
|
+
update(level, nowMs) {
|
|
1299
|
+
let want;
|
|
1300
|
+
if (this.mode === "pushToTalk") {
|
|
1301
|
+
want = this.ptt;
|
|
1302
|
+
} else {
|
|
1303
|
+
if (level >= this.vadThreshold) this.lastLoud = nowMs;
|
|
1304
|
+
want = nowMs - this.lastLoud <= VAD_HANGOVER_MS;
|
|
1305
|
+
}
|
|
1306
|
+
const edge = want === this.open ? null : want ? "open" : "close";
|
|
1307
|
+
this.open = want;
|
|
1308
|
+
return { send: want, edge };
|
|
1309
|
+
}
|
|
1310
|
+
get isOpen() {
|
|
1311
|
+
return this.open;
|
|
1312
|
+
}
|
|
1313
|
+
};
|
|
1314
|
+
function seqDiff(a, b) {
|
|
1315
|
+
const d = a - b & 65535;
|
|
1316
|
+
return d >= 32768 ? d - 65536 : d;
|
|
1317
|
+
}
|
|
1318
|
+
var JitterBuffer = class {
|
|
1319
|
+
constructor(jitterMs = JITTER_MS, framesPerPacket = FRAMES_PER_PACKET) {
|
|
1320
|
+
this.framesPerPacket = framesPerPacket;
|
|
1321
|
+
this.prefillFrames = Math.max(1, Math.round(jitterMs / FRAME_MS));
|
|
1322
|
+
}
|
|
1323
|
+
framesPerPacket;
|
|
1324
|
+
packets = /* @__PURE__ */ new Map();
|
|
1325
|
+
expected = null;
|
|
1326
|
+
ready = [];
|
|
1327
|
+
playing = false;
|
|
1328
|
+
endSeq = null;
|
|
1329
|
+
waitTicks = 0;
|
|
1330
|
+
prefillFrames;
|
|
1331
|
+
push(p) {
|
|
1332
|
+
if (this.expected === null) {
|
|
1333
|
+
this.expected = p.seq;
|
|
1334
|
+
} else if (seqDiff(p.seq, this.expected) < 0) {
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
this.packets.set(p.seq, p);
|
|
1338
|
+
if (p.flags & FLAG_END_OF_BURST) this.endSeq = p.seq;
|
|
1339
|
+
else if (this.endSeq !== null && seqDiff(p.seq, this.endSeq) > 0) this.endSeq = null;
|
|
1340
|
+
}
|
|
1341
|
+
get ended() {
|
|
1342
|
+
return this.endSeq !== null;
|
|
1343
|
+
}
|
|
1344
|
+
bufferedFrames() {
|
|
1345
|
+
let n = this.ready.length;
|
|
1346
|
+
for (const p of this.packets.values()) n += p.frames.length;
|
|
1347
|
+
return n;
|
|
1348
|
+
}
|
|
1349
|
+
newerThanExpected() {
|
|
1350
|
+
for (const s of this.packets.keys()) if (this.expected !== null && seqDiff(s, this.expected) > 0) return true;
|
|
1351
|
+
return false;
|
|
1352
|
+
}
|
|
1353
|
+
pull() {
|
|
1354
|
+
if (this.expected === null) return { kind: "idle" };
|
|
1355
|
+
if (!this.playing) {
|
|
1356
|
+
if (this.bufferedFrames() >= this.prefillFrames || this.ended && this.packets.size > 0) this.playing = true;
|
|
1357
|
+
else return { kind: "wait" };
|
|
1358
|
+
}
|
|
1359
|
+
if (this.ready.length === 0) this.refill();
|
|
1360
|
+
const f = this.ready.shift();
|
|
1361
|
+
if (f) {
|
|
1362
|
+
this.waitTicks = 0;
|
|
1363
|
+
return f.length === 0 ? { kind: "plc" } : { kind: "frame", opus: f };
|
|
1364
|
+
}
|
|
1365
|
+
if (this.ended && this.packets.size === 0) {
|
|
1366
|
+
this.reset();
|
|
1367
|
+
return { kind: "idle" };
|
|
1368
|
+
}
|
|
1369
|
+
if (++this.waitTicks > 12) {
|
|
1370
|
+
this.reset();
|
|
1371
|
+
return { kind: "idle" };
|
|
1372
|
+
}
|
|
1373
|
+
return { kind: "wait" };
|
|
1374
|
+
}
|
|
1375
|
+
refill() {
|
|
1376
|
+
if (this.expected === null) return;
|
|
1377
|
+
const p = this.packets.get(this.expected);
|
|
1378
|
+
if (p) {
|
|
1379
|
+
this.packets.delete(this.expected);
|
|
1380
|
+
this.expected = this.expected + 1 & 65535;
|
|
1381
|
+
for (const fr of p.frames) this.ready.push(fr);
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
if (this.newerThanExpected()) {
|
|
1385
|
+
this.expected = this.expected + 1 & 65535;
|
|
1386
|
+
for (let i = 0; i < this.framesPerPacket; i++) this.ready.push(new Uint8Array(0));
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
reset() {
|
|
1390
|
+
this.packets.clear();
|
|
1391
|
+
this.ready = [];
|
|
1392
|
+
this.expected = null;
|
|
1393
|
+
this.playing = false;
|
|
1394
|
+
this.endSeq = null;
|
|
1395
|
+
this.waitTicks = 0;
|
|
1396
|
+
}
|
|
1397
|
+
get active() {
|
|
1398
|
+
return this.expected !== null;
|
|
1399
|
+
}
|
|
1400
|
+
};
|
|
1401
|
+
var VoiceRelayPolicy = class {
|
|
1402
|
+
speakers = /* @__PURE__ */ new Map();
|
|
1403
|
+
// peerId -> last packet time
|
|
1404
|
+
windows = /* @__PURE__ */ new Map();
|
|
1405
|
+
/** A burst with no packet for this long counts as over even if its end flag was lost. */
|
|
1406
|
+
burstTimeoutMs = 500;
|
|
1407
|
+
accept(from, size, flags, now) {
|
|
1408
|
+
if (size > MAX_VOICE_PACKET_BYTES) return false;
|
|
1409
|
+
let w = this.windows.get(from);
|
|
1410
|
+
if (!w) this.windows.set(from, w = []);
|
|
1411
|
+
while (w.length && now - w[0] >= 1e3) w.shift();
|
|
1412
|
+
if (w.length >= MAX_PACKETS_PER_SEC) return false;
|
|
1413
|
+
for (const [p, t] of this.speakers) if (now - t > this.burstTimeoutMs) this.speakers.delete(p);
|
|
1414
|
+
if (!this.speakers.has(from) && this.speakers.size >= MAX_SPEAKERS) return false;
|
|
1415
|
+
w.push(now);
|
|
1416
|
+
if (flags & FLAG_END_OF_BURST) this.speakers.delete(from);
|
|
1417
|
+
else this.speakers.set(from, now);
|
|
1418
|
+
return true;
|
|
1419
|
+
}
|
|
1420
|
+
get activeSpeakers() {
|
|
1421
|
+
return this.speakers.size;
|
|
1422
|
+
}
|
|
1423
|
+
forget(peerId) {
|
|
1424
|
+
this.speakers.delete(peerId);
|
|
1425
|
+
this.windows.delete(peerId);
|
|
1426
|
+
}
|
|
1427
|
+
};
|
|
1428
|
+
|
|
1429
|
+
// src/voice.ts
|
|
1430
|
+
var SPEAKING_TIMEOUT_MS = 300;
|
|
1431
|
+
var Voice = class extends Emitter {
|
|
1432
|
+
constructor(hub, deps) {
|
|
1433
|
+
super();
|
|
1434
|
+
this.hub = hub;
|
|
1435
|
+
this.deps = deps;
|
|
1436
|
+
}
|
|
1437
|
+
hub;
|
|
1438
|
+
deps;
|
|
1439
|
+
mode = "pushToTalk";
|
|
1440
|
+
/** Master mute for capture (self). */
|
|
1441
|
+
muted = false;
|
|
1442
|
+
/** 0..1 RMS of the last captured frame. */
|
|
1443
|
+
inputLevel = 0;
|
|
1444
|
+
bitrate = 24e3;
|
|
1445
|
+
_enabled = false;
|
|
1446
|
+
gate = new VoiceGate();
|
|
1447
|
+
packetizer = new VoicePacketizer();
|
|
1448
|
+
relay = new VoiceRelayPolicy();
|
|
1449
|
+
speakers = /* @__PURE__ */ new Map();
|
|
1450
|
+
peerMuted = /* @__PURE__ */ new Set();
|
|
1451
|
+
gains = /* @__PURE__ */ new Map();
|
|
1452
|
+
encoder = null;
|
|
1453
|
+
source = null;
|
|
1454
|
+
sink = null;
|
|
1455
|
+
cancelPlayout = null;
|
|
1456
|
+
endTimer = null;
|
|
1457
|
+
sending = false;
|
|
1458
|
+
get enabled() {
|
|
1459
|
+
return this._enabled;
|
|
1460
|
+
}
|
|
1461
|
+
set enabled(v) {
|
|
1462
|
+
if (v === this._enabled) return;
|
|
1463
|
+
this._enabled = v;
|
|
1464
|
+
if (v) void this.start();
|
|
1465
|
+
else this.stop();
|
|
1466
|
+
}
|
|
1467
|
+
pushToTalk(down) {
|
|
1468
|
+
this.gate.ptt = down;
|
|
1469
|
+
}
|
|
1470
|
+
isMutedPeer(peerId) {
|
|
1471
|
+
return this.peerMuted.has(peerId);
|
|
1472
|
+
}
|
|
1473
|
+
setMuted(peerId, muted) {
|
|
1474
|
+
if (muted) this.peerMuted.add(peerId);
|
|
1475
|
+
else this.peerMuted.delete(peerId);
|
|
1476
|
+
}
|
|
1477
|
+
setVolume(peerId, gain) {
|
|
1478
|
+
this.gains.set(peerId, Math.min(2, Math.max(0, gain)));
|
|
1479
|
+
}
|
|
1480
|
+
volumeOf(peerId) {
|
|
1481
|
+
return this.gains.get(peerId) ?? 1;
|
|
1482
|
+
}
|
|
1483
|
+
// ---- capture -------------------------------------------------------------------------------------------------
|
|
1484
|
+
async start() {
|
|
1485
|
+
try {
|
|
1486
|
+
if (!this.deps.codec) throw new Error("no Opus codec available: supply one through the voice.codec option (WASM fallback)");
|
|
1487
|
+
if (!this.deps.createSink) throw new Error("no audio output available");
|
|
1488
|
+
this.sink = this.deps.createSink();
|
|
1489
|
+
this.cancelPlayout = this.hub.clock.setInterval(() => this.playoutTick(), FRAME_MS);
|
|
1490
|
+
if (this.deps.createSource) {
|
|
1491
|
+
this.encoder = this.deps.codec.createEncoder({ bitrate: this.bitrate }, (opus) => this.onEncoded(opus));
|
|
1492
|
+
this.source = this.deps.createSource();
|
|
1493
|
+
await this.source.start((pcm) => this.onCapturedFrame(pcm, this.hub.clock.now()));
|
|
1494
|
+
}
|
|
1495
|
+
} catch (e) {
|
|
1496
|
+
this._enabled = false;
|
|
1497
|
+
this.stop();
|
|
1498
|
+
this.emit("error", { message: e instanceof Error ? e.message : String(e), cause: e });
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
stop() {
|
|
1502
|
+
if (this.sending) this.closeBurst();
|
|
1503
|
+
this.source?.stop();
|
|
1504
|
+
this.source = null;
|
|
1505
|
+
this.encoder?.close();
|
|
1506
|
+
this.encoder = null;
|
|
1507
|
+
this.cancelPlayout?.();
|
|
1508
|
+
this.cancelPlayout = null;
|
|
1509
|
+
this.sink?.close();
|
|
1510
|
+
this.sink = null;
|
|
1511
|
+
for (const [id, s] of this.speakers) {
|
|
1512
|
+
s.decoder?.close();
|
|
1513
|
+
if (s.talking) this.emit("speaking", id, false);
|
|
1514
|
+
}
|
|
1515
|
+
this.speakers.clear();
|
|
1516
|
+
}
|
|
1517
|
+
/** Feed one 20 ms mono 48 kHz frame (called by the mic source; exposed so tests and custom sources can drive it). */
|
|
1518
|
+
onCapturedFrame(pcm, nowMs) {
|
|
1519
|
+
const level = rms(pcm);
|
|
1520
|
+
this.inputLevel = level;
|
|
1521
|
+
if (!this._enabled || this.muted) {
|
|
1522
|
+
if (this.sending) this.closeBurst();
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
const g = this.gate.update(level, nowMs);
|
|
1526
|
+
if (g.send) {
|
|
1527
|
+
this.sending = true;
|
|
1528
|
+
this.endTimer?.();
|
|
1529
|
+
this.endTimer = null;
|
|
1530
|
+
this.encoder?.encode(pcm);
|
|
1531
|
+
} else if (g.edge === "close" && this.sending) {
|
|
1532
|
+
this.closeBurst();
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
closeBurst() {
|
|
1536
|
+
this.sending = false;
|
|
1537
|
+
this.endTimer?.();
|
|
1538
|
+
this.endTimer = this.hub.clock.setTimeout(() => {
|
|
1539
|
+
this.endTimer = null;
|
|
1540
|
+
this.sendPacket(this.packetizer.endBurst());
|
|
1541
|
+
}, FRAME_MS * 2);
|
|
1542
|
+
}
|
|
1543
|
+
/** Send already-encoded Opus frames as one packet (no codec involved); used by tools and conformance. */
|
|
1544
|
+
sendEncoded(frames, endOfBurst = false) {
|
|
1545
|
+
this.sendPacket(this.packetizer.packet(frames, endOfBurst ? FLAG_END_OF_BURST : 0));
|
|
1546
|
+
}
|
|
1547
|
+
/** Encoded frame from the encoder (also the test entry point for packetization). */
|
|
1548
|
+
onEncoded(opus) {
|
|
1549
|
+
const p = this.packetizer.push(opus);
|
|
1550
|
+
if (p) this.sendPacket(p);
|
|
1551
|
+
}
|
|
1552
|
+
sendPacket(p) {
|
|
1553
|
+
const msg = { type: "voice", seq: p.seq, flags: p.flags, frames: p.frames };
|
|
1554
|
+
if (this.hub.isHost) {
|
|
1555
|
+
for (const peer of this.hub.connectedPeers()) this.hub.sendTo(peer, msg, this.hub.localPeerId, "voice");
|
|
1556
|
+
} else {
|
|
1557
|
+
this.hub.sendToHost(msg, 0, "voice");
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
// ---- receive -------------------------------------------------------------------------------------------------
|
|
1561
|
+
handle(from, route, msg) {
|
|
1562
|
+
if (msg.type !== "voice") return;
|
|
1563
|
+
const size = msg.frames.reduce((a, f) => a + f.length + 2, 0) + 7;
|
|
1564
|
+
const now = this.hub.clock.now();
|
|
1565
|
+
let speaker = route;
|
|
1566
|
+
if (this.hub.isHost) {
|
|
1567
|
+
speaker = from;
|
|
1568
|
+
if (!this.relay.accept(from, size, msg.flags, now)) return;
|
|
1569
|
+
for (const peer of this.hub.connectedPeers()) {
|
|
1570
|
+
if (peer !== from) this.hub.sendTo(peer, msg, from, "voice");
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
this.emit("packet", speaker, msg.seq, msg.flags, msg.frames);
|
|
1574
|
+
if (!this._enabled || speaker === this.hub.localPeerId || this.peerMuted.has(speaker)) return;
|
|
1575
|
+
this.receivePacket(speaker, { seq: msg.seq, flags: msg.flags, frames: msg.frames }, now);
|
|
1576
|
+
}
|
|
1577
|
+
/** Local playback path for one speaker's packet (after host forwarding). */
|
|
1578
|
+
receivePacket(speaker, p, now) {
|
|
1579
|
+
let s = this.speakers.get(speaker);
|
|
1580
|
+
if (!s) {
|
|
1581
|
+
s = { jitter: new JitterBuffer(), decoder: null, talking: false, lastHeard: now };
|
|
1582
|
+
this.speakers.set(speaker, s);
|
|
1583
|
+
}
|
|
1584
|
+
s.lastHeard = now;
|
|
1585
|
+
s.jitter.push(p);
|
|
1586
|
+
if (!s.talking && p.frames.length > 0) {
|
|
1587
|
+
s.talking = true;
|
|
1588
|
+
this.emit("speaking", speaker, true);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
playoutTick() {
|
|
1592
|
+
const now = this.hub.clock.now();
|
|
1593
|
+
for (const [id, s] of this.speakers) {
|
|
1594
|
+
const r = s.jitter.pull();
|
|
1595
|
+
if (r.kind === "frame" || r.kind === "plc") {
|
|
1596
|
+
if (!s.decoder && this.deps.codec) {
|
|
1597
|
+
s.decoder = this.deps.codec.createDecoder((pcm) => this.sink?.play(id, pcm, this.volumeOf(id)));
|
|
1598
|
+
}
|
|
1599
|
+
s.decoder?.decode(r.kind === "frame" ? r.opus : null);
|
|
1600
|
+
} else if (r.kind === "idle" && s.talking) {
|
|
1601
|
+
s.talking = false;
|
|
1602
|
+
this.emit("speaking", id, false);
|
|
1603
|
+
} else if (s.talking && now - s.lastHeard > SPEAKING_TIMEOUT_MS + 5 * FRAME_MS && !s.jitter.active) {
|
|
1604
|
+
s.talking = false;
|
|
1605
|
+
this.emit("speaking", id, false);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
onPeerLeft(peerId) {
|
|
1610
|
+
this.relay.forget(peerId);
|
|
1611
|
+
const s = this.speakers.get(peerId);
|
|
1612
|
+
if (s) {
|
|
1613
|
+
s.decoder?.close();
|
|
1614
|
+
if (s.talking) this.emit("speaking", peerId, false);
|
|
1615
|
+
this.speakers.delete(peerId);
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
close() {
|
|
1619
|
+
this._enabled = false;
|
|
1620
|
+
this.stop();
|
|
1621
|
+
this.endTimer?.();
|
|
1622
|
+
}
|
|
1623
|
+
/** For tests: expose the end-of-burst flag constant. */
|
|
1624
|
+
static END = FLAG_END_OF_BURST;
|
|
1625
|
+
};
|
|
1626
|
+
|
|
1627
|
+
// src/socket.ts
|
|
1628
|
+
function closeCodeFor(reason) {
|
|
1629
|
+
if (reason === "left") return 1e3;
|
|
1630
|
+
if (reason === "host_left" || reason === "closed") return 1001;
|
|
1631
|
+
if (reason === "kicked") return 4e3;
|
|
1632
|
+
return 1006;
|
|
1633
|
+
}
|
|
1634
|
+
var enc2 = new TextEncoder();
|
|
1635
|
+
var dec2 = new TextDecoder();
|
|
1636
|
+
var PeerSocket = class {
|
|
1637
|
+
constructor(peerId, out, binary, onLocalClose) {
|
|
1638
|
+
this.peerId = peerId;
|
|
1639
|
+
this.out = out;
|
|
1640
|
+
this.binary = binary;
|
|
1641
|
+
this.onLocalClose = onLocalClose;
|
|
1642
|
+
}
|
|
1643
|
+
peerId;
|
|
1644
|
+
out;
|
|
1645
|
+
binary;
|
|
1646
|
+
onLocalClose;
|
|
1647
|
+
readyState = 0;
|
|
1648
|
+
bufferedAmount = 0;
|
|
1649
|
+
onopen = null;
|
|
1650
|
+
onmessage = null;
|
|
1651
|
+
onclose = null;
|
|
1652
|
+
onerror = null;
|
|
1653
|
+
/** Unsubscribes from the lobby; set by the factory. */
|
|
1654
|
+
detach = () => {
|
|
1655
|
+
};
|
|
1656
|
+
send(data) {
|
|
1657
|
+
if (this.readyState !== 1) return;
|
|
1658
|
+
this.out(typeof data === "string" ? enc2.encode(data) : data);
|
|
1659
|
+
}
|
|
1660
|
+
close(code = 1e3, reason = "left") {
|
|
1661
|
+
if (this.readyState === 3) return;
|
|
1662
|
+
this.closed(code, reason);
|
|
1663
|
+
this.onLocalClose();
|
|
1664
|
+
}
|
|
1665
|
+
/** @internal OPEN now; `onopen` now, or one microtask later so a handler assigned right after creation still runs. */
|
|
1666
|
+
open(later = false) {
|
|
1667
|
+
if (this.readyState !== 0) return;
|
|
1668
|
+
this.readyState = 1;
|
|
1669
|
+
if (!later) this.onopen?.({ type: "open" });
|
|
1670
|
+
else queueMicrotask(() => this.readyState === 1 && this.onopen?.({ type: "open" }));
|
|
1671
|
+
}
|
|
1672
|
+
/** @internal */
|
|
1673
|
+
deliver(bytes) {
|
|
1674
|
+
if (this.readyState === 1) this.onmessage?.({ type: "message", data: this.binary ? bytes : dec2.decode(bytes) });
|
|
1675
|
+
}
|
|
1676
|
+
/** @internal */
|
|
1677
|
+
closed(code, reason) {
|
|
1678
|
+
if (this.readyState === 3) return;
|
|
1679
|
+
this.readyState = 3;
|
|
1680
|
+
this.detach();
|
|
1681
|
+
this.onclose?.({ type: "close", code, reason, wasClean: code === 1e3 || code === 1001 });
|
|
1682
|
+
}
|
|
1683
|
+
};
|
|
1684
|
+
function joinerSocket(lobby, o = {}) {
|
|
1685
|
+
if (lobby.isHost) throw new Error("asWebSocket() is for joiners; the host uses lobby.onConnection()");
|
|
1686
|
+
const channel = o.channel ?? "reliable";
|
|
1687
|
+
const s = new PeerSocket(lobby.hostPeerId, (b) => lobby.send(lobby.hostPeerId, b, { channel }), !!o.binary, () => void lobby.leave());
|
|
1688
|
+
const offs = [
|
|
1689
|
+
lobby.on("message", (from, bytes) => {
|
|
1690
|
+
if (from === lobby.hostPeerId) s.deliver(bytes);
|
|
1691
|
+
}),
|
|
1692
|
+
lobby.on("sessionEnded", (reason) => s.closed(closeCodeFor(reason), reason)),
|
|
1693
|
+
// The socket's far end is the host it opened to; after a migration that server is gone (direct lobbies end instead).
|
|
1694
|
+
lobby.on("hostChanged", () => s.closed(1001, "host_changed"))
|
|
1695
|
+
];
|
|
1696
|
+
s.detach = () => offs.forEach((f) => f());
|
|
1697
|
+
if (lobby.connectedPeers.includes(lobby.hostPeerId)) s.open(true);
|
|
1698
|
+
else lobby.ready.then(() => s.open(true), () => {
|
|
1699
|
+
});
|
|
1700
|
+
return s;
|
|
1701
|
+
}
|
|
1702
|
+
function hostConnections(lobby, handler, o = {}) {
|
|
1703
|
+
if (!lobby.isHost) throw new Error("onConnection() is for the host; a joiner uses lobby.asWebSocket()");
|
|
1704
|
+
const channel = o.channel ?? "reliable";
|
|
1705
|
+
const conns = /* @__PURE__ */ new Map();
|
|
1706
|
+
let stopped = false;
|
|
1707
|
+
const ensure = (peerId) => {
|
|
1708
|
+
if (peerId === lobby.localPeerId) return void 0;
|
|
1709
|
+
let c = conns.get(peerId);
|
|
1710
|
+
if (c || stopped) return c;
|
|
1711
|
+
c = new PeerSocket(peerId, (b) => lobby.send(peerId, b, { channel }), !!o.binary, () => {
|
|
1712
|
+
});
|
|
1713
|
+
const conn = c;
|
|
1714
|
+
conn.detach = () => {
|
|
1715
|
+
if (conns.get(peerId) === conn) conns.delete(peerId);
|
|
1716
|
+
};
|
|
1717
|
+
conns.set(peerId, conn);
|
|
1718
|
+
conn.open();
|
|
1719
|
+
handler(conn);
|
|
1720
|
+
return conn;
|
|
1721
|
+
};
|
|
1722
|
+
const offs = [
|
|
1723
|
+
lobby.on("peerReady", (p) => void ensure(p.peerId)),
|
|
1724
|
+
lobby.on("message", (from, bytes) => ensure(from)?.deliver(bytes)),
|
|
1725
|
+
lobby.on("peerLeft", (p, reason) => conns.get(p.peerId)?.closed(reason === "left" ? 1001 : closeCodeFor(reason), reason)),
|
|
1726
|
+
lobby.on("sessionEnded", (reason) => {
|
|
1727
|
+
for (const c of [...conns.values()]) c.closed(reason === "left" ? 1001 : closeCodeFor(reason), reason);
|
|
1728
|
+
offs.forEach((f) => f());
|
|
1729
|
+
})
|
|
1730
|
+
];
|
|
1731
|
+
for (const id of lobby.connectedPeers) ensure(id);
|
|
1732
|
+
return () => {
|
|
1733
|
+
stopped = true;
|
|
1734
|
+
};
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// src/lobby.ts
|
|
1738
|
+
var MIN_PEER_TIMEOUT_MS = 6e3;
|
|
1739
|
+
function seatName(p) {
|
|
1740
|
+
const n = typeof p.name === "string" ? p.name.trim() : "";
|
|
1741
|
+
if (n && n !== "Player") return p;
|
|
1742
|
+
return { ...p, name: `Player ${p.peerId}` };
|
|
1743
|
+
}
|
|
1744
|
+
function hasRelay(ice) {
|
|
1745
|
+
return ice.some((s) => (Array.isArray(s.urls) ? s.urls : [s.urls]).some((u) => /^turns?:/i.test(String(u))));
|
|
1746
|
+
}
|
|
1747
|
+
var HOST_WATCHDOG_MS = 250;
|
|
1748
|
+
var PING_EVERY_MS2 = 2e3;
|
|
1749
|
+
var Lobby = class extends Emitter {
|
|
1750
|
+
code;
|
|
1751
|
+
mode;
|
|
1752
|
+
tickRate;
|
|
1753
|
+
lockstep;
|
|
1754
|
+
chat;
|
|
1755
|
+
voice;
|
|
1756
|
+
localPeerId;
|
|
1757
|
+
hostPeerId;
|
|
1758
|
+
phase;
|
|
1759
|
+
meta;
|
|
1760
|
+
resumeToken;
|
|
1761
|
+
/** Last measured round trip to the host in ms (joiners), or 0. */
|
|
1762
|
+
rtt = 0;
|
|
1763
|
+
/**
|
|
1764
|
+
* Direct-IP host only (onelobby/direct): the code friends paste to join, e.g. "6B01-RGFE-P2B" (docs/direct-ip.md
|
|
1765
|
+
* s.2a). Show it with joinCodeKind. Empty when the machine has no IPv4 address, and on joiners.
|
|
1766
|
+
*/
|
|
1767
|
+
joinCode = "";
|
|
1768
|
+
/** "internet": the router forwarded the port and the code carries its public address; "same_network": the LAN address. */
|
|
1769
|
+
joinCodeKind = "none";
|
|
1770
|
+
/** Direct-IP host only: this machine's addresses with the port, for an "Advanced" fold-out. */
|
|
1771
|
+
directAddresses = [];
|
|
1772
|
+
/** Direct-IP host only: how the router port-open went ("UPnP", "no UPnP gateway answered; ...", "off"). */
|
|
1773
|
+
portMapping = "";
|
|
1774
|
+
signaling;
|
|
1775
|
+
cfg;
|
|
1776
|
+
roster = /* @__PURE__ */ new Map();
|
|
1777
|
+
links = /* @__PURE__ */ new Map();
|
|
1778
|
+
connected = /* @__PURE__ */ new Set();
|
|
1779
|
+
// host: peers that said hi
|
|
1780
|
+
hiSentTo = /* @__PURE__ */ new Set();
|
|
1781
|
+
epoch = 0;
|
|
1782
|
+
ended = false;
|
|
1783
|
+
serverUp = true;
|
|
1784
|
+
cancelWatchdog = null;
|
|
1785
|
+
cancelPing = null;
|
|
1786
|
+
pingN = 0;
|
|
1787
|
+
chunkId = 1;
|
|
1788
|
+
reassemblers = /* @__PURE__ */ new Map();
|
|
1789
|
+
hub;
|
|
1790
|
+
outbox = /* @__PURE__ */ new Map();
|
|
1791
|
+
markReady = () => {
|
|
1792
|
+
};
|
|
1793
|
+
failReady = () => {
|
|
1794
|
+
};
|
|
1795
|
+
isReady = false;
|
|
1796
|
+
cancelReadyTimer = null;
|
|
1797
|
+
relayRetry = false;
|
|
1798
|
+
/** clock.now() of the last frame from each peer (peerTimeoutMs). */
|
|
1799
|
+
lastHeard = /* @__PURE__ */ new Map();
|
|
1800
|
+
/** Host: per-peer fixed window for rateLimit. */
|
|
1801
|
+
rate = /* @__PURE__ */ new Map();
|
|
1802
|
+
/**
|
|
1803
|
+
* Resolves when this peer can exchange data: immediately for the host, once the link to the host is open for a
|
|
1804
|
+
* joiner. Rejects with OneLobbyError("ice_failed") when the joiner cannot reach the host in `readyTimeoutMs`
|
|
1805
|
+
* (or with "closed"/"kicked" when the lobby ends first).
|
|
1806
|
+
*/
|
|
1807
|
+
ready = new Promise((res, rej) => {
|
|
1808
|
+
this.markReady = () => {
|
|
1809
|
+
if (this.isReady) return;
|
|
1810
|
+
this.isReady = true;
|
|
1811
|
+
this.cancelReadyTimer?.();
|
|
1812
|
+
this.cancelReadyTimer = null;
|
|
1813
|
+
res();
|
|
1814
|
+
};
|
|
1815
|
+
this.failReady = rej;
|
|
1816
|
+
});
|
|
1817
|
+
constructor(cfg) {
|
|
1818
|
+
super();
|
|
1819
|
+
this.cfg = cfg;
|
|
1820
|
+
this.signaling = cfg.signaling;
|
|
1821
|
+
const st = cfg.state;
|
|
1822
|
+
this.code = st.code;
|
|
1823
|
+
this.mode = st.mode;
|
|
1824
|
+
this.tickRate = st.tickRate;
|
|
1825
|
+
this.localPeerId = st.you;
|
|
1826
|
+
this.hostPeerId = st.hostId;
|
|
1827
|
+
this.phase = st.phase;
|
|
1828
|
+
this.meta = { ...st.meta };
|
|
1829
|
+
this.resumeToken = st.resumeToken;
|
|
1830
|
+
for (const p of st.peers) this.roster.set(p.peerId, seatName(p));
|
|
1831
|
+
this.ready.catch(() => {
|
|
1832
|
+
});
|
|
1833
|
+
const self = this;
|
|
1834
|
+
this.hub = {
|
|
1835
|
+
get clock() {
|
|
1836
|
+
return self.cfg.clock;
|
|
1837
|
+
},
|
|
1838
|
+
get localPeerId() {
|
|
1839
|
+
return self.localPeerId;
|
|
1840
|
+
},
|
|
1841
|
+
get hostPeerId() {
|
|
1842
|
+
return self.hostPeerId;
|
|
1843
|
+
},
|
|
1844
|
+
get isHost() {
|
|
1845
|
+
return self.isHost;
|
|
1846
|
+
},
|
|
1847
|
+
get tickRate() {
|
|
1848
|
+
return self.tickRate;
|
|
1849
|
+
},
|
|
1850
|
+
connectedPeers: () => self.connectedPeerIds(),
|
|
1851
|
+
sendTo: (peerId, msg, route, channel) => self.sendTo(peerId, msg, route ?? 0, channel),
|
|
1852
|
+
sendToHost: (msg, route, channel) => {
|
|
1853
|
+
if (!self.isHost) self.sendTo(self.hostPeerId, msg, route ?? 0, channel);
|
|
1854
|
+
},
|
|
1855
|
+
dropPeer: (peerId, reason) => self.dropPeer(peerId, reason),
|
|
1856
|
+
reportError: (e) => self.reportError(e),
|
|
1857
|
+
nextChunkId: () => self.chunkId++ >>> 0
|
|
1858
|
+
};
|
|
1859
|
+
this.lockstep = new Lockstep(this.hub, { hashEveryTicks: cfg.hashEveryTicks });
|
|
1860
|
+
this.chat = new Chat(this.hub, cfg.wall, (id) => this.voice.isMutedPeer(id));
|
|
1861
|
+
this.voice = new Voice(this.hub, cfg.voice);
|
|
1862
|
+
this.lockstep.on("started", () => this.setPhase("in_game"));
|
|
1863
|
+
this.lockstep.on("error", (e) => this.reportError(e));
|
|
1864
|
+
this.signaling.onMessage = (m) => this.onSignaling(m);
|
|
1865
|
+
this.signaling.onClose = () => this.onServerClosed();
|
|
1866
|
+
if (this.isHost) this.markReady();
|
|
1867
|
+
else this.armReadyTimer();
|
|
1868
|
+
if (this.isHost && this.mode === "lockstep") {
|
|
1869
|
+
if (this.phase === "in_game") this.lockstep.hostStart();
|
|
1870
|
+
else this.lockstep.hostIdle();
|
|
1871
|
+
}
|
|
1872
|
+
this.cancelWatchdog = cfg.clock.setInterval(() => this.watchdog(), HOST_WATCHDOG_MS);
|
|
1873
|
+
this.cancelPing = cfg.clock.setInterval(() => this.pingHost(), PING_EVERY_MS2);
|
|
1874
|
+
}
|
|
1875
|
+
// ---- state -----------------------------------------------------------------------------------------------------
|
|
1876
|
+
get isHost() {
|
|
1877
|
+
return this.localPeerId === this.hostPeerId;
|
|
1878
|
+
}
|
|
1879
|
+
get peers() {
|
|
1880
|
+
return [...this.roster.values()].sort((a, b) => a.peerId - b.peerId);
|
|
1881
|
+
}
|
|
1882
|
+
peer(peerId) {
|
|
1883
|
+
return this.roster.get(peerId);
|
|
1884
|
+
}
|
|
1885
|
+
/** Peers whose link is open and handshake done (host: the ready joiners; joiner: the host once ready). */
|
|
1886
|
+
get connectedPeers() {
|
|
1887
|
+
return this.connectedPeerIds();
|
|
1888
|
+
}
|
|
1889
|
+
/**
|
|
1890
|
+
* Joiner: the link to the host as a WebSocket-shaped object (text by default), so WebSocket client code runs
|
|
1891
|
+
* unchanged. It opens when `ready` resolves and closes when the session ends (`host_left` gives code 1001).
|
|
1892
|
+
*/
|
|
1893
|
+
asWebSocket(opts) {
|
|
1894
|
+
return joinerSocket(this, opts);
|
|
1895
|
+
}
|
|
1896
|
+
/** Host: `handler(conn)` once per joiner (a WebSocket-shaped connection), the server-side mirror of asWebSocket(). */
|
|
1897
|
+
onConnection(handler, opts) {
|
|
1898
|
+
return hostConnections(this, handler, opts);
|
|
1899
|
+
}
|
|
1900
|
+
set snapshotChunkSize(n) {
|
|
1901
|
+
this.lockstep.chunkSize = n;
|
|
1902
|
+
}
|
|
1903
|
+
/** Current migration epoch (0 until the first host change). */
|
|
1904
|
+
get hostEpoch() {
|
|
1905
|
+
return this.epoch;
|
|
1906
|
+
}
|
|
1907
|
+
/** @internal Host role: create the link to `peerId` and start negotiating (adapters and tests). */
|
|
1908
|
+
_offer(peerId) {
|
|
1909
|
+
this.offerTo(peerId);
|
|
1910
|
+
return this.links.get(peerId);
|
|
1911
|
+
}
|
|
1912
|
+
/** @internal Joiner role: create the answering link to `peerId` (adapters and tests). */
|
|
1913
|
+
_answer(peerId) {
|
|
1914
|
+
return this.makeLink(peerId);
|
|
1915
|
+
}
|
|
1916
|
+
/** @internal Called by OneLobby right after construction: the host waits for joiners, a joiner waits for the host's offer. */
|
|
1917
|
+
_begin() {
|
|
1918
|
+
}
|
|
1919
|
+
// ---- game-facing actions ---------------------------------------------------------------------------------------
|
|
1920
|
+
async start() {
|
|
1921
|
+
if (!this.isHost) throw new Error("only the host can start the game");
|
|
1922
|
+
if (this.phase === "in_game") return;
|
|
1923
|
+
this.signaling.send({ t: "phase", phase: "in_game" });
|
|
1924
|
+
this.setPhase("in_game");
|
|
1925
|
+
if (this.mode === "lockstep") this.lockstep.hostStart();
|
|
1926
|
+
}
|
|
1927
|
+
async setMeta(meta) {
|
|
1928
|
+
if (!this.isHost) throw new Error("only the host can set metadata");
|
|
1929
|
+
this.meta = { ...meta };
|
|
1930
|
+
this.signaling.send({ t: "meta", meta });
|
|
1931
|
+
this.emit("metaChanged", this.meta);
|
|
1932
|
+
}
|
|
1933
|
+
async kick(peerId) {
|
|
1934
|
+
if (!this.isHost) throw new Error("only the host can kick");
|
|
1935
|
+
this.signaling.send({ t: "kick", peerId });
|
|
1936
|
+
this.removePeer(peerId, "kicked");
|
|
1937
|
+
}
|
|
1938
|
+
async approve(peerId, ok) {
|
|
1939
|
+
if (!this.isHost) throw new Error("only the host can approve joins");
|
|
1940
|
+
this.signaling.send({ t: "approve", peerId, ok });
|
|
1941
|
+
}
|
|
1942
|
+
async leave() {
|
|
1943
|
+
if (this.ended) return;
|
|
1944
|
+
this.signaling.send({ t: "leave" });
|
|
1945
|
+
this.teardown("left");
|
|
1946
|
+
}
|
|
1947
|
+
/** raw mode (and any extra game data): to one peer. */
|
|
1948
|
+
send(peerId, bytes, opts = {}) {
|
|
1949
|
+
const channel = opts.channel ?? "reliable";
|
|
1950
|
+
if (this.isHost) this.sendTo(peerId, { type: "raw", bytes }, this.localPeerId, channel);
|
|
1951
|
+
else this.sendTo(this.hostPeerId, { type: "raw", bytes }, peerId, channel);
|
|
1952
|
+
}
|
|
1953
|
+
/** To everyone else in the lobby. */
|
|
1954
|
+
broadcast(bytes, opts = {}) {
|
|
1955
|
+
const channel = opts.channel ?? "reliable";
|
|
1956
|
+
if (this.isHost) {
|
|
1957
|
+
for (const p of this.roster.keys()) if (p !== this.localPeerId) this.sendTo(p, { type: "raw", bytes }, this.localPeerId, channel);
|
|
1958
|
+
} else {
|
|
1959
|
+
this.sendTo(this.hostPeerId, { type: "raw", bytes }, 0, channel);
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
// ---- sending ---------------------------------------------------------------------------------------------------
|
|
1963
|
+
channelFor(msg) {
|
|
1964
|
+
if (msg.type === "voice") return "voice";
|
|
1965
|
+
if (msg.type === "ping" || msg.type === "pong") return "fast";
|
|
1966
|
+
return "reliable";
|
|
1967
|
+
}
|
|
1968
|
+
sendTo(peerId, msg, route, channel) {
|
|
1969
|
+
const link = this.links.get(peerId);
|
|
1970
|
+
const ch0 = channel ?? this.channelFor(msg);
|
|
1971
|
+
const queueable = msg.type === "raw" || msg.type === "chat" || msg.type === "cmd";
|
|
1972
|
+
const ready = !!link?.isOpen && (!queueable || !this.isHost || this.connected.has(peerId));
|
|
1973
|
+
if (!ready) {
|
|
1974
|
+
const known = this.roster.has(peerId) || peerId === this.hostPeerId;
|
|
1975
|
+
if (queueable && ch0 === "reliable" && known && !this.ended) {
|
|
1976
|
+
let q = this.outbox.get(peerId);
|
|
1977
|
+
if (!q) this.outbox.set(peerId, q = []);
|
|
1978
|
+
if (q.length >= 1e3) q.shift();
|
|
1979
|
+
q.push({ msg, route, channel });
|
|
1980
|
+
}
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
if (!link) return;
|
|
1984
|
+
try {
|
|
1985
|
+
const ch = ch0;
|
|
1986
|
+
let bytes;
|
|
1987
|
+
try {
|
|
1988
|
+
bytes = encodeMessage(msg, route);
|
|
1989
|
+
} catch (e) {
|
|
1990
|
+
if (!(e instanceof CodecError) || e.code !== "too_long") throw e;
|
|
1991
|
+
bytes = encodeMessage(msg, route, true);
|
|
1992
|
+
}
|
|
1993
|
+
if (bytes.length > MAX_FRAME_BYTES) {
|
|
1994
|
+
for (const c of splitChunks(this.chunkId++ >>> 0, bytes)) link.send(ch, encodeMessage(c, route));
|
|
1995
|
+
} else {
|
|
1996
|
+
link.send(ch, bytes);
|
|
1997
|
+
}
|
|
1998
|
+
} catch (e) {
|
|
1999
|
+
this.reportError(e instanceof Error ? e : new Error(String(e)));
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
connectedPeerIds() {
|
|
2003
|
+
if (this.isHost) return [...this.connected].filter((id) => this.links.get(id)?.isOpen).sort((a, b) => a - b);
|
|
2004
|
+
const l = this.links.get(this.hostPeerId);
|
|
2005
|
+
return l?.isOpen ? [this.hostPeerId] : [];
|
|
2006
|
+
}
|
|
2007
|
+
reportError(e) {
|
|
2008
|
+
this.errorSink?.(e);
|
|
2009
|
+
}
|
|
2010
|
+
/** @internal set by OneLobby so lobby errors reach `kit.on("error")`. */
|
|
2011
|
+
errorSink = null;
|
|
2012
|
+
/** @internal set by OneLobby: the server issued a new resume token (rememberSeat). */
|
|
2013
|
+
seatSink = null;
|
|
2014
|
+
// ---- joiner connect timeout -------------------------------------------------------------------------------------
|
|
2015
|
+
armReadyTimer() {
|
|
2016
|
+
const total = this.cfg.readyTimeoutMs ?? 0;
|
|
2017
|
+
if (!(total > 0)) return;
|
|
2018
|
+
const canRelay = !this.cfg.relayOnly && hasRelay(this.cfg.ice);
|
|
2019
|
+
const first = canRelay ? Math.ceil(total / 2) : total;
|
|
2020
|
+
this.cancelReadyTimer = this.cfg.clock.setTimeout(() => {
|
|
2021
|
+
if (this.isReady || this.ended) return;
|
|
2022
|
+
if (canRelay) this.retryRelayed(total - first);
|
|
2023
|
+
else this.giveUpConnecting();
|
|
2024
|
+
}, first);
|
|
2025
|
+
}
|
|
2026
|
+
/** The direct attempt stalled: dial the host ourselves through the TURN relay (a host answers any offer). */
|
|
2027
|
+
retryRelayed(budget) {
|
|
2028
|
+
this.relayRetry = true;
|
|
2029
|
+
const link = this.makeLink(this.hostPeerId, true);
|
|
2030
|
+
link.startOffer();
|
|
2031
|
+
this.cancelReadyTimer = this.cfg.clock.setTimeout(() => {
|
|
2032
|
+
if (!this.isReady && !this.ended) this.giveUpConnecting();
|
|
2033
|
+
}, budget);
|
|
2034
|
+
}
|
|
2035
|
+
giveUpConnecting() {
|
|
2036
|
+
const err = new OneLobbyError("ice_failed", `could not open a connection to the host${this.relayRetry ? " (direct or relayed)" : ""}`);
|
|
2037
|
+
this.failReady(err);
|
|
2038
|
+
this.reportError(err);
|
|
2039
|
+
if (this.ended) return;
|
|
2040
|
+
this.signaling.send({ t: "leave" });
|
|
2041
|
+
this.teardown("ice_failed");
|
|
2042
|
+
}
|
|
2043
|
+
// ---- links -----------------------------------------------------------------------------------------------------
|
|
2044
|
+
makeLink(peerId, relayOnly = this.cfg.relayOnly) {
|
|
2045
|
+
const old = this.links.get(peerId);
|
|
2046
|
+
if (old) {
|
|
2047
|
+
old.onClose = null;
|
|
2048
|
+
old.onMessage = null;
|
|
2049
|
+
old.close();
|
|
2050
|
+
}
|
|
2051
|
+
const link = this.cfg.links.create({ remoteId: peerId, iceServers: this.cfg.ice, relayOnly });
|
|
2052
|
+
this.links.set(peerId, link);
|
|
2053
|
+
link.onSignal = (s) => this.signaling.send({ t: "signal", to: peerId, ...s });
|
|
2054
|
+
link.onOpen = () => this.onLinkOpen(peerId, link);
|
|
2055
|
+
link.onClose = (reason) => this.onLinkClosed(peerId, link, reason);
|
|
2056
|
+
link.onMessage = (ch, bytes) => this.onData(peerId, ch, bytes);
|
|
2057
|
+
link.onPath = (path) => this.signaling.send({ t: "path", path });
|
|
2058
|
+
return link;
|
|
2059
|
+
}
|
|
2060
|
+
/** Host side: offer to a joiner. */
|
|
2061
|
+
offerTo(peerId) {
|
|
2062
|
+
const link = this.makeLink(peerId);
|
|
2063
|
+
link.startOffer();
|
|
2064
|
+
}
|
|
2065
|
+
onLinkOpen(peerId, link) {
|
|
2066
|
+
if (this.isHost) {
|
|
2067
|
+
if (this.epoch > 0) this.sendTo(peerId, { type: "host_claim", epoch: this.epoch, hostId: this.localPeerId, tick: this.lockstep.lastTick }, 0);
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
2070
|
+
if (peerId === this.hostPeerId) this.sendHi(link);
|
|
2071
|
+
}
|
|
2072
|
+
sendHi(link) {
|
|
2073
|
+
if (this.hiSentTo.has(link)) return;
|
|
2074
|
+
this.hiSentTo.add(link);
|
|
2075
|
+
this.lockstep.touch();
|
|
2076
|
+
link.send("reliable", encodeMessage(this.lockstep.hiMessage(this.localPeerId)));
|
|
2077
|
+
this.markReady();
|
|
2078
|
+
this.emit("ready");
|
|
2079
|
+
this.emit("peerReady", this.roster.get(link.remoteId) ?? { peerId: link.remoteId, name: "" });
|
|
2080
|
+
this.flush(link.remoteId);
|
|
2081
|
+
}
|
|
2082
|
+
onLinkClosed(peerId, link, _reason) {
|
|
2083
|
+
if (this.links.get(peerId) !== link) return;
|
|
2084
|
+
this.links.delete(peerId);
|
|
2085
|
+
this.hiSentTo.delete(link);
|
|
2086
|
+
if (this.ended) return;
|
|
2087
|
+
if (this.isHost) {
|
|
2088
|
+
this.connected.delete(peerId);
|
|
2089
|
+
this.lockstep.onPeerLeft(peerId);
|
|
2090
|
+
this.voice.onPeerLeft(peerId);
|
|
2091
|
+
if (!this.serverUp && this.roster.has(peerId)) this.removePeer(peerId, "timeout");
|
|
2092
|
+
} else if (peerId === this.hostPeerId) {
|
|
2093
|
+
if (!this.isReady && this.cancelReadyTimer) return;
|
|
2094
|
+
this.handleHostLost("link closed");
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
// ---- data channel dispatch -------------------------------------------------------------------------------------
|
|
2098
|
+
onData(from, channel, bytes, reassembled = false) {
|
|
2099
|
+
let d;
|
|
2100
|
+
try {
|
|
2101
|
+
d = decodeMessage(bytes, reassembled);
|
|
2102
|
+
} catch (e) {
|
|
2103
|
+
this.reportError(e instanceof Error ? e : new Error(String(e)));
|
|
2104
|
+
return;
|
|
2105
|
+
}
|
|
2106
|
+
if (!d) return;
|
|
2107
|
+
if (!reassembled) this.lastHeard.set(from, this.cfg.clock.now());
|
|
2108
|
+
const { msg, route } = d;
|
|
2109
|
+
switch (msg.type) {
|
|
2110
|
+
case "hi":
|
|
2111
|
+
if (this.isHost) this.onHi(from, msg.lastTick);
|
|
2112
|
+
break;
|
|
2113
|
+
case "welcome_dc":
|
|
2114
|
+
if (!this.isHost) {
|
|
2115
|
+
if (msg.hostId !== this.hostPeerId) this.setHost(msg.hostId);
|
|
2116
|
+
this.lockstep.touch();
|
|
2117
|
+
}
|
|
2118
|
+
break;
|
|
2119
|
+
case "cmd":
|
|
2120
|
+
case "frame":
|
|
2121
|
+
case "hash":
|
|
2122
|
+
case "desync":
|
|
2123
|
+
case "snapshot_req":
|
|
2124
|
+
case "snapshot":
|
|
2125
|
+
if (from === this.hostPeerId || this.isHost) this.lockstep.handle(from, msg);
|
|
2126
|
+
break;
|
|
2127
|
+
case "chunk": {
|
|
2128
|
+
if (reassembled) break;
|
|
2129
|
+
let r = this.reassemblers.get(from);
|
|
2130
|
+
if (!r) this.reassemblers.set(from, r = new Reassembler());
|
|
2131
|
+
let whole;
|
|
2132
|
+
try {
|
|
2133
|
+
whole = r.push(msg);
|
|
2134
|
+
} catch (e) {
|
|
2135
|
+
this.reportError(e instanceof Error ? e : new Error(String(e)));
|
|
2136
|
+
break;
|
|
2137
|
+
}
|
|
2138
|
+
if (!whole) break;
|
|
2139
|
+
if (this.lockstep.snapshotPending && from === this.hostPeerId) this.lockstep.onSnapshotPayload(whole);
|
|
2140
|
+
else this.onData(from, channel, whole, true);
|
|
2141
|
+
break;
|
|
2142
|
+
}
|
|
2143
|
+
case "chat":
|
|
2144
|
+
case "chat_history":
|
|
2145
|
+
case "chat_reject":
|
|
2146
|
+
this.chat.handle(from, route, msg);
|
|
2147
|
+
break;
|
|
2148
|
+
case "voice":
|
|
2149
|
+
if (channel === "voice") this.voice.handle(from, route, msg);
|
|
2150
|
+
break;
|
|
2151
|
+
case "raw":
|
|
2152
|
+
this.onRaw(from, route, msg.bytes, channel);
|
|
2153
|
+
break;
|
|
2154
|
+
case "ping":
|
|
2155
|
+
this.sendTo(from, { type: "pong", n: msg.n, t: msg.t }, 0, "fast");
|
|
2156
|
+
break;
|
|
2157
|
+
case "pong":
|
|
2158
|
+
this.rtt = Math.max(0, (this.cfg.clock.now() >>> 0) - msg.t);
|
|
2159
|
+
break;
|
|
2160
|
+
case "host_claim":
|
|
2161
|
+
this.onHostClaim(from, msg.epoch, msg.hostId);
|
|
2162
|
+
break;
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
onHi(from, lastTick) {
|
|
2166
|
+
this.connected.add(from);
|
|
2167
|
+
this.sendTo(from, {
|
|
2168
|
+
type: "welcome_dc",
|
|
2169
|
+
// the tick the snapshot will carry (the game's applied tick), so welcome_dc.tick == snapshot.tick even when
|
|
2170
|
+
// the game loop has not yet consumed every emitted frame; 0 before the game starts
|
|
2171
|
+
tick: this.mode === "lockstep" ? this.lockstep.currentTick : 0,
|
|
2172
|
+
tickRate: this.tickRate,
|
|
2173
|
+
mode: this.mode === "lockstep" ? MODE_LOCKSTEP : MODE_RAW,
|
|
2174
|
+
hostId: this.localPeerId
|
|
2175
|
+
}, 0);
|
|
2176
|
+
this.chat.sendHistory(from);
|
|
2177
|
+
if (this.mode === "lockstep") this.lockstep.onPeerHi(from, lastTick);
|
|
2178
|
+
this.emit("peerReady", this.roster.get(from) ?? { peerId: from, name: "" });
|
|
2179
|
+
this.flush(from);
|
|
2180
|
+
}
|
|
2181
|
+
flush(peerId) {
|
|
2182
|
+
const q = this.outbox.get(peerId);
|
|
2183
|
+
if (!q) return;
|
|
2184
|
+
this.outbox.delete(peerId);
|
|
2185
|
+
for (const m of q) this.sendTo(peerId, m.msg, m.route, m.channel);
|
|
2186
|
+
}
|
|
2187
|
+
onRaw(from, route, bytes, channel) {
|
|
2188
|
+
if (!this.isHost) {
|
|
2189
|
+
this.emit("message", route, bytes, channel);
|
|
2190
|
+
return;
|
|
2191
|
+
}
|
|
2192
|
+
if (!this.allowRaw(from)) return;
|
|
2193
|
+
if (route === 0 || route === this.localPeerId) {
|
|
2194
|
+
this.emit("message", from, bytes, channel);
|
|
2195
|
+
}
|
|
2196
|
+
if (route === 0) {
|
|
2197
|
+
for (const p of this.connectedPeerIds()) if (p !== from) this.sendTo(p, { type: "raw", bytes }, from, channel);
|
|
2198
|
+
} else if (route !== this.localPeerId) {
|
|
2199
|
+
this.sendTo(route, { type: "raw", bytes }, from, channel);
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
allowRaw(from) {
|
|
2203
|
+
const lim = this.cfg.rateLimit;
|
|
2204
|
+
if (!lim || !(lim.messages > 0) || !(lim.perMs > 0)) return true;
|
|
2205
|
+
const now = this.cfg.clock.now();
|
|
2206
|
+
let w = this.rate.get(from);
|
|
2207
|
+
if (!w || now - w.start >= lim.perMs) this.rate.set(from, w = { start: now, n: 0 });
|
|
2208
|
+
return ++w.n <= lim.messages;
|
|
2209
|
+
}
|
|
2210
|
+
// ---- migration -------------------------------------------------------------------------------------------------
|
|
2211
|
+
setHost(hostId) {
|
|
2212
|
+
if (hostId === this.hostPeerId) return;
|
|
2213
|
+
this.hostPeerId = hostId;
|
|
2214
|
+
this.emit("hostChanged", hostId);
|
|
2215
|
+
}
|
|
2216
|
+
/** The host is gone (link closed, no frames for 3 s, or the server said so): run the s.9 rule. */
|
|
2217
|
+
handleHostLost(reason) {
|
|
2218
|
+
if (this.isHost || this.ended) return;
|
|
2219
|
+
if (this.cfg.direct) {
|
|
2220
|
+
this.teardown("host_left");
|
|
2221
|
+
return;
|
|
2222
|
+
}
|
|
2223
|
+
const failed = this.hostPeerId;
|
|
2224
|
+
const gone = this.roster.get(failed);
|
|
2225
|
+
if (gone) {
|
|
2226
|
+
this.roster.delete(failed);
|
|
2227
|
+
this.emit("peerLeft", gone, reason === "link closed" ? "timeout" : reason);
|
|
2228
|
+
}
|
|
2229
|
+
const old = this.links.get(failed);
|
|
2230
|
+
if (old) {
|
|
2231
|
+
old.onClose = null;
|
|
2232
|
+
old.onMessage = null;
|
|
2233
|
+
old.close();
|
|
2234
|
+
this.links.delete(failed);
|
|
2235
|
+
this.hiSentTo.delete(old);
|
|
2236
|
+
}
|
|
2237
|
+
const candidate = pickNewHost([...this.roster.keys(), this.localPeerId], failed) ?? this.localPeerId;
|
|
2238
|
+
if (candidate === this.localPeerId) this.becomeHost();
|
|
2239
|
+
}
|
|
2240
|
+
becomeHost() {
|
|
2241
|
+
this.epoch = this.epoch + 1 & 65535;
|
|
2242
|
+
this.hostPeerId = this.localPeerId;
|
|
2243
|
+
this.markReady();
|
|
2244
|
+
this.connected.clear();
|
|
2245
|
+
this.hiSentTo.clear();
|
|
2246
|
+
this.lastHeard.clear();
|
|
2247
|
+
this.lockstep.becomeHost();
|
|
2248
|
+
this.emit("hostChanged", this.localPeerId);
|
|
2249
|
+
if (this.serverUp) this.signaling.send({ t: "host_changed", hostId: this.localPeerId, epoch: this.epoch });
|
|
2250
|
+
for (const p of this.roster.keys()) {
|
|
2251
|
+
if (p !== this.localPeerId) this.offerTo(p);
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
onHostClaim(from, epoch, hostId) {
|
|
2255
|
+
const mine = { epoch: this.epoch, hostId: this.hostPeerId };
|
|
2256
|
+
const theirs = { epoch, hostId };
|
|
2257
|
+
if (hostId === this.hostPeerId && epoch === this.epoch && !this.isHost) {
|
|
2258
|
+
const l = this.links.get(from);
|
|
2259
|
+
if (l) this.sendHi(l);
|
|
2260
|
+
return;
|
|
2261
|
+
}
|
|
2262
|
+
if (!claimWinsWrapped(theirs, mine)) return;
|
|
2263
|
+
const wasHost = this.isHost;
|
|
2264
|
+
this.epoch = epoch;
|
|
2265
|
+
if (wasHost) {
|
|
2266
|
+
for (const [id, l] of this.links) {
|
|
2267
|
+
if (id === from) continue;
|
|
2268
|
+
l.onClose = null;
|
|
2269
|
+
l.onMessage = null;
|
|
2270
|
+
l.close();
|
|
2271
|
+
this.links.delete(id);
|
|
2272
|
+
}
|
|
2273
|
+
this.connected.clear();
|
|
2274
|
+
this.lockstep.becomeJoiner();
|
|
2275
|
+
}
|
|
2276
|
+
this.setHost(hostId);
|
|
2277
|
+
const link = this.links.get(from);
|
|
2278
|
+
if (link && from === hostId) this.sendHi(link);
|
|
2279
|
+
}
|
|
2280
|
+
// ---- signaling -------------------------------------------------------------------------------------------------
|
|
2281
|
+
onSignaling(m) {
|
|
2282
|
+
switch (m.t) {
|
|
2283
|
+
case "peer_joined": {
|
|
2284
|
+
if (!m.peer || this.roster.has(m.peer.peerId)) return;
|
|
2285
|
+
const peer = seatName(m.peer);
|
|
2286
|
+
this.roster.set(peer.peerId, peer);
|
|
2287
|
+
this.emit("peerJoined", peer);
|
|
2288
|
+
if (this.isHost) this.offerTo(peer.peerId);
|
|
2289
|
+
break;
|
|
2290
|
+
}
|
|
2291
|
+
case "peer_left":
|
|
2292
|
+
this.removePeer(Number(m.peerId), String(m.reason ?? "left"));
|
|
2293
|
+
break;
|
|
2294
|
+
case "host_changed": {
|
|
2295
|
+
if (this.cfg.direct) {
|
|
2296
|
+
if (!this.isHost) this.teardown("host_left");
|
|
2297
|
+
break;
|
|
2298
|
+
}
|
|
2299
|
+
const id = Number(m.hostId);
|
|
2300
|
+
if (typeof m.epoch === "number" && m.epoch > this.epoch) this.epoch = m.epoch & 65535;
|
|
2301
|
+
if (id === this.hostPeerId) break;
|
|
2302
|
+
if (id === this.localPeerId && !this.isHost) {
|
|
2303
|
+
const failed = this.hostPeerId;
|
|
2304
|
+
this.roster.delete(failed);
|
|
2305
|
+
this.becomeHost();
|
|
2306
|
+
} else {
|
|
2307
|
+
this.setHost(id);
|
|
2308
|
+
}
|
|
2309
|
+
break;
|
|
2310
|
+
}
|
|
2311
|
+
case "join_request":
|
|
2312
|
+
this.emit("joinRequested", seatName(m.peer));
|
|
2313
|
+
break;
|
|
2314
|
+
case "lobby": {
|
|
2315
|
+
const st = m;
|
|
2316
|
+
this.meta = { ...st.meta };
|
|
2317
|
+
if (st.resumeToken && st.resumeToken !== this.resumeToken) {
|
|
2318
|
+
this.resumeToken = st.resumeToken;
|
|
2319
|
+
this.seatSink?.(this.resumeToken);
|
|
2320
|
+
}
|
|
2321
|
+
this.roster = new Map(st.peers.map((p) => [p.peerId, seatName(p)]));
|
|
2322
|
+
if (st.hostId !== this.hostPeerId && !this.isHost) this.setHost(st.hostId);
|
|
2323
|
+
this.setPhase(st.phase);
|
|
2324
|
+
break;
|
|
2325
|
+
}
|
|
2326
|
+
case "meta":
|
|
2327
|
+
this.meta = { ...m.meta };
|
|
2328
|
+
this.emit("metaChanged", this.meta);
|
|
2329
|
+
break;
|
|
2330
|
+
case "phase":
|
|
2331
|
+
this.setPhase(m.phase);
|
|
2332
|
+
break;
|
|
2333
|
+
case "signal":
|
|
2334
|
+
void this.onSignal(Number(m.from), m);
|
|
2335
|
+
break;
|
|
2336
|
+
case "error":
|
|
2337
|
+
if (m.code === "kicked") this.teardown("kicked");
|
|
2338
|
+
else this.reportError(new OneLobbyError(String(m.code), String(m.msg ?? ""), m.data));
|
|
2339
|
+
break;
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
async onSignal(from, s) {
|
|
2343
|
+
let link = this.links.get(from);
|
|
2344
|
+
if (s.sdp?.type === "offer") {
|
|
2345
|
+
link = this.makeLink(from);
|
|
2346
|
+
}
|
|
2347
|
+
if (!link) return;
|
|
2348
|
+
const payload = {};
|
|
2349
|
+
if (s.sdp) payload.sdp = s.sdp;
|
|
2350
|
+
if (s.ice) payload.ice = s.ice;
|
|
2351
|
+
await link.handleSignal(payload);
|
|
2352
|
+
}
|
|
2353
|
+
removePeer(peerId, reason) {
|
|
2354
|
+
const peer = this.roster.get(peerId);
|
|
2355
|
+
if (peerId === this.localPeerId) {
|
|
2356
|
+
this.teardown(reason === "kicked" ? "kicked" : reason);
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
if (peer) this.roster.delete(peerId);
|
|
2360
|
+
const link = this.links.get(peerId);
|
|
2361
|
+
if (link && peerId !== this.hostPeerId) {
|
|
2362
|
+
link.onClose = null;
|
|
2363
|
+
link.onMessage = null;
|
|
2364
|
+
link.close();
|
|
2365
|
+
this.links.delete(peerId);
|
|
2366
|
+
this.hiSentTo.delete(link);
|
|
2367
|
+
}
|
|
2368
|
+
this.connected.delete(peerId);
|
|
2369
|
+
this.outbox.delete(peerId);
|
|
2370
|
+
this.lastHeard.delete(peerId);
|
|
2371
|
+
this.rate.delete(peerId);
|
|
2372
|
+
this.lockstep.onPeerLeft(peerId);
|
|
2373
|
+
this.chat.onPeerLeft(peerId);
|
|
2374
|
+
this.voice.onPeerLeft(peerId);
|
|
2375
|
+
if (peer) this.emit("peerLeft", peer, reason);
|
|
2376
|
+
if (peerId === this.hostPeerId && !this.isHost) {
|
|
2377
|
+
this.handleHostLost(reason);
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
dropPeer(peerId, reason) {
|
|
2381
|
+
this.signaling.send({ t: "kick", peerId });
|
|
2382
|
+
this.removePeer(peerId, reason);
|
|
2383
|
+
}
|
|
2384
|
+
setPhase(p) {
|
|
2385
|
+
if (p !== "lobby" && p !== "in_game") return;
|
|
2386
|
+
if (this.phase === p) return;
|
|
2387
|
+
this.phase = p;
|
|
2388
|
+
if (p === "in_game" && !this.lockstep.started) this.lockstep.markStarted();
|
|
2389
|
+
this.emit("phase", p);
|
|
2390
|
+
}
|
|
2391
|
+
onServerClosed() {
|
|
2392
|
+
if (this.ended) return;
|
|
2393
|
+
if (this.cfg.direct && !this.isHost) {
|
|
2394
|
+
this.teardown("host_left");
|
|
2395
|
+
return;
|
|
2396
|
+
}
|
|
2397
|
+
this.serverUp = false;
|
|
2398
|
+
this.emit("serverLost");
|
|
2399
|
+
if (this.links.size === 0 && this.roster.size <= 1) this.teardown("server_lost");
|
|
2400
|
+
}
|
|
2401
|
+
// ---- housekeeping ----------------------------------------------------------------------------------------------
|
|
2402
|
+
watchdog() {
|
|
2403
|
+
if (this.ended) return;
|
|
2404
|
+
const timeout = this.cfg.peerTimeoutMs ?? 0;
|
|
2405
|
+
if (timeout > 0) this.dropSilent(Math.max(timeout, MIN_PEER_TIMEOUT_MS));
|
|
2406
|
+
if (this.ended || this.isHost) return;
|
|
2407
|
+
if (this.mode === "lockstep" && this.lockstep.hostSilent()) {
|
|
2408
|
+
this.lockstep.touch();
|
|
2409
|
+
this.lockstep.pokeHost();
|
|
2410
|
+
this.handleHostLost("timeout");
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
/** peerTimeoutMs: the host drops joiners it has not heard from; a joiner handles a silent host as lost. */
|
|
2414
|
+
dropSilent(timeout) {
|
|
2415
|
+
const now = this.cfg.clock.now();
|
|
2416
|
+
if (this.isHost) {
|
|
2417
|
+
for (const id of [...this.connected]) {
|
|
2418
|
+
const last = this.lastHeard.get(id);
|
|
2419
|
+
if (last !== void 0 && now - last > timeout) this.removePeer(id, "timeout");
|
|
2420
|
+
}
|
|
2421
|
+
} else if (this.isReady) {
|
|
2422
|
+
const last = this.lastHeard.get(this.hostPeerId);
|
|
2423
|
+
if (last !== void 0 && now - last > timeout) {
|
|
2424
|
+
this.lastHeard.delete(this.hostPeerId);
|
|
2425
|
+
this.handleHostLost("timeout");
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
pingHost() {
|
|
2430
|
+
if (this.ended || this.isHost) return;
|
|
2431
|
+
this.sendTo(this.hostPeerId, { type: "ping", n: ++this.pingN, t: this.cfg.clock.now() >>> 0 }, 0, "fast");
|
|
2432
|
+
}
|
|
2433
|
+
teardown(reason) {
|
|
2434
|
+
if (this.ended) return;
|
|
2435
|
+
this.ended = true;
|
|
2436
|
+
this.cancelReadyTimer?.();
|
|
2437
|
+
if (!this.isReady) {
|
|
2438
|
+
const code = reason === "ice_failed" || reason === "kicked" ? reason : "closed";
|
|
2439
|
+
this.failReady(new OneLobbyError(code, `the lobby ended before connecting (${reason})`));
|
|
2440
|
+
}
|
|
2441
|
+
this.cancelWatchdog?.();
|
|
2442
|
+
this.cancelPing?.();
|
|
2443
|
+
this.lockstep.stop();
|
|
2444
|
+
this.voice.close();
|
|
2445
|
+
for (const l of this.links.values()) {
|
|
2446
|
+
l.onClose = null;
|
|
2447
|
+
l.onMessage = null;
|
|
2448
|
+
l.close();
|
|
2449
|
+
}
|
|
2450
|
+
this.links.clear();
|
|
2451
|
+
this.signaling.onClose = null;
|
|
2452
|
+
this.signaling.onMessage = null;
|
|
2453
|
+
this.signaling.close();
|
|
2454
|
+
this.emit("sessionEnded", reason);
|
|
2455
|
+
}
|
|
2456
|
+
/** Total in-flight links (diagnostics and tests). */
|
|
2457
|
+
get linkCount() {
|
|
2458
|
+
return this.links.size;
|
|
2459
|
+
}
|
|
2460
|
+
};
|
|
2461
|
+
|
|
2462
|
+
// src/transport.ts
|
|
2463
|
+
var CHANNEL_IDS = { reliable: 0, fast: 1, voice: 2 };
|
|
2464
|
+
var defaultSocketFactory = (url) => new WebSocket(url);
|
|
2465
|
+
|
|
2466
|
+
// src/voice-browser.ts
|
|
2467
|
+
function webCodecsOpusAvailable() {
|
|
2468
|
+
return typeof AudioEncoder !== "undefined" && typeof AudioDecoder !== "undefined" && typeof AudioData !== "undefined";
|
|
2469
|
+
}
|
|
2470
|
+
var webCodecsOpus = {
|
|
2471
|
+
createEncoder(opts, onFrame) {
|
|
2472
|
+
let ts = 0;
|
|
2473
|
+
const enc4 = new AudioEncoder({
|
|
2474
|
+
output: (chunk) => {
|
|
2475
|
+
const out = new Uint8Array(chunk.byteLength);
|
|
2476
|
+
chunk.copyTo(out);
|
|
2477
|
+
onFrame(out);
|
|
2478
|
+
},
|
|
2479
|
+
error: (e) => console.error("onelobby: AudioEncoder", e)
|
|
2480
|
+
});
|
|
2481
|
+
enc4.configure({
|
|
2482
|
+
codec: "opus",
|
|
2483
|
+
sampleRate: SAMPLE_RATE,
|
|
2484
|
+
numberOfChannels: 1,
|
|
2485
|
+
bitrate: opts.bitrate,
|
|
2486
|
+
opus: { frameDuration: 2e4, application: "voip", useinbandfec: true, usedtx: false, packetlossperc: 10 }
|
|
2487
|
+
});
|
|
2488
|
+
return {
|
|
2489
|
+
encode(pcm) {
|
|
2490
|
+
const data = new AudioData({
|
|
2491
|
+
format: "f32",
|
|
2492
|
+
sampleRate: SAMPLE_RATE,
|
|
2493
|
+
numberOfFrames: pcm.length,
|
|
2494
|
+
numberOfChannels: 1,
|
|
2495
|
+
timestamp: ts,
|
|
2496
|
+
data: pcm
|
|
2497
|
+
});
|
|
2498
|
+
ts += 2e4;
|
|
2499
|
+
enc4.encode(data);
|
|
2500
|
+
data.close();
|
|
2501
|
+
},
|
|
2502
|
+
close() {
|
|
2503
|
+
if (enc4.state !== "closed") enc4.close();
|
|
2504
|
+
}
|
|
2505
|
+
};
|
|
2506
|
+
},
|
|
2507
|
+
createDecoder(onPcm) {
|
|
2508
|
+
let ts = 0;
|
|
2509
|
+
const dec4 = new AudioDecoder({
|
|
2510
|
+
output: (audio) => {
|
|
2511
|
+
const pcm = new Float32Array(audio.numberOfFrames);
|
|
2512
|
+
audio.copyTo(pcm, { planeIndex: 0, format: "f32-planar" });
|
|
2513
|
+
audio.close();
|
|
2514
|
+
onPcm(pcm);
|
|
2515
|
+
},
|
|
2516
|
+
error: (e) => console.error("onelobby: AudioDecoder", e)
|
|
2517
|
+
});
|
|
2518
|
+
dec4.configure({ codec: "opus", sampleRate: SAMPLE_RATE, numberOfChannels: 1 });
|
|
2519
|
+
return {
|
|
2520
|
+
decode(opus) {
|
|
2521
|
+
if (opus === null) {
|
|
2522
|
+
onPcm(new Float32Array(FRAME_SAMPLES));
|
|
2523
|
+
return;
|
|
2524
|
+
}
|
|
2525
|
+
dec4.decode(new EncodedAudioChunk({ type: "key", timestamp: ts, data: opus }));
|
|
2526
|
+
ts += 2e4;
|
|
2527
|
+
},
|
|
2528
|
+
close() {
|
|
2529
|
+
if (dec4.state !== "closed") dec4.close();
|
|
2530
|
+
}
|
|
2531
|
+
};
|
|
2532
|
+
}
|
|
2533
|
+
};
|
|
2534
|
+
var WORKLET_SRC = `
|
|
2535
|
+
class OneLobbyCapture extends AudioWorkletProcessor {
|
|
2536
|
+
constructor() { super(); this.buf = new Float32Array(${FRAME_SAMPLES}); this.n = 0; }
|
|
2537
|
+
process(inputs) {
|
|
2538
|
+
const ch = inputs[0] && inputs[0][0];
|
|
2539
|
+
if (ch) {
|
|
2540
|
+
for (let i = 0; i < ch.length; i++) {
|
|
2541
|
+
this.buf[this.n++] = ch[i];
|
|
2542
|
+
if (this.n === ${FRAME_SAMPLES}) {
|
|
2543
|
+
const out = this.buf; this.buf = new Float32Array(${FRAME_SAMPLES}); this.n = 0;
|
|
2544
|
+
this.port.postMessage(out, [out.buffer]);
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
return true;
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
registerProcessor('onelobby-capture', OneLobbyCapture);
|
|
2552
|
+
`;
|
|
2553
|
+
var WorkletMic = class {
|
|
2554
|
+
ctx = null;
|
|
2555
|
+
stream = null;
|
|
2556
|
+
async start(onFrame) {
|
|
2557
|
+
this.stream = await navigator.mediaDevices.getUserMedia({
|
|
2558
|
+
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true }
|
|
2559
|
+
});
|
|
2560
|
+
this.ctx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
|
2561
|
+
const url = URL.createObjectURL(new Blob([WORKLET_SRC], { type: "text/javascript" }));
|
|
2562
|
+
try {
|
|
2563
|
+
await this.ctx.audioWorklet.addModule(url);
|
|
2564
|
+
} finally {
|
|
2565
|
+
URL.revokeObjectURL(url);
|
|
2566
|
+
}
|
|
2567
|
+
const src = this.ctx.createMediaStreamSource(this.stream);
|
|
2568
|
+
const node = new AudioWorkletNode(this.ctx, "onelobby-capture", { numberOfInputs: 1, numberOfOutputs: 0 });
|
|
2569
|
+
node.port.onmessage = (e) => onFrame(e.data);
|
|
2570
|
+
src.connect(node);
|
|
2571
|
+
if (this.ctx.state === "suspended") await this.ctx.resume();
|
|
2572
|
+
}
|
|
2573
|
+
stop() {
|
|
2574
|
+
for (const t of this.stream?.getTracks() ?? []) t.stop();
|
|
2575
|
+
void this.ctx?.close();
|
|
2576
|
+
this.stream = null;
|
|
2577
|
+
this.ctx = null;
|
|
2578
|
+
}
|
|
2579
|
+
};
|
|
2580
|
+
var WebAudioSink = class {
|
|
2581
|
+
ctx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
|
2582
|
+
next = /* @__PURE__ */ new Map();
|
|
2583
|
+
constructor() {
|
|
2584
|
+
void this.ctx.resume();
|
|
2585
|
+
}
|
|
2586
|
+
play(peerId, pcm, gain) {
|
|
2587
|
+
if (pcm.length === 0) return;
|
|
2588
|
+
const buf = this.ctx.createBuffer(1, pcm.length, SAMPLE_RATE);
|
|
2589
|
+
buf.copyToChannel(pcm, 0);
|
|
2590
|
+
const src = this.ctx.createBufferSource();
|
|
2591
|
+
src.buffer = buf;
|
|
2592
|
+
const g = this.ctx.createGain();
|
|
2593
|
+
g.gain.value = gain;
|
|
2594
|
+
src.connect(g).connect(this.ctx.destination);
|
|
2595
|
+
const now = this.ctx.currentTime;
|
|
2596
|
+
let at = this.next.get(peerId) ?? 0;
|
|
2597
|
+
if (at < now + 0.01) at = now + 0.02;
|
|
2598
|
+
src.start(at);
|
|
2599
|
+
this.next.set(peerId, at + pcm.length / SAMPLE_RATE);
|
|
2600
|
+
}
|
|
2601
|
+
close() {
|
|
2602
|
+
void this.ctx.close();
|
|
2603
|
+
}
|
|
2604
|
+
};
|
|
2605
|
+
|
|
2606
|
+
// src/webrtc.ts
|
|
2607
|
+
var CHANNEL_INIT = {
|
|
2608
|
+
reliable: { negotiated: true, id: CHANNEL_IDS.reliable, ordered: true },
|
|
2609
|
+
fast: { negotiated: true, id: CHANNEL_IDS.fast, ordered: false, maxRetransmits: 0 },
|
|
2610
|
+
voice: { negotiated: true, id: CHANNEL_IDS.voice, ordered: false, maxRetransmits: 0 }
|
|
2611
|
+
};
|
|
2612
|
+
var WebRtcLink = class {
|
|
2613
|
+
remoteId;
|
|
2614
|
+
isOpen = false;
|
|
2615
|
+
onSignal = null;
|
|
2616
|
+
onOpen = null;
|
|
2617
|
+
onClose = null;
|
|
2618
|
+
onMessage = null;
|
|
2619
|
+
onPath = null;
|
|
2620
|
+
pc;
|
|
2621
|
+
channels = {};
|
|
2622
|
+
pendingIce = [];
|
|
2623
|
+
closed = false;
|
|
2624
|
+
opened = 0;
|
|
2625
|
+
extra;
|
|
2626
|
+
/** `rtc`: the RTCPeerConnection class to use (default the global one; in Node, node-datachannel's polyfill). */
|
|
2627
|
+
constructor(opts, rtc = globalThis.RTCPeerConnection) {
|
|
2628
|
+
this.remoteId = opts.remoteId;
|
|
2629
|
+
this.extra = opts.extraRemoteCandidates ?? [];
|
|
2630
|
+
const config = {
|
|
2631
|
+
iceServers: opts.iceServers,
|
|
2632
|
+
iceTransportPolicy: opts.relayOnly ? "relay" : "all"
|
|
2633
|
+
};
|
|
2634
|
+
if (opts.portRange) {
|
|
2635
|
+
config.portRangeBegin = opts.portRange.begin;
|
|
2636
|
+
config.portRangeEnd = opts.portRange.end;
|
|
2637
|
+
if (opts.portRange.begin === opts.portRange.end) config.enableIceUdpMux = true;
|
|
2638
|
+
}
|
|
2639
|
+
this.pc = new rtc(config);
|
|
2640
|
+
this.pc.onicecandidate = (e) => {
|
|
2641
|
+
if (e.candidate) this.onSignal?.({ ice: e.candidate.toJSON() });
|
|
2642
|
+
};
|
|
2643
|
+
this.pc.onconnectionstatechange = () => {
|
|
2644
|
+
const s = this.pc.connectionState;
|
|
2645
|
+
if (s === "failed" || s === "closed" || s === "disconnected") {
|
|
2646
|
+
if (s === "disconnected") setTimeout(() => this.pc.connectionState === "disconnected" && this.fail("disconnected"), 4e3);
|
|
2647
|
+
else this.fail(s);
|
|
2648
|
+
}
|
|
2649
|
+
};
|
|
2650
|
+
for (const name of ["reliable", "fast", "voice"]) {
|
|
2651
|
+
const ch = this.pc.createDataChannel(name, CHANNEL_INIT[name]);
|
|
2652
|
+
ch.binaryType = "arraybuffer";
|
|
2653
|
+
ch.onopen = () => {
|
|
2654
|
+
if (++this.opened === 3 && !this.closed) {
|
|
2655
|
+
this.isOpen = true;
|
|
2656
|
+
this.onOpen?.();
|
|
2657
|
+
void this.reportPath();
|
|
2658
|
+
}
|
|
2659
|
+
};
|
|
2660
|
+
ch.onclose = () => this.fail(`channel ${name} closed`);
|
|
2661
|
+
ch.onmessage = (e) => {
|
|
2662
|
+
const d = e.data;
|
|
2663
|
+
const data = d instanceof ArrayBuffer ? new Uint8Array(d) : ArrayBuffer.isView(d) ? new Uint8Array(d.buffer, d.byteOffset, d.byteLength) : new Uint8Array(d);
|
|
2664
|
+
this.onMessage?.(name, data);
|
|
2665
|
+
};
|
|
2666
|
+
this.channels[name] = ch;
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
startOffer() {
|
|
2670
|
+
void (async () => {
|
|
2671
|
+
try {
|
|
2672
|
+
const offer = await this.pc.createOffer();
|
|
2673
|
+
await this.pc.setLocalDescription(offer);
|
|
2674
|
+
this.onSignal?.({ sdp: { type: "offer", sdp: offer.sdp ?? "" } });
|
|
2675
|
+
} catch (e) {
|
|
2676
|
+
this.fail(`offer failed: ${String(e)}`);
|
|
2677
|
+
}
|
|
2678
|
+
})();
|
|
2679
|
+
}
|
|
2680
|
+
async handleSignal(s) {
|
|
2681
|
+
try {
|
|
2682
|
+
if (s.sdp) {
|
|
2683
|
+
await this.pc.setRemoteDescription({ type: s.sdp.type, sdp: s.sdp.sdp });
|
|
2684
|
+
for (const c of this.pendingIce.splice(0)) await this.pc.addIceCandidate(c);
|
|
2685
|
+
if (this.extra.length) {
|
|
2686
|
+
const mid = /^a=mid:(\S+)/m.exec(s.sdp.sdp)?.[1] ?? "0";
|
|
2687
|
+
for (const candidate of this.extra.splice(0)) await this.pc.addIceCandidate({ candidate, sdpMid: mid, sdpMLineIndex: 0 });
|
|
2688
|
+
}
|
|
2689
|
+
if (s.sdp.type === "offer") {
|
|
2690
|
+
const answer = await this.pc.createAnswer();
|
|
2691
|
+
await this.pc.setLocalDescription(answer);
|
|
2692
|
+
this.onSignal?.({ sdp: { type: "answer", sdp: answer.sdp ?? "" } });
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
if (s.ice) {
|
|
2696
|
+
if (this.pc.remoteDescription) await this.pc.addIceCandidate(s.ice);
|
|
2697
|
+
else this.pendingIce.push(s.ice);
|
|
2698
|
+
}
|
|
2699
|
+
} catch (e) {
|
|
2700
|
+
this.fail(`signal failed: ${String(e)}`);
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
/** Selected candidate pair has a relay candidate -> "relayed", else "direct". Retries while stats settle. */
|
|
2704
|
+
async reportPath(attempt = 0) {
|
|
2705
|
+
if (this.closed || attempt > 10) return;
|
|
2706
|
+
try {
|
|
2707
|
+
const stats = await this.pc.getStats();
|
|
2708
|
+
const byId = /* @__PURE__ */ new Map();
|
|
2709
|
+
stats.forEach((v) => byId.set(v.id, v));
|
|
2710
|
+
let pair;
|
|
2711
|
+
stats.forEach((v) => {
|
|
2712
|
+
if (v.type === "transport" && v.selectedCandidatePairId) pair = byId.get(v.selectedCandidatePairId);
|
|
2713
|
+
});
|
|
2714
|
+
if (!pair) stats.forEach((v) => {
|
|
2715
|
+
if (v.type === "candidate-pair" && (v.selected || v.nominated && v.state === "succeeded")) pair = v;
|
|
2716
|
+
});
|
|
2717
|
+
const l = pair && byId.get(pair.localCandidateId);
|
|
2718
|
+
const r = pair && byId.get(pair.remoteCandidateId);
|
|
2719
|
+
if (l && r) {
|
|
2720
|
+
this.onPath?.(l.candidateType === "relay" || r.candidateType === "relay" ? "relayed" : "direct");
|
|
2721
|
+
return;
|
|
2722
|
+
}
|
|
2723
|
+
} catch {
|
|
2724
|
+
}
|
|
2725
|
+
setTimeout(() => void this.reportPath(attempt + 1), 300);
|
|
2726
|
+
}
|
|
2727
|
+
send(channel, bytes) {
|
|
2728
|
+
const ch = this.channels[channel];
|
|
2729
|
+
if (ch && ch.readyState === "open") ch.send(bytes);
|
|
2730
|
+
}
|
|
2731
|
+
close() {
|
|
2732
|
+
this.fail("closed locally");
|
|
2733
|
+
}
|
|
2734
|
+
fail(reason) {
|
|
2735
|
+
if (this.closed) return;
|
|
2736
|
+
this.closed = true;
|
|
2737
|
+
const wasOpen = this.isOpen;
|
|
2738
|
+
this.isOpen = false;
|
|
2739
|
+
try {
|
|
2740
|
+
this.pc.close();
|
|
2741
|
+
} catch {
|
|
2742
|
+
}
|
|
2743
|
+
void wasOpen;
|
|
2744
|
+
this.onClose?.(reason);
|
|
2745
|
+
}
|
|
2746
|
+
};
|
|
2747
|
+
var webRtcLinkFactory = { create: (opts) => new WebRtcLink(opts) };
|
|
2748
|
+
function webRtcLinkFactoryFor(rtc) {
|
|
2749
|
+
return { create: (opts) => new WebRtcLink(opts, rtc) };
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
// src/joincode.ts
|
|
2753
|
+
var JOIN_CODE_LENGTH = 11;
|
|
2754
|
+
function crc5(value, bits) {
|
|
2755
|
+
let crc = 0;
|
|
2756
|
+
for (let i = bits - 1; i >= 0; i--) {
|
|
2757
|
+
const bit = Number(value >> BigInt(i) & 1n);
|
|
2758
|
+
const top = crc >> 4 & 1;
|
|
2759
|
+
crc = crc << 1 & 31;
|
|
2760
|
+
if (top ^ bit) crc ^= 5;
|
|
2761
|
+
}
|
|
2762
|
+
return crc;
|
|
2763
|
+
}
|
|
2764
|
+
function normalizeJoinCode(input) {
|
|
2765
|
+
const s = input.replace(/[ \-]/g, "").replace(/[a-z]/g, (c) => c.toUpperCase()).replace(/[IL]/g, "1").replace(/O/g, "0");
|
|
2766
|
+
if (s.length !== JOIN_CODE_LENGTH) return null;
|
|
2767
|
+
for (const c of s) if (CROCKFORD_ALPHABET.indexOf(c) < 0) return null;
|
|
2768
|
+
return s;
|
|
2769
|
+
}
|
|
2770
|
+
function decodeJoinCode(input) {
|
|
2771
|
+
const s = normalizeJoinCode(input);
|
|
2772
|
+
if (!s) return { error: "bad_code" };
|
|
2773
|
+
let v = 0n;
|
|
2774
|
+
for (const c of s) v = v << 5n | BigInt(CROCKFORD_ALPHABET.indexOf(c));
|
|
2775
|
+
const data = v >> 5n;
|
|
2776
|
+
if (crc5(data, 50) !== Number(v & 31n)) return { error: "bad_code" };
|
|
2777
|
+
if (data >> 48n !== 0n) return { error: "unknown_version" };
|
|
2778
|
+
const port = Number(data & 0xffffn);
|
|
2779
|
+
if (port === 0) return { error: "bad_code" };
|
|
2780
|
+
const a = Number(data >> 16n & 0xffffffffn);
|
|
2781
|
+
return { ip: [a >>> 24, a >>> 16 & 255, a >>> 8 & 255, a & 255].join("."), port };
|
|
2782
|
+
}
|
|
2783
|
+
function looksLikeJoinCode(input) {
|
|
2784
|
+
return !/[.:]/.test(input) && normalizeJoinCode(input) !== null;
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
// src/direct-address.ts
|
|
2788
|
+
var DEFAULT_DIRECT_PORT = 47810;
|
|
2789
|
+
var IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
|
2790
|
+
var HOSTNAME = /^(?=.{1,253}$)([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/;
|
|
2791
|
+
function isIPv4(s) {
|
|
2792
|
+
const m = IPV4.exec(s);
|
|
2793
|
+
return !!m && m.slice(1).every((x) => Number(x) <= 255);
|
|
2794
|
+
}
|
|
2795
|
+
function isIPv6(s) {
|
|
2796
|
+
if (!/^[0-9A-Fa-f:.]+$/.test(s) || s.split("::").length > 2 || !s.includes(":")) return false;
|
|
2797
|
+
const tail = s.split(":").pop() ?? "";
|
|
2798
|
+
const groups = s.split(":").filter((g, i, a) => g !== "" || i > 0 && i < a.length - 1);
|
|
2799
|
+
if (tail.includes(".") && !isIPv4(tail)) return false;
|
|
2800
|
+
return groups.every((g) => g === "" || g.includes(".") || /^[0-9A-Fa-f]{1,4}$/.test(g));
|
|
2801
|
+
}
|
|
2802
|
+
function parseDirectAddress(input, defaultPort = DEFAULT_DIRECT_PORT) {
|
|
2803
|
+
let s = input.trim();
|
|
2804
|
+
if (/^ws:\/\//i.test(s)) s = s.slice(5);
|
|
2805
|
+
s = s.replace(/\/+$/, "");
|
|
2806
|
+
if (!s) return null;
|
|
2807
|
+
let host;
|
|
2808
|
+
let portText = null;
|
|
2809
|
+
if (s[0] === "[") {
|
|
2810
|
+
const close = s.indexOf("]");
|
|
2811
|
+
if (close < 0) return null;
|
|
2812
|
+
host = s.slice(1, close);
|
|
2813
|
+
const rest = s.slice(close + 1);
|
|
2814
|
+
if (rest) {
|
|
2815
|
+
if (rest[0] !== ":") return null;
|
|
2816
|
+
portText = rest.slice(1);
|
|
2817
|
+
}
|
|
2818
|
+
} else {
|
|
2819
|
+
const first = s.indexOf(":");
|
|
2820
|
+
if (first >= 0 && first === s.lastIndexOf(":")) {
|
|
2821
|
+
host = s.slice(0, first);
|
|
2822
|
+
portText = s.slice(first + 1);
|
|
2823
|
+
} else host = s;
|
|
2824
|
+
}
|
|
2825
|
+
let port = defaultPort;
|
|
2826
|
+
if (portText !== null) {
|
|
2827
|
+
if (!/^\d{1,5}$/.test(portText)) return null;
|
|
2828
|
+
port = Number(portText);
|
|
2829
|
+
if (port < 1 || port > 65535) return null;
|
|
2830
|
+
}
|
|
2831
|
+
const v6 = isIPv6(host);
|
|
2832
|
+
if (!v6 && !isIPv4(host) && !HOSTNAME.test(host)) return null;
|
|
2833
|
+
const display = (v6 ? `[${host}]` : host) + `:${port}`;
|
|
2834
|
+
return { host, port, isIPv6: v6, display, url: `ws://${display}/`, candidate: `candidate:1 1 UDP 2122317823 ${host} ${port} typ host` };
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
// src/onelobby.ts
|
|
2838
|
+
var LIB_NAME = "onelobby";
|
|
2839
|
+
var LIB_VERSION = "0.2.0";
|
|
2840
|
+
var DEFAULT_READY_TIMEOUT_MS = 15e3;
|
|
2841
|
+
function hasMic() {
|
|
2842
|
+
return typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia && typeof AudioWorkletNode !== "undefined";
|
|
2843
|
+
}
|
|
2844
|
+
function browserStorage() {
|
|
2845
|
+
try {
|
|
2846
|
+
const s = globalThis.sessionStorage;
|
|
2847
|
+
return s ?? null;
|
|
2848
|
+
} catch {
|
|
2849
|
+
return null;
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
var liveSeats = /* @__PURE__ */ new Set();
|
|
2853
|
+
function seatInUse(token) {
|
|
2854
|
+
for (const l of liveSeats) if (l.resumeToken === token) return true;
|
|
2855
|
+
return false;
|
|
2856
|
+
}
|
|
2857
|
+
var OneLobby = class extends Emitter {
|
|
2858
|
+
options;
|
|
2859
|
+
socketFactory;
|
|
2860
|
+
linkFactory;
|
|
2861
|
+
clock;
|
|
2862
|
+
wall;
|
|
2863
|
+
voiceCodec;
|
|
2864
|
+
createMic;
|
|
2865
|
+
createSink;
|
|
2866
|
+
storage;
|
|
2867
|
+
constructor(o) {
|
|
2868
|
+
super();
|
|
2869
|
+
const tickRate = o.tickRate ?? 20;
|
|
2870
|
+
this.options = {
|
|
2871
|
+
gameId: o.gameId,
|
|
2872
|
+
netVersion: o.netVersion,
|
|
2873
|
+
lobbyServer: o.lobbyServer,
|
|
2874
|
+
mode: o.mode ?? "lockstep",
|
|
2875
|
+
tickRate,
|
|
2876
|
+
hashEveryTicks: o.hashEveryTicks ?? tickRate,
|
|
2877
|
+
displayName: (o.displayName ?? "").trim(),
|
|
2878
|
+
relayOnly: o.relayOnly ?? false,
|
|
2879
|
+
readyTimeoutMs: o.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
|
|
2880
|
+
peerTimeoutMs: o.peerTimeoutMs ?? 0
|
|
2881
|
+
};
|
|
2882
|
+
const a = o.advanced ?? {};
|
|
2883
|
+
const pick = (k) => a[k] !== void 0 ? a[k] : o[k];
|
|
2884
|
+
this.socketFactory = pick("socketFactory") ?? defaultSocketFactory;
|
|
2885
|
+
this.linkFactory = pick("linkFactory") ?? webRtcLinkFactory;
|
|
2886
|
+
this.clock = pick("clock") ?? systemClock;
|
|
2887
|
+
this.wall = pick("wallClock") ?? (() => Date.now());
|
|
2888
|
+
const codec = pick("voiceCodec");
|
|
2889
|
+
this.voiceCodec = codec !== void 0 ? codec : webCodecsOpusAvailable() ? webCodecsOpus : null;
|
|
2890
|
+
const mic = pick("createMic");
|
|
2891
|
+
this.createMic = mic !== void 0 ? mic : hasMic() ? () => new WorkletMic() : null;
|
|
2892
|
+
const sink = pick("createSink");
|
|
2893
|
+
this.createSink = sink !== void 0 ? sink : typeof AudioContext !== "undefined" ? () => new WebAudioSink() : null;
|
|
2894
|
+
this.storage = o.rememberSeat === false ? null : a.storage !== void 0 ? a.storage : browserStorage();
|
|
2895
|
+
}
|
|
2896
|
+
// ---- remembered seat -------------------------------------------------------------------------------------------
|
|
2897
|
+
get seatKey() {
|
|
2898
|
+
return `onelobby.seat.${this.options.gameId}`;
|
|
2899
|
+
}
|
|
2900
|
+
loadSeat() {
|
|
2901
|
+
if (!this.storage) return null;
|
|
2902
|
+
try {
|
|
2903
|
+
const raw = this.storage.getItem(this.seatKey);
|
|
2904
|
+
if (!raw) return null;
|
|
2905
|
+
const s = JSON.parse(raw);
|
|
2906
|
+
if (typeof s.code !== "string" || typeof s.token !== "string" || s.server !== this.options.lobbyServer) return null;
|
|
2907
|
+
return s;
|
|
2908
|
+
} catch {
|
|
2909
|
+
return null;
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
saveSeat(code, token) {
|
|
2913
|
+
if (!this.storage || !token) return;
|
|
2914
|
+
try {
|
|
2915
|
+
this.storage.setItem(this.seatKey, JSON.stringify({ code, token, server: this.options.lobbyServer }));
|
|
2916
|
+
} catch {
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2919
|
+
clearSeat(code) {
|
|
2920
|
+
if (!this.storage) return;
|
|
2921
|
+
try {
|
|
2922
|
+
if (code !== void 0) {
|
|
2923
|
+
const s = this.loadSeat();
|
|
2924
|
+
if (s && s.code !== code) return;
|
|
2925
|
+
}
|
|
2926
|
+
this.storage.removeItem(this.seatKey);
|
|
2927
|
+
} catch {
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
/** The lobby code of the seat this tab can take back (rememberSeat), or null. */
|
|
2931
|
+
get rememberedCode() {
|
|
2932
|
+
return this.loadSeat()?.code ?? null;
|
|
2933
|
+
}
|
|
2934
|
+
/**
|
|
2935
|
+
* Take back the seat remembered from before a page reload (host or joiner). Resolves null when there is none
|
|
2936
|
+
* or the server no longer holds it (the seat is forgotten then).
|
|
2937
|
+
*/
|
|
2938
|
+
async resume() {
|
|
2939
|
+
const seat = this.loadSeat();
|
|
2940
|
+
if (!seat || seatInUse(seat.token)) return null;
|
|
2941
|
+
try {
|
|
2942
|
+
return await this.rejoin(seat.token);
|
|
2943
|
+
} catch (e) {
|
|
2944
|
+
if (e instanceof OneLobbyError && e.code === "invalid_token") {
|
|
2945
|
+
this.clearSeat();
|
|
2946
|
+
return null;
|
|
2947
|
+
}
|
|
2948
|
+
throw e;
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
// ---- connecting ------------------------------------------------------------------------------------------------
|
|
2952
|
+
helloMsg(resumeToken) {
|
|
2953
|
+
return {
|
|
2954
|
+
t: "hello",
|
|
2955
|
+
proto: PROTO_ID,
|
|
2956
|
+
gameId: this.options.gameId,
|
|
2957
|
+
netVersion: this.options.netVersion,
|
|
2958
|
+
client: { lib: LIB_NAME, ver: LIB_VERSION, platform: typeof navigator !== "undefined" ? "browser" : "node" },
|
|
2959
|
+
name: this.options.displayName.slice(0, 24),
|
|
2960
|
+
...resumeToken ? { resumeToken } : {}
|
|
2961
|
+
};
|
|
2962
|
+
}
|
|
2963
|
+
async connect(url = this.options.lobbyServer, factory = this.socketFactory, timeoutMs = 0) {
|
|
2964
|
+
const sig = new SignalingClient(url, factory, this.clock);
|
|
2965
|
+
if (timeoutMs > 0) {
|
|
2966
|
+
let cancel = () => {
|
|
2967
|
+
};
|
|
2968
|
+
const timeout = new Promise((_, reject) => {
|
|
2969
|
+
cancel = this.clock.setTimeout(() => reject(new OneLobbyError("direct_unreachable", `no answer from ${url} within ${timeoutMs} ms`)), timeoutMs);
|
|
2970
|
+
});
|
|
2971
|
+
try {
|
|
2972
|
+
await Promise.race([sig.connect(), timeout]);
|
|
2973
|
+
} catch (e) {
|
|
2974
|
+
sig.close();
|
|
2975
|
+
if (e instanceof OneLobbyError && e.code === "connect_failed") throw new OneLobbyError("direct_unreachable", `no answer from ${url}`);
|
|
2976
|
+
throw e;
|
|
2977
|
+
} finally {
|
|
2978
|
+
cancel();
|
|
2979
|
+
}
|
|
2980
|
+
} else await sig.connect();
|
|
2981
|
+
try {
|
|
2982
|
+
const welcome = await sig.request(this.helloMsg(), "welcome");
|
|
2983
|
+
return { sig, welcome };
|
|
2984
|
+
} catch (e) {
|
|
2985
|
+
sig.close();
|
|
2986
|
+
throw e;
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2989
|
+
makeLobby(sig, welcome, state, direct, rateLimit) {
|
|
2990
|
+
const lobby = new Lobby({
|
|
2991
|
+
signaling: sig,
|
|
2992
|
+
state,
|
|
2993
|
+
ice: welcome.ice ?? [],
|
|
2994
|
+
links: direct?.links ?? this.linkFactory,
|
|
2995
|
+
direct: !!direct,
|
|
2996
|
+
clock: this.clock,
|
|
2997
|
+
wall: this.wall,
|
|
2998
|
+
hashEveryTicks: this.options.hashEveryTicks,
|
|
2999
|
+
relayOnly: state.relayOnly ?? this.options.relayOnly,
|
|
3000
|
+
displayName: this.options.displayName,
|
|
3001
|
+
voice: { codec: this.voiceCodec, createSource: this.createMic, createSink: this.createSink },
|
|
3002
|
+
readyTimeoutMs: this.options.readyTimeoutMs,
|
|
3003
|
+
peerTimeoutMs: this.options.peerTimeoutMs,
|
|
3004
|
+
rateLimit
|
|
3005
|
+
});
|
|
3006
|
+
lobby.errorSink = (e) => this.emit("error", e);
|
|
3007
|
+
if (this.storage && !direct) {
|
|
3008
|
+
liveSeats.add(lobby);
|
|
3009
|
+
this.saveSeat(lobby.code, lobby.resumeToken);
|
|
3010
|
+
lobby.seatSink = (token) => this.saveSeat(lobby.code, token);
|
|
3011
|
+
lobby.on("sessionEnded", () => {
|
|
3012
|
+
liveSeats.delete(lobby);
|
|
3013
|
+
this.clearSeat(lobby.code);
|
|
3014
|
+
});
|
|
3015
|
+
}
|
|
3016
|
+
return lobby;
|
|
3017
|
+
}
|
|
3018
|
+
async host(h) {
|
|
3019
|
+
const { sig, welcome } = await this.connect();
|
|
3020
|
+
try {
|
|
3021
|
+
const state = await sig.request(
|
|
3022
|
+
{
|
|
3023
|
+
t: "create",
|
|
3024
|
+
name: h.name,
|
|
3025
|
+
maxPlayers: h.maxPlayers,
|
|
3026
|
+
visibility: h.visibility ?? "unlisted",
|
|
3027
|
+
mode: this.options.mode,
|
|
3028
|
+
tickRate: this.options.tickRate,
|
|
3029
|
+
lateJoin: h.lateJoin ?? true,
|
|
3030
|
+
meta: h.meta ?? {},
|
|
3031
|
+
...this.options.relayOnly ? { relayOnly: true } : {}
|
|
3032
|
+
},
|
|
3033
|
+
"lobby"
|
|
3034
|
+
);
|
|
3035
|
+
return this.makeLobby(sig, welcome, state, void 0, h.rateLimit);
|
|
3036
|
+
} catch (e) {
|
|
3037
|
+
sig.close();
|
|
3038
|
+
throw e;
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
3041
|
+
async join(code) {
|
|
3042
|
+
const norm = normalizeCode(code);
|
|
3043
|
+
if (!norm) throw new OneLobbyError("bad_code", `"${code}" is not a valid join code`);
|
|
3044
|
+
const seat = this.loadSeat();
|
|
3045
|
+
if (seat && seat.code === norm && !seatInUse(seat.token)) {
|
|
3046
|
+
try {
|
|
3047
|
+
return await this.rejoin(seat.token);
|
|
3048
|
+
} catch (e) {
|
|
3049
|
+
this.clearSeat();
|
|
3050
|
+
if (!(e instanceof OneLobbyError && e.code === "invalid_token")) throw e;
|
|
3051
|
+
}
|
|
3052
|
+
}
|
|
3053
|
+
const { sig, welcome } = await this.connect();
|
|
3054
|
+
try {
|
|
3055
|
+
const state = await sig.request({ t: "join", code: norm }, "lobby");
|
|
3056
|
+
return this.makeLobby(sig, welcome, state);
|
|
3057
|
+
} catch (e) {
|
|
3058
|
+
sig.close();
|
|
3059
|
+
throw e;
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
3062
|
+
/**
|
|
3063
|
+
* Join a direct-IP host (docs/direct-ip.md): a join code from the host's screen (`6B01-RGFE-P2B`, any case, spaces
|
|
3064
|
+
* or hyphens) or, advanced, an address (`ip`, `ip:port`, `[v6]:port`). No lobby server is involved: the code is
|
|
3065
|
+
* the host's address. A mistyped code fails at once with `bad_code`; nobody listening is `direct_unreachable`.
|
|
3066
|
+
* The address is also given to ICE as the host's UDP candidate. When the host leaves, `sessionEnded("host_left")`.
|
|
3067
|
+
*/
|
|
3068
|
+
async joinDirect(codeOrAddress, opts = {}) {
|
|
3069
|
+
let input = codeOrAddress.trim();
|
|
3070
|
+
if (looksLikeJoinCode(input)) {
|
|
3071
|
+
const d = decodeJoinCode(input);
|
|
3072
|
+
if ("error" in d)
|
|
3073
|
+
throw new OneLobbyError(d.error, d.error === "unknown_version" ? `"${input}" is a join code from a newer version of the game` : `"${input}" is not a valid join code: check for a typo`);
|
|
3074
|
+
input = `${d.ip}:${d.port}`;
|
|
3075
|
+
}
|
|
3076
|
+
const addr = parseDirectAddress(input, opts.defaultPort ?? DEFAULT_DIRECT_PORT);
|
|
3077
|
+
if (!addr) throw new OneLobbyError("bad_address", `"${codeOrAddress}" is not a join code or an address (try 6B01-RGFE-P2B or 192.168.1.20:47810)`);
|
|
3078
|
+
const inner = this.linkFactory;
|
|
3079
|
+
const links = { create: (o) => inner.create({ ...o, iceServers: [], extraRemoteCandidates: [addr.candidate] }) };
|
|
3080
|
+
const { sig, welcome } = await this.connect(addr.url, this.socketFactory, opts.connectTimeoutMs ?? 5e3);
|
|
3081
|
+
try {
|
|
3082
|
+
const state = await sig.request({ t: "join", ...opts.password ? { password: opts.password } : {} }, "lobby");
|
|
3083
|
+
return this.makeLobby(sig, welcome, state, { links });
|
|
3084
|
+
} catch (e) {
|
|
3085
|
+
sig.close();
|
|
3086
|
+
throw e;
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
/** @internal onelobby/direct's host: create the one lobby on the embedded hub, in direct mode. */
|
|
3090
|
+
async hostDirectWith(h, hooks) {
|
|
3091
|
+
const { sig, welcome } = await this.connect("onelobby-direct://host/", hooks.socketFactory);
|
|
3092
|
+
try {
|
|
3093
|
+
const state = await sig.request(
|
|
3094
|
+
{
|
|
3095
|
+
t: "create",
|
|
3096
|
+
name: h.name,
|
|
3097
|
+
maxPlayers: h.maxPlayers,
|
|
3098
|
+
visibility: "unlisted",
|
|
3099
|
+
mode: this.options.mode,
|
|
3100
|
+
tickRate: this.options.tickRate,
|
|
3101
|
+
lateJoin: h.lateJoin ?? true,
|
|
3102
|
+
meta: h.meta ?? {}
|
|
3103
|
+
},
|
|
3104
|
+
"lobby"
|
|
3105
|
+
);
|
|
3106
|
+
return this.makeLobby(sig, welcome, state, { links: hooks.wrapLinks(this.linkFactory) }, h.rateLimit);
|
|
3107
|
+
} catch (e) {
|
|
3108
|
+
sig.close();
|
|
3109
|
+
throw e;
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
async rejoin(resumeToken) {
|
|
3113
|
+
const sig = new SignalingClient(this.options.lobbyServer, this.socketFactory, this.clock);
|
|
3114
|
+
await sig.connect();
|
|
3115
|
+
try {
|
|
3116
|
+
const lobbyP = sig.expect("lobby");
|
|
3117
|
+
const welcomeP = sig.request(this.helloMsg(resumeToken), "welcome");
|
|
3118
|
+
lobbyP.catch(() => {
|
|
3119
|
+
});
|
|
3120
|
+
const welcome = await welcomeP;
|
|
3121
|
+
if (welcome.peerId === void 0) throw new OneLobbyError("invalid_token", "resume token unknown or expired");
|
|
3122
|
+
const state = await lobbyP;
|
|
3123
|
+
return this.makeLobby(sig, welcome, state);
|
|
3124
|
+
} catch (e) {
|
|
3125
|
+
sig.close();
|
|
3126
|
+
throw e;
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
async list(opts = {}) {
|
|
3130
|
+
const { sig } = await this.connect();
|
|
3131
|
+
try {
|
|
3132
|
+
const res = await sig.request(
|
|
3133
|
+
{ t: "list", visibility: "public", meta: opts.meta ?? {}, limit: Math.min(100, opts.limit ?? 50), ...opts.cursor ? { cursor: opts.cursor } : {} },
|
|
3134
|
+
"lobbies"
|
|
3135
|
+
);
|
|
3136
|
+
return res.items ?? [];
|
|
3137
|
+
} finally {
|
|
3138
|
+
sig.close();
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
};
|
|
3142
|
+
|
|
3143
|
+
// src/friendly.ts
|
|
3144
|
+
var TEXT = {
|
|
3145
|
+
// server errors (spec/signaling.md)
|
|
3146
|
+
unknown_code: { message: "We couldn't find a lobby with that code.", hint: "Check the code with the host. Codes stop working once everyone leaves." },
|
|
3147
|
+
bad_code: { message: "That doesn't look like a lobby code.", hint: "Codes are 6 letters and numbers, like 7HQ 4K2." },
|
|
3148
|
+
lobby_full: { message: "That lobby is full.", hint: "Ask the host to make room, or start your own lobby." },
|
|
3149
|
+
version_mismatch: { message: "You and the host are running different versions of the game.", hint: "Reload the page (or update the game) on both sides, then try again." },
|
|
3150
|
+
in_game: { message: "That game has already started.", hint: "Ask the host to let people join mid-game, or wait for the next round." },
|
|
3151
|
+
kicked: { message: "The host removed you from the lobby." },
|
|
3152
|
+
denied: { message: "The host didn't let you in this time.", hint: "Ask the host to approve you, then try again." },
|
|
3153
|
+
not_host: { message: "Only the host can do that." },
|
|
3154
|
+
rate_limited: { message: "Slow down a little: too many tries in a short time.", hint: "Wait a moment, then try again." },
|
|
3155
|
+
max_lobbies: { message: "You already have the most lobbies open that this server allows.", hint: "Close one of your other lobbies, then try again." },
|
|
3156
|
+
server_full: { message: "The game server is full right now.", hint: "Try again in a few minutes." },
|
|
3157
|
+
game_not_allowed: { message: "This game server doesn't host this game.", hint: "The game's server setting may be wrong; let the game's owner know." },
|
|
3158
|
+
bad_request: { message: "The game sent something the server didn't understand.", hint: "Reload the page and try again. If it keeps happening, the game may need an update." },
|
|
3159
|
+
// client errors
|
|
3160
|
+
connect_failed: { message: "Couldn't reach the game server.", hint: "Check your internet connection, then try again." },
|
|
3161
|
+
closed: { message: "Lost the connection to the game server.", hint: "Check your internet connection, then try again." },
|
|
3162
|
+
server_lost: { message: "Lost the connection to the game server.", hint: "Your game may keep going; new players can't join until it's back." },
|
|
3163
|
+
invalid_token: { message: "Your old seat in that lobby is no longer available.", hint: "Join again with the lobby code." },
|
|
3164
|
+
ice_failed: { message: "Couldn't connect to the other players.", hint: "Some networks (school, work, public Wi-Fi) block game connections. Try another network or a phone hotspot." },
|
|
3165
|
+
timeout: { message: "The connection took too long.", hint: "Check your internet connection, then try again." },
|
|
3166
|
+
left: { message: "You left the lobby." },
|
|
3167
|
+
unknown: { message: "Something went wrong.", hint: "Try again. If it keeps happening, reload the page." }
|
|
3168
|
+
};
|
|
3169
|
+
var CODE_RE = /^[a-z][a-z0-9_]*$/;
|
|
3170
|
+
function codeOf(err) {
|
|
3171
|
+
if (typeof err === "string") {
|
|
3172
|
+
const m = /^([a-z][a-z0-9_]*)(?::|$)/.exec(err.trim());
|
|
3173
|
+
return { code: m ? m[1] : null };
|
|
3174
|
+
}
|
|
3175
|
+
if (err && typeof err === "object") {
|
|
3176
|
+
const o = err;
|
|
3177
|
+
const data = o.data && typeof o.data === "object" ? o.data : void 0;
|
|
3178
|
+
if (typeof o.code === "string" && CODE_RE.test(o.code)) return { code: o.code, data };
|
|
3179
|
+
if (typeof o.message === "string") {
|
|
3180
|
+
const m = /^([a-z][a-z0-9_]*):/.exec(o.message);
|
|
3181
|
+
if (m && TEXT[m[1]]) return { code: m[1], data };
|
|
3182
|
+
if (o.name === "NetworkError" || /network|failed to fetch|websocket/i.test(o.message)) return { code: "connect_failed" };
|
|
3183
|
+
if (o.name === "TimeoutError" || /timed? ?out/i.test(o.message)) return { code: "timeout" };
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
return { code: null };
|
|
3187
|
+
}
|
|
3188
|
+
function describeError(err) {
|
|
3189
|
+
const { code, data } = codeOf(err);
|
|
3190
|
+
if (!code) return { code: "unknown", ...TEXT.unknown };
|
|
3191
|
+
let key = code;
|
|
3192
|
+
if (code === "kicked" && data?.reason === "denied") key = "denied";
|
|
3193
|
+
if (code === "rate_limited" && data?.reason === "max_lobbies") key = "max_lobbies";
|
|
3194
|
+
const e = TEXT[key];
|
|
3195
|
+
if (!e) return { code, ...TEXT.unknown };
|
|
3196
|
+
const out = { code, message: e.message };
|
|
3197
|
+
if (e.hint) out.hint = e.hint;
|
|
3198
|
+
if (code === "rate_limited" && typeof data?.retryAfterMs === "number" && key === "rate_limited") {
|
|
3199
|
+
const s = Math.max(1, Math.ceil(data.retryAfterMs / 1e3));
|
|
3200
|
+
out.hint = `Wait ${s} second${s === 1 ? "" : "s"}, then try again.`;
|
|
3201
|
+
}
|
|
3202
|
+
return out;
|
|
3203
|
+
}
|
|
3204
|
+
function currentHref() {
|
|
3205
|
+
try {
|
|
3206
|
+
return typeof location !== "undefined" ? location.href : void 0;
|
|
3207
|
+
} catch {
|
|
3208
|
+
return void 0;
|
|
3209
|
+
}
|
|
3210
|
+
}
|
|
3211
|
+
function shareUrl(code, base, param = "join") {
|
|
3212
|
+
const norm = normalizeCode(code);
|
|
3213
|
+
if (!norm) throw new Error(`"${code}" is not a valid lobby code`);
|
|
3214
|
+
const b = base ?? currentHref();
|
|
3215
|
+
if (!b) throw new Error("shareUrl needs a base URL outside the browser");
|
|
3216
|
+
const u = new URL(b);
|
|
3217
|
+
u.searchParams.delete(param);
|
|
3218
|
+
u.searchParams.set(param, formatCode(norm).replace(" ", "-"));
|
|
3219
|
+
return u.toString();
|
|
3220
|
+
}
|
|
3221
|
+
function codeFromUrl(url, param = "join") {
|
|
3222
|
+
const href = url ?? currentHref();
|
|
3223
|
+
if (!href) return null;
|
|
3224
|
+
let u;
|
|
3225
|
+
try {
|
|
3226
|
+
u = new URL(href, "http://localhost/");
|
|
3227
|
+
} catch {
|
|
3228
|
+
return null;
|
|
3229
|
+
}
|
|
3230
|
+
const v = u.searchParams.get(param) ?? new URLSearchParams(u.hash.replace(/^#/, "")).get(param);
|
|
3231
|
+
return v ? normalizeCode(v) : null;
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3234
|
+
// src/deprecated.ts
|
|
3235
|
+
var Keepalive = OneLobby;
|
|
3236
|
+
var KeepaliveError = OneLobbyError;
|
|
3237
|
+
|
|
3238
|
+
// src/ui/controller.ts
|
|
3239
|
+
var enc3 = new TextEncoder();
|
|
3240
|
+
var dec3 = new TextDecoder();
|
|
3241
|
+
var toBytes = (w) => enc3.encode(JSON.stringify(w));
|
|
3242
|
+
var SharedState = class {
|
|
3243
|
+
constructor(c) {
|
|
3244
|
+
this.c = c;
|
|
3245
|
+
}
|
|
3246
|
+
c;
|
|
3247
|
+
/** @internal */
|
|
3248
|
+
values = /* @__PURE__ */ new Map();
|
|
3249
|
+
get(key) {
|
|
3250
|
+
return this.values.get(key);
|
|
3251
|
+
}
|
|
3252
|
+
/** Set a value for everyone. The host decides the order; everyone gets `shared-change`. */
|
|
3253
|
+
set(key, value) {
|
|
3254
|
+
this.c._sharedOp({ k: "set", key: String(key), v: value === void 0 ? null : value });
|
|
3255
|
+
}
|
|
3256
|
+
/** Add `n` (default 1) to a number, safely even when several players click at once. */
|
|
3257
|
+
add(key, n = 1) {
|
|
3258
|
+
this.c._sharedOp({ k: "add", key: String(key), n: Number(n) || 0 });
|
|
3259
|
+
}
|
|
3260
|
+
/** A plain copy of every value. */
|
|
3261
|
+
all() {
|
|
3262
|
+
return Object.fromEntries(this.values);
|
|
3263
|
+
}
|
|
3264
|
+
keys() {
|
|
3265
|
+
return [...this.values.keys()];
|
|
3266
|
+
}
|
|
3267
|
+
};
|
|
3268
|
+
var LobbyController = class {
|
|
3269
|
+
constructor(opts) {
|
|
3270
|
+
this.opts = opts;
|
|
3271
|
+
}
|
|
3272
|
+
opts;
|
|
3273
|
+
state = "idle";
|
|
3274
|
+
lobby = null;
|
|
3275
|
+
error = null;
|
|
3276
|
+
/** True while a joiner waits for its direct link to the host. */
|
|
3277
|
+
linking = false;
|
|
3278
|
+
name = "";
|
|
3279
|
+
shared = new SharedState(this);
|
|
3280
|
+
onChange = () => {
|
|
3281
|
+
};
|
|
3282
|
+
onEvent = () => {
|
|
3283
|
+
};
|
|
3284
|
+
lastAction = null;
|
|
3285
|
+
unsub = [];
|
|
3286
|
+
gen = 0;
|
|
3287
|
+
/** Host: changes made so far. Joiner: the newest change applied (older queued ones are skipped). */
|
|
3288
|
+
seq = 0;
|
|
3289
|
+
get isHost() {
|
|
3290
|
+
return !!this.lobby && this.lobby.isHost;
|
|
3291
|
+
}
|
|
3292
|
+
get code() {
|
|
3293
|
+
return this.lobby?.code ?? null;
|
|
3294
|
+
}
|
|
3295
|
+
get players() {
|
|
3296
|
+
const l = this.lobby;
|
|
3297
|
+
if (!l) return [];
|
|
3298
|
+
const list = l.peers.map((p) => this.toPlayer(p));
|
|
3299
|
+
if (!list.some((p) => p.isMe)) list.push({ id: l.localPeerId, name: this.name || `Player ${l.localPeerId}`, isHost: l.isHost, isMe: true });
|
|
3300
|
+
return list.sort((a, b) => a.id - b.id);
|
|
3301
|
+
}
|
|
3302
|
+
/** One line of plain language for the status area. */
|
|
3303
|
+
get statusText() {
|
|
3304
|
+
switch (this.state) {
|
|
3305
|
+
case "idle":
|
|
3306
|
+
return "Host a game, or type a code to join one.";
|
|
3307
|
+
case "connecting":
|
|
3308
|
+
return this.lastAction?.kind === "join" ? "Joining\u2026" : this.lastAction?.kind === "resume" ? "Getting your seat back\u2026" : "Creating a game\u2026";
|
|
3309
|
+
case "error":
|
|
3310
|
+
return this.error?.message ?? "Something went wrong.";
|
|
3311
|
+
case "lobby": {
|
|
3312
|
+
if (this.linking) return `Connecting to ${this.hostName}\u2026`;
|
|
3313
|
+
if (this.isHost) {
|
|
3314
|
+
const n = this.players.length;
|
|
3315
|
+
return n <= 1 ? "Waiting for players\u2026 Share the code with your friends." : `${n} players here. Press Start when everyone's in.`;
|
|
3316
|
+
}
|
|
3317
|
+
return `Connected! Waiting for ${this.hostName} to start.`;
|
|
3318
|
+
}
|
|
3319
|
+
case "playing":
|
|
3320
|
+
return this.linking ? `Connecting to ${this.hostName}\u2026` : "Game on!";
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3323
|
+
get hostName() {
|
|
3324
|
+
const l = this.lobby;
|
|
3325
|
+
if (!l) return "the host";
|
|
3326
|
+
return l.peer(l.hostPeerId)?.name || "the host";
|
|
3327
|
+
}
|
|
3328
|
+
toPlayer(p, id) {
|
|
3329
|
+
const l = this.lobby;
|
|
3330
|
+
const pid = p?.peerId ?? id ?? 0;
|
|
3331
|
+
return { id: pid, name: p?.name || pid === l.localPeerId && this.name || `Player ${pid}`, isHost: pid === l.hostPeerId, isMe: pid === l.localPeerId };
|
|
3332
|
+
}
|
|
3333
|
+
// ---- actions -----------------------------------------------------------------------------------------------------
|
|
3334
|
+
host(name) {
|
|
3335
|
+
this.lastAction = { kind: "host" };
|
|
3336
|
+
return this.run(name, (kit) => kit.host({ name: this.opts.lobbyName ?? "game", maxPlayers: typeof this.opts.maxPlayers === "function" ? this.opts.maxPlayers() : this.opts.maxPlayers, visibility: "unlisted", lateJoin: true }));
|
|
3337
|
+
}
|
|
3338
|
+
join(code, name) {
|
|
3339
|
+
const norm = normalizeCode(code);
|
|
3340
|
+
this.lastAction = { kind: "join", code };
|
|
3341
|
+
if (!norm) {
|
|
3342
|
+
this.fail(new OneLobbyError("bad_code"));
|
|
3343
|
+
return Promise.resolve();
|
|
3344
|
+
}
|
|
3345
|
+
return this.run(name, (kit) => kit.join(norm));
|
|
3346
|
+
}
|
|
3347
|
+
/**
|
|
3348
|
+
* Take back this tab's seat after a page reload, if the kit remembers one (host or joiner). Quietly stays on the
|
|
3349
|
+
* start screen when there is nothing to resume or the seat is gone. Resolves true when back in a game.
|
|
3350
|
+
*/
|
|
3351
|
+
async resume(name) {
|
|
3352
|
+
const kit = this.opts.makeKit(name);
|
|
3353
|
+
if (!kit.resume || !kit.rememberedCode) return false;
|
|
3354
|
+
this.lastAction = { kind: "resume" };
|
|
3355
|
+
await this.run(name, () => kit.resume(), true);
|
|
3356
|
+
return !!this.lobby;
|
|
3357
|
+
}
|
|
3358
|
+
/** Repeat whatever failed last (host or join). */
|
|
3359
|
+
retry() {
|
|
3360
|
+
const a = this.lastAction;
|
|
3361
|
+
if (!a || a.kind === "resume") {
|
|
3362
|
+
this.reset();
|
|
3363
|
+
return Promise.resolve();
|
|
3364
|
+
}
|
|
3365
|
+
return a.kind === "host" ? this.host(this.name) : this.join(a.code, this.name);
|
|
3366
|
+
}
|
|
3367
|
+
async start() {
|
|
3368
|
+
if (!this.lobby || !this.isHost || this.state === "playing") return;
|
|
3369
|
+
await this.lobby.start();
|
|
3370
|
+
}
|
|
3371
|
+
async leave() {
|
|
3372
|
+
const l = this.lobby;
|
|
3373
|
+
this.detach();
|
|
3374
|
+
await l?.leave();
|
|
3375
|
+
this.reset();
|
|
3376
|
+
}
|
|
3377
|
+
/** Back to the start screen (after an error, or to cancel). */
|
|
3378
|
+
reset() {
|
|
3379
|
+
this.detach();
|
|
3380
|
+
this.state = "idle";
|
|
3381
|
+
this.error = null;
|
|
3382
|
+
this.onChange();
|
|
3383
|
+
}
|
|
3384
|
+
/** Send any JSON-able value to everyone else. */
|
|
3385
|
+
send(data) {
|
|
3386
|
+
if (!this.lobby) return;
|
|
3387
|
+
this.lobby.broadcast(toBytes({ k: "m", d: data === void 0 ? null : data }));
|
|
3388
|
+
}
|
|
3389
|
+
// ---- internals ---------------------------------------------------------------------------------------------------
|
|
3390
|
+
async run(name, go, quiet = false) {
|
|
3391
|
+
this.detach();
|
|
3392
|
+
const gen = ++this.gen;
|
|
3393
|
+
this.name = (name || "").trim().slice(0, 24);
|
|
3394
|
+
this.state = "connecting";
|
|
3395
|
+
this.error = null;
|
|
3396
|
+
this.onChange();
|
|
3397
|
+
let lobby;
|
|
3398
|
+
try {
|
|
3399
|
+
lobby = await go(this.opts.makeKit(this.name));
|
|
3400
|
+
} catch (e) {
|
|
3401
|
+
if (gen !== this.gen) return;
|
|
3402
|
+
if (quiet) this.reset();
|
|
3403
|
+
else this.fail(e);
|
|
3404
|
+
return;
|
|
3405
|
+
}
|
|
3406
|
+
if (gen !== this.gen) {
|
|
3407
|
+
void lobby?.leave();
|
|
3408
|
+
return;
|
|
3409
|
+
}
|
|
3410
|
+
if (lobby) this.attach(lobby);
|
|
3411
|
+
else this.reset();
|
|
3412
|
+
}
|
|
3413
|
+
attach(l) {
|
|
3414
|
+
this.lobby = l;
|
|
3415
|
+
if (!l.isHost) this.shared.values.clear();
|
|
3416
|
+
this.seq = 0;
|
|
3417
|
+
this.state = l.phase === "in_game" ? "playing" : "lobby";
|
|
3418
|
+
this.linking = !l.isHost;
|
|
3419
|
+
const on = this.unsub;
|
|
3420
|
+
on.push(l.on("ready", () => this.linked()));
|
|
3421
|
+
on.push(l.on("peerJoined", (p) => {
|
|
3422
|
+
this.onEvent("player-joined", { player: this.toPlayer(p) });
|
|
3423
|
+
this.onChange();
|
|
3424
|
+
}));
|
|
3425
|
+
on.push(l.on("peerLeft", (p, reason) => {
|
|
3426
|
+
this.onEvent("player-left", { player: this.toPlayer(p), reason });
|
|
3427
|
+
this.onChange();
|
|
3428
|
+
}));
|
|
3429
|
+
on.push(l.on("peerReady", (p) => {
|
|
3430
|
+
if (l.isHost && p.peerId !== l.localPeerId) l.send(p.peerId, toBytes({ k: "SS", all: this.shared.all(), q: this.seq }));
|
|
3431
|
+
}));
|
|
3432
|
+
on.push(l.on("hostChanged", () => this.onChange()));
|
|
3433
|
+
on.push(l.on("phase", (ph) => {
|
|
3434
|
+
if (ph === "in_game" && this.state !== "playing") {
|
|
3435
|
+
this.state = "playing";
|
|
3436
|
+
this.onChange();
|
|
3437
|
+
this.onEvent("start", {});
|
|
3438
|
+
}
|
|
3439
|
+
}));
|
|
3440
|
+
on.push(l.on("message", (from, bytes) => this.onRaw(from, bytes)));
|
|
3441
|
+
on.push(l.on("sessionEnded", (reason) => {
|
|
3442
|
+
if (this.lobby !== l) return;
|
|
3443
|
+
if (reason === "left") return;
|
|
3444
|
+
this.fail(new OneLobbyError(reason));
|
|
3445
|
+
}));
|
|
3446
|
+
l.ready.then(() => {
|
|
3447
|
+
if (this.lobby === l) this.linked();
|
|
3448
|
+
}, () => {
|
|
3449
|
+
});
|
|
3450
|
+
this.onChange();
|
|
3451
|
+
if (this.state === "playing" && !this.linking) this.onEvent("start", {});
|
|
3452
|
+
}
|
|
3453
|
+
linked() {
|
|
3454
|
+
if (!this.linking) return;
|
|
3455
|
+
this.linking = false;
|
|
3456
|
+
this.onChange();
|
|
3457
|
+
if (this.state === "playing") this.onEvent("start", {});
|
|
3458
|
+
}
|
|
3459
|
+
detach() {
|
|
3460
|
+
this.gen++;
|
|
3461
|
+
for (const u of this.unsub.splice(0)) u();
|
|
3462
|
+
this.lobby = null;
|
|
3463
|
+
this.linking = false;
|
|
3464
|
+
}
|
|
3465
|
+
fail(e) {
|
|
3466
|
+
const l = this.lobby;
|
|
3467
|
+
this.detach();
|
|
3468
|
+
if (l) void l.leave();
|
|
3469
|
+
this.state = "error";
|
|
3470
|
+
this.error = describeError(e);
|
|
3471
|
+
this.onChange();
|
|
3472
|
+
this.onEvent("error", this.error);
|
|
3473
|
+
}
|
|
3474
|
+
playerById(id) {
|
|
3475
|
+
const l = this.lobby;
|
|
3476
|
+
if (!l || !id) return null;
|
|
3477
|
+
return this.toPlayer(l.peer(id), id);
|
|
3478
|
+
}
|
|
3479
|
+
onRaw(from, bytes) {
|
|
3480
|
+
let w;
|
|
3481
|
+
try {
|
|
3482
|
+
w = JSON.parse(dec3.decode(bytes));
|
|
3483
|
+
} catch {
|
|
3484
|
+
return;
|
|
3485
|
+
}
|
|
3486
|
+
if (!w || typeof w !== "object") return;
|
|
3487
|
+
const l = this.lobby;
|
|
3488
|
+
switch (w.k) {
|
|
3489
|
+
case "m":
|
|
3490
|
+
this.onEvent("message", { from: this.playerById(from), data: w.d });
|
|
3491
|
+
break;
|
|
3492
|
+
case "set":
|
|
3493
|
+
case "add":
|
|
3494
|
+
if (l.isHost) this.applyHost(w, from);
|
|
3495
|
+
break;
|
|
3496
|
+
case "S":
|
|
3497
|
+
if (!l.isHost && from === l.hostPeerId && !(w.q <= this.seq)) {
|
|
3498
|
+
this.seq = w.q;
|
|
3499
|
+
this.applyValue(w.key, w.v, w.by);
|
|
3500
|
+
}
|
|
3501
|
+
break;
|
|
3502
|
+
case "SS":
|
|
3503
|
+
if (!l.isHost && from === l.hostPeerId && w.all && typeof w.all === "object" && !(w.q < this.seq)) {
|
|
3504
|
+
this.seq = w.q;
|
|
3505
|
+
for (const k of [...this.shared.values.keys()]) if (!(k in w.all)) this.applyValue(k, null, 0);
|
|
3506
|
+
for (const [k, v] of Object.entries(w.all)) this.applyValue(k, v, 0);
|
|
3507
|
+
}
|
|
3508
|
+
break;
|
|
3509
|
+
}
|
|
3510
|
+
}
|
|
3511
|
+
/** @internal from SharedState */
|
|
3512
|
+
_sharedOp(op) {
|
|
3513
|
+
const l = this.lobby;
|
|
3514
|
+
if (!l) {
|
|
3515
|
+
const v = op.k === "set" ? op.v : (Number(this.shared.values.get(op.key)) || 0) + op.n;
|
|
3516
|
+
this.applyValue(op.key, v, 0);
|
|
3517
|
+
return;
|
|
3518
|
+
}
|
|
3519
|
+
if (l.isHost) this.applyHost(op, l.localPeerId);
|
|
3520
|
+
else l.send(l.hostPeerId, toBytes(op));
|
|
3521
|
+
}
|
|
3522
|
+
applyHost(op, by) {
|
|
3523
|
+
const l = this.lobby;
|
|
3524
|
+
if (typeof op.key !== "string") return;
|
|
3525
|
+
const v = op.k === "set" ? op.v : (Number(this.shared.values.get(op.key)) || 0) + (Number(op.n) || 0);
|
|
3526
|
+
this.applyValue(op.key, v, by);
|
|
3527
|
+
l.broadcast(toBytes({ k: "S", key: op.key, v, by, q: ++this.seq }));
|
|
3528
|
+
}
|
|
3529
|
+
applyValue(key, v, by) {
|
|
3530
|
+
if (v === null) this.shared.values.delete(key);
|
|
3531
|
+
else this.shared.values.set(key, v);
|
|
3532
|
+
this.onEvent("shared-change", { key, value: v, from: this.playerById(by) });
|
|
3533
|
+
}
|
|
3534
|
+
};
|
|
3535
|
+
|
|
3536
|
+
// src/ui/styles.ts
|
|
3537
|
+
var LOBBY_CSS = `
|
|
3538
|
+
:host {
|
|
3539
|
+
--onelobby-bg: #ffffff;
|
|
3540
|
+
--onelobby-fg: #1b1f24;
|
|
3541
|
+
--onelobby-muted: #57606a;
|
|
3542
|
+
--onelobby-border: #d0d7de;
|
|
3543
|
+
--onelobby-accent: #0b5cad;
|
|
3544
|
+
--onelobby-accent-fg: #ffffff;
|
|
3545
|
+
--onelobby-danger: #b42318;
|
|
3546
|
+
--onelobby-field: #f6f8fa;
|
|
3547
|
+
--onelobby-radius: 12px;
|
|
3548
|
+
--onelobby-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
3549
|
+
display: block;
|
|
3550
|
+
max-width: 28rem;
|
|
3551
|
+
font-family: var(--onelobby-font);
|
|
3552
|
+
color: var(--onelobby-fg);
|
|
3553
|
+
line-height: 1.4;
|
|
3554
|
+
}
|
|
3555
|
+
:host([theme="dark"]) {
|
|
3556
|
+
--onelobby-bg: #161b22; --onelobby-fg: #e6edf3; --onelobby-muted: #9da7b3; --onelobby-border: #30363d;
|
|
3557
|
+
--onelobby-accent: #4c9be8; --onelobby-accent-fg: #0d1117; --onelobby-danger: #ff7b72; --onelobby-field: #0d1117;
|
|
3558
|
+
}
|
|
3559
|
+
@media (prefers-color-scheme: dark) {
|
|
3560
|
+
:host([theme="auto"]), :host(:not([theme])) {
|
|
3561
|
+
--onelobby-bg: #161b22; --onelobby-fg: #e6edf3; --onelobby-muted: #9da7b3; --onelobby-border: #30363d;
|
|
3562
|
+
--onelobby-accent: #4c9be8; --onelobby-accent-fg: #0d1117; --onelobby-danger: #ff7b72; --onelobby-field: #0d1117;
|
|
3563
|
+
}
|
|
3564
|
+
}
|
|
3565
|
+
[hidden] { display: none !important; }
|
|
3566
|
+
.card {
|
|
3567
|
+
background: var(--onelobby-bg);
|
|
3568
|
+
border: 1px solid var(--onelobby-border);
|
|
3569
|
+
border-radius: var(--onelobby-radius);
|
|
3570
|
+
padding: 1rem;
|
|
3571
|
+
display: grid;
|
|
3572
|
+
gap: 0.75rem;
|
|
3573
|
+
}
|
|
3574
|
+
.status { font-size: 1rem; margin: 0; }
|
|
3575
|
+
.status.error { color: var(--onelobby-danger); font-weight: 600; }
|
|
3576
|
+
.hint { margin: 0; color: var(--onelobby-muted); }
|
|
3577
|
+
label { display: grid; gap: 0.25rem; font-size: 0.9rem; color: var(--onelobby-muted); }
|
|
3578
|
+
input {
|
|
3579
|
+
font: inherit; font-size: 1rem; color: var(--onelobby-fg); background: var(--onelobby-field);
|
|
3580
|
+
border: 1px solid var(--onelobby-border); border-radius: 8px; padding: 0.6rem 0.7rem; min-height: 44px; box-sizing: border-box; width: 100%;
|
|
3581
|
+
}
|
|
3582
|
+
#code { text-transform: uppercase; letter-spacing: 0.15em; font-family: ui-monospace, Consolas, monospace; }
|
|
3583
|
+
button {
|
|
3584
|
+
font: inherit; font-size: 1rem; min-height: 44px; padding: 0.5rem 1rem; border-radius: 8px; cursor: pointer;
|
|
3585
|
+
border: 1px solid var(--onelobby-border); background: var(--onelobby-field); color: var(--onelobby-fg);
|
|
3586
|
+
}
|
|
3587
|
+
button.primary { background: var(--onelobby-accent); color: var(--onelobby-accent-fg); border-color: var(--onelobby-accent); font-weight: 600; }
|
|
3588
|
+
button:disabled { opacity: 0.55; cursor: not-allowed; }
|
|
3589
|
+
button:focus-visible, input:focus-visible { outline: 3px solid var(--onelobby-accent); outline-offset: 2px; }
|
|
3590
|
+
.row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: end; }
|
|
3591
|
+
.row > label { flex: 1 1 10rem; }
|
|
3592
|
+
.or { text-align: center; color: var(--onelobby-muted); font-size: 0.9rem; }
|
|
3593
|
+
.codebox { display: grid; gap: 0.5rem; justify-items: start; }
|
|
3594
|
+
.code {
|
|
3595
|
+
font-family: ui-monospace, Consolas, monospace; font-size: clamp(2rem, 9vw, 2.75rem); font-weight: 700;
|
|
3596
|
+
letter-spacing: 0.12em; user-select: all;
|
|
3597
|
+
}
|
|
3598
|
+
h3 { margin: 0; font-size: 0.9rem; color: var(--onelobby-muted); font-weight: 600; }
|
|
3599
|
+
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.25rem; }
|
|
3600
|
+
li { display: flex; gap: 0.5rem; align-items: center; }
|
|
3601
|
+
.tag { font-size: 0.75rem; border: 1px solid var(--onelobby-border); border-radius: 999px; padding: 0 0.5rem; color: var(--onelobby-muted); }
|
|
3602
|
+
.bar { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; justify-content: space-between; }
|
|
3603
|
+
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
|
3604
|
+
`;
|
|
3605
|
+
|
|
3606
|
+
// src/ui/server-url.ts
|
|
3607
|
+
function lobbyServerUrl(raw) {
|
|
3608
|
+
const s = (raw ?? "").trim();
|
|
3609
|
+
if (!s || /\s/.test(s)) return null;
|
|
3610
|
+
if (/your[-_ ]?(lobby[-_ ]?)?server|replace[-_ ]?me|<|>/i.test(s)) return null;
|
|
3611
|
+
let url = s;
|
|
3612
|
+
if (/^https?:\/\//i.test(url)) url = url.replace(/^http/i, "ws");
|
|
3613
|
+
else if (!/^wss?:\/\//i.test(url)) url = "wss://" + url;
|
|
3614
|
+
let u;
|
|
3615
|
+
try {
|
|
3616
|
+
u = new URL(url);
|
|
3617
|
+
} catch {
|
|
3618
|
+
return null;
|
|
3619
|
+
}
|
|
3620
|
+
const host = u.hostname.toLowerCase();
|
|
3621
|
+
if (!host) return null;
|
|
3622
|
+
if (/(^|\.)example\.(com|net|org)$/.test(host) || /\.example$/.test(host) || host === "example") return null;
|
|
3623
|
+
if (!host.includes(".") && !host.includes(":") && host !== "localhost") return null;
|
|
3624
|
+
return url;
|
|
3625
|
+
}
|
|
3626
|
+
|
|
3627
|
+
// src/ui/element.ts
|
|
3628
|
+
var LOBBY_TAG = "one-lobby";
|
|
3629
|
+
var NAME_KEY = "onelobby-name";
|
|
3630
|
+
var TEMPLATE = `
|
|
3631
|
+
<div class="card" part="card">
|
|
3632
|
+
<p class="status" id="status" role="status" aria-live="polite"></p>
|
|
3633
|
+
<p class="hint" id="hint" hidden></p>
|
|
3634
|
+
|
|
3635
|
+
<div id="v-idle" class="view">
|
|
3636
|
+
<div class="row">
|
|
3637
|
+
<label id="nameLabel">Your name
|
|
3638
|
+
<input id="name" maxlength="24" autocomplete="nickname" placeholder="e.g. Sam">
|
|
3639
|
+
</label>
|
|
3640
|
+
</div>
|
|
3641
|
+
<div class="row" style="margin-top:.75rem">
|
|
3642
|
+
<button id="host" class="primary" type="button">Host a game</button>
|
|
3643
|
+
</div>
|
|
3644
|
+
<p class="or">or join a friend</p>
|
|
3645
|
+
<form id="joinForm" class="row">
|
|
3646
|
+
<label>Game code
|
|
3647
|
+
<input id="code" maxlength="9" autocomplete="off" autocapitalize="characters" spellcheck="false" placeholder="ABC 123">
|
|
3648
|
+
</label>
|
|
3649
|
+
<button id="join" type="submit">Join</button>
|
|
3650
|
+
</form>
|
|
3651
|
+
</div>
|
|
3652
|
+
|
|
3653
|
+
<div id="v-busy" class="view" hidden>
|
|
3654
|
+
<button id="cancel" type="button">Cancel</button>
|
|
3655
|
+
</div>
|
|
3656
|
+
|
|
3657
|
+
<div id="v-lobby" class="view" hidden>
|
|
3658
|
+
<div class="codebox">
|
|
3659
|
+
<h3>Game code</h3>
|
|
3660
|
+
<span class="code" id="codeText"></span>
|
|
3661
|
+
<div class="row">
|
|
3662
|
+
<button id="copyCode" type="button">Copy code</button>
|
|
3663
|
+
<button id="copyLink" type="button">Copy invite link</button>
|
|
3664
|
+
</div>
|
|
3665
|
+
<span class="sr" id="copied" aria-live="polite"></span>
|
|
3666
|
+
</div>
|
|
3667
|
+
<h3 id="playersTitle" style="margin-top:.75rem">Players</h3>
|
|
3668
|
+
<ul id="players" aria-labelledby="playersTitle"></ul>
|
|
3669
|
+
<div class="bar" style="margin-top:.75rem">
|
|
3670
|
+
<button id="start" class="primary" type="button">Start</button>
|
|
3671
|
+
<button id="leave" type="button">Leave</button>
|
|
3672
|
+
</div>
|
|
3673
|
+
</div>
|
|
3674
|
+
|
|
3675
|
+
<div id="v-error" class="view" hidden>
|
|
3676
|
+
<div class="row">
|
|
3677
|
+
<button id="retry" class="primary" type="button">Try again</button>
|
|
3678
|
+
<button id="back" type="button">Back</button>
|
|
3679
|
+
</div>
|
|
3680
|
+
</div>
|
|
3681
|
+
</div>
|
|
3682
|
+
`;
|
|
3683
|
+
function readStored() {
|
|
3684
|
+
try {
|
|
3685
|
+
return localStorage.getItem(NAME_KEY) ?? "";
|
|
3686
|
+
} catch {
|
|
3687
|
+
return "";
|
|
3688
|
+
}
|
|
3689
|
+
}
|
|
3690
|
+
function store(name) {
|
|
3691
|
+
try {
|
|
3692
|
+
localStorage.setItem(NAME_KEY, name);
|
|
3693
|
+
} catch {
|
|
3694
|
+
}
|
|
3695
|
+
}
|
|
3696
|
+
async function copyText(text) {
|
|
3697
|
+
try {
|
|
3698
|
+
await navigator.clipboard.writeText(text);
|
|
3699
|
+
return true;
|
|
3700
|
+
} catch {
|
|
3701
|
+
const ta = document.createElement("textarea");
|
|
3702
|
+
ta.value = text;
|
|
3703
|
+
ta.style.position = "fixed";
|
|
3704
|
+
ta.style.opacity = "0";
|
|
3705
|
+
document.body.appendChild(ta);
|
|
3706
|
+
ta.select();
|
|
3707
|
+
let ok = false;
|
|
3708
|
+
try {
|
|
3709
|
+
ok = document.execCommand("copy");
|
|
3710
|
+
} catch {
|
|
3711
|
+
ok = false;
|
|
3712
|
+
}
|
|
3713
|
+
ta.remove();
|
|
3714
|
+
return ok;
|
|
3715
|
+
}
|
|
3716
|
+
}
|
|
3717
|
+
function makeClass() {
|
|
3718
|
+
class OneLobbyBox extends HTMLElement {
|
|
3719
|
+
static observedAttributes = ["name", "theme"];
|
|
3720
|
+
/** @internal exposed for debugging and tests */
|
|
3721
|
+
_controller;
|
|
3722
|
+
root;
|
|
3723
|
+
$ = (id) => this.root.getElementById(id);
|
|
3724
|
+
autoJoined = false;
|
|
3725
|
+
copiedTimer;
|
|
3726
|
+
constructor() {
|
|
3727
|
+
super();
|
|
3728
|
+
this.root = this.attachShadow({ mode: "open" });
|
|
3729
|
+
this.root.innerHTML = `<style>${LOBBY_CSS}</style>${TEMPLATE}`;
|
|
3730
|
+
this._controller = new LobbyController({ makeKit: (name) => this.makeKit(name), maxPlayers: () => this.maxPlayers });
|
|
3731
|
+
this._controller.onChange = () => this.render();
|
|
3732
|
+
this._controller.onEvent = (type, detail) => this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
|
|
3733
|
+
this.wire();
|
|
3734
|
+
}
|
|
3735
|
+
// ---- public API ----------------------------------------------------------------------------------------------
|
|
3736
|
+
get isHost() {
|
|
3737
|
+
return this._controller.isHost;
|
|
3738
|
+
}
|
|
3739
|
+
get players() {
|
|
3740
|
+
return this._controller.players;
|
|
3741
|
+
}
|
|
3742
|
+
get code() {
|
|
3743
|
+
return this._controller.code;
|
|
3744
|
+
}
|
|
3745
|
+
get state() {
|
|
3746
|
+
return this._controller.state;
|
|
3747
|
+
}
|
|
3748
|
+
get shared() {
|
|
3749
|
+
return this._controller.shared;
|
|
3750
|
+
}
|
|
3751
|
+
send(data) {
|
|
3752
|
+
this._controller.send(data);
|
|
3753
|
+
}
|
|
3754
|
+
host() {
|
|
3755
|
+
return this._controller.host(this.playerName());
|
|
3756
|
+
}
|
|
3757
|
+
join(code) {
|
|
3758
|
+
return this._controller.join(code, this.playerName());
|
|
3759
|
+
}
|
|
3760
|
+
start() {
|
|
3761
|
+
return this._controller.start();
|
|
3762
|
+
}
|
|
3763
|
+
leave() {
|
|
3764
|
+
return this._controller.leave();
|
|
3765
|
+
}
|
|
3766
|
+
// ---- lifecycle -----------------------------------------------------------------------------------------------
|
|
3767
|
+
connectedCallback() {
|
|
3768
|
+
const name = this.getAttribute("name");
|
|
3769
|
+
const input = this.$("name");
|
|
3770
|
+
if (!input.value) input.value = name ?? readStored();
|
|
3771
|
+
this.$("nameLabel").hidden = !!name;
|
|
3772
|
+
this.render();
|
|
3773
|
+
if (!this.autoJoined) {
|
|
3774
|
+
this.autoJoined = true;
|
|
3775
|
+
const code = codeFromUrl();
|
|
3776
|
+
if (code && this._controller.state === "idle") {
|
|
3777
|
+
this.$("code").value = formatCode(code);
|
|
3778
|
+
if (input.value.trim()) void this.join(code);
|
|
3779
|
+
else {
|
|
3780
|
+
this.$("status").textContent = "You're invited! Type your name, then press Join.";
|
|
3781
|
+
input.focus();
|
|
3782
|
+
}
|
|
3783
|
+
} else if (!code && this._controller.state === "idle" && lobbyServerUrl(this.getAttribute("server"))) {
|
|
3784
|
+
void this._controller.resume(input.value.trim());
|
|
3785
|
+
}
|
|
3786
|
+
}
|
|
3787
|
+
}
|
|
3788
|
+
disconnectedCallback() {
|
|
3789
|
+
if (this._controller.lobby) void this._controller.leave();
|
|
3790
|
+
}
|
|
3791
|
+
attributeChangedCallback(attr) {
|
|
3792
|
+
if (attr === "name" && this.isConnected) {
|
|
3793
|
+
const v = this.getAttribute("name");
|
|
3794
|
+
if (v) this.$("name").value = v;
|
|
3795
|
+
this.$("nameLabel").hidden = !!v;
|
|
3796
|
+
}
|
|
3797
|
+
}
|
|
3798
|
+
// ---- internals -----------------------------------------------------------------------------------------------
|
|
3799
|
+
get maxPlayers() {
|
|
3800
|
+
const n = parseInt(this.getAttribute("max-players") ?? "", 10);
|
|
3801
|
+
return Number.isFinite(n) && n >= 2 ? Math.min(n, 250) : 8;
|
|
3802
|
+
}
|
|
3803
|
+
playerName() {
|
|
3804
|
+
const v = this.$("name").value.trim() || this.getAttribute("name") || "";
|
|
3805
|
+
if (!this.getAttribute("name") && v) store(v);
|
|
3806
|
+
return v;
|
|
3807
|
+
}
|
|
3808
|
+
makeKit(name) {
|
|
3809
|
+
const server = lobbyServerUrl(this.getAttribute("server"));
|
|
3810
|
+
if (!server) throw new OneLobbyError("no_server", "the <one-lobby> tag has no usable server address");
|
|
3811
|
+
return new OneLobby({
|
|
3812
|
+
gameId: this.getAttribute("game") || "onelobby-drop-in",
|
|
3813
|
+
netVersion: this.getAttribute("version") || "1",
|
|
3814
|
+
lobbyServer: server,
|
|
3815
|
+
mode: "raw",
|
|
3816
|
+
displayName: name,
|
|
3817
|
+
// joiners give up (with a friendly "couldn't connect" and a retry button) after this long
|
|
3818
|
+
readyTimeoutMs: 15e3,
|
|
3819
|
+
advanced: { createMic: null, createSink: null, voiceCodec: null }
|
|
3820
|
+
});
|
|
3821
|
+
}
|
|
3822
|
+
wire() {
|
|
3823
|
+
const on = (id, ev, fn) => this.$(id).addEventListener(ev, fn);
|
|
3824
|
+
on("host", "click", () => void this.host());
|
|
3825
|
+
on("joinForm", "submit", (e) => {
|
|
3826
|
+
e.preventDefault();
|
|
3827
|
+
void this.join(this.$("code").value);
|
|
3828
|
+
});
|
|
3829
|
+
on("cancel", "click", () => this._controller.reset());
|
|
3830
|
+
on("start", "click", () => void this.start());
|
|
3831
|
+
on("leave", "click", () => void this.leave());
|
|
3832
|
+
on("retry", "click", () => void this._controller.retry());
|
|
3833
|
+
on("back", "click", () => this._controller.reset());
|
|
3834
|
+
on("copyCode", "click", () => void this.copy(this.code ?? "", "copyCode", "Copied!"));
|
|
3835
|
+
on("copyLink", "click", () => void this.copy(this.code ? shareUrl(this.code) : "", "copyLink", "Link copied!"));
|
|
3836
|
+
}
|
|
3837
|
+
async copy(text, id, done) {
|
|
3838
|
+
if (!text) return;
|
|
3839
|
+
const ok = await copyText(text);
|
|
3840
|
+
const btn = this.$(id);
|
|
3841
|
+
const label = id === "copyCode" ? "Copy code" : "Copy invite link";
|
|
3842
|
+
btn.textContent = ok ? done : "Couldn't copy, select it by hand";
|
|
3843
|
+
this.$("copied").textContent = ok ? done : "";
|
|
3844
|
+
clearTimeout(this.copiedTimer);
|
|
3845
|
+
this.copiedTimer = setTimeout(() => {
|
|
3846
|
+
btn.textContent = label;
|
|
3847
|
+
this.$("copied").textContent = "";
|
|
3848
|
+
}, 2e3);
|
|
3849
|
+
}
|
|
3850
|
+
render() {
|
|
3851
|
+
const c = this._controller;
|
|
3852
|
+
const s = c.state;
|
|
3853
|
+
const status = this.$("status");
|
|
3854
|
+
status.textContent = s === "error" && c.error?.code === "no_server" ? "This page is missing its lobby server address." : c.statusText;
|
|
3855
|
+
status.classList.toggle("error", s === "error");
|
|
3856
|
+
const hint = this.$("hint");
|
|
3857
|
+
const hintText = s === "error" ? c.error?.code === "no_server" ? 'Put your lobby server address in server="\u2026" on the <one-lobby> tag (it looks like https://your-name.onrender.com). The guide shows how to get one.' : c.error?.hint ?? "" : "";
|
|
3858
|
+
hint.textContent = hintText;
|
|
3859
|
+
hint.hidden = !hintText;
|
|
3860
|
+
this.$("v-idle").hidden = s !== "idle";
|
|
3861
|
+
this.$("v-busy").hidden = s !== "connecting";
|
|
3862
|
+
this.$("v-lobby").hidden = s !== "lobby" && s !== "playing";
|
|
3863
|
+
this.$("v-error").hidden = s !== "error";
|
|
3864
|
+
this.setAttribute("state", s);
|
|
3865
|
+
if (s === "lobby" || s === "playing") {
|
|
3866
|
+
this.$("codeText").textContent = formatCode(c.code ?? "");
|
|
3867
|
+
const ul = this.$("players");
|
|
3868
|
+
ul.replaceChildren(
|
|
3869
|
+
...c.players.map((p) => {
|
|
3870
|
+
const li = document.createElement("li");
|
|
3871
|
+
const nm = document.createElement("span");
|
|
3872
|
+
nm.textContent = p.name;
|
|
3873
|
+
li.append(nm);
|
|
3874
|
+
if (p.isHost) li.append(Object.assign(document.createElement("span"), { className: "tag", textContent: "host" }));
|
|
3875
|
+
if (p.isMe) li.append(Object.assign(document.createElement("span"), { className: "tag", textContent: "you" }));
|
|
3876
|
+
return li;
|
|
3877
|
+
})
|
|
3878
|
+
);
|
|
3879
|
+
const start = this.$("start");
|
|
3880
|
+
start.hidden = !c.isHost || s === "playing";
|
|
3881
|
+
start.disabled = c.players.length < 2;
|
|
3882
|
+
start.title = start.disabled ? "Wait for at least one more player" : "";
|
|
3883
|
+
}
|
|
3884
|
+
}
|
|
3885
|
+
}
|
|
3886
|
+
return OneLobbyBox;
|
|
3887
|
+
}
|
|
3888
|
+
function defineLobbyElement(tag = LOBBY_TAG) {
|
|
3889
|
+
if (typeof customElements === "undefined" || customElements.get(tag)) return;
|
|
3890
|
+
customElements.define(tag, makeClass());
|
|
3891
|
+
}
|
|
3892
|
+
|
|
3893
|
+
// src/ui/index.ts
|
|
3894
|
+
defineLobbyElement();
|
|
3895
|
+
return __toCommonJS(global_exports);
|
|
3896
|
+
})();
|
|
3897
|
+
//# sourceMappingURL=onelobby.global.js.map
|