@genex-ai/multiplayer 0.16.1 → 0.17.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/PROTOCOL.md +624 -0
- package/README.md +191 -27
- package/dist/bridge.d.ts +89 -0
- package/dist/bridge.global.js +14 -0
- package/dist/bridge.js +558 -0
- package/dist/chunk-C52XFIB6.js +312 -0
- package/dist/chunk-CTDU4IJ5.js +2191 -0
- package/dist/index.d.ts +110 -18
- package/dist/index.js +7 -1778
- package/dist/protocol.d.ts +419 -0
- package/dist/protocol.js +58 -0
- package/dist/schema.d.ts +122 -68
- package/dist/schema.js +95 -16
- package/package.json +27 -10
|
@@ -0,0 +1,2191 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ClientMessage,
|
|
3
|
+
ClientReconnection,
|
|
4
|
+
ClockSync,
|
|
5
|
+
CloseCode,
|
|
6
|
+
Interpolation,
|
|
7
|
+
JoinError,
|
|
8
|
+
Limits,
|
|
9
|
+
POSE_KEYS,
|
|
10
|
+
PROTOCOL_VERSION,
|
|
11
|
+
RESERVED_SHARED_PREFIX,
|
|
12
|
+
RoomType,
|
|
13
|
+
SEND_TIME_KEY,
|
|
14
|
+
ServerMessage,
|
|
15
|
+
isReservedEventType
|
|
16
|
+
} from "./chunk-C52XFIB6.js";
|
|
17
|
+
|
|
18
|
+
// src/index.ts
|
|
19
|
+
import { Client, getStateCallbacks } from "@colyseus/sdk";
|
|
20
|
+
|
|
21
|
+
// src/ice-request.ts
|
|
22
|
+
function createIceRequester(send, timeoutMs) {
|
|
23
|
+
let inFlight = null;
|
|
24
|
+
let settle = null;
|
|
25
|
+
return {
|
|
26
|
+
request() {
|
|
27
|
+
if (inFlight) return inFlight;
|
|
28
|
+
inFlight = new Promise((resolve) => {
|
|
29
|
+
let timer;
|
|
30
|
+
const done = (cfg) => {
|
|
31
|
+
if (!settle) return;
|
|
32
|
+
settle = null;
|
|
33
|
+
inFlight = null;
|
|
34
|
+
if (timer) clearTimeout(timer);
|
|
35
|
+
resolve(cfg);
|
|
36
|
+
};
|
|
37
|
+
settle = done;
|
|
38
|
+
timer = setTimeout(() => done(null), timeoutMs);
|
|
39
|
+
send();
|
|
40
|
+
});
|
|
41
|
+
return inFlight;
|
|
42
|
+
},
|
|
43
|
+
deliver(cfg) {
|
|
44
|
+
settle?.(cfg);
|
|
45
|
+
},
|
|
46
|
+
get pending() {
|
|
47
|
+
return inFlight !== null;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/pose.ts
|
|
53
|
+
var NUMERIC_POSE_KEYS = ["x", "y", "z", "yaw", "vx", "vy", "vz"];
|
|
54
|
+
var AXES = [
|
|
55
|
+
["x", "vx"],
|
|
56
|
+
["y", "vy"],
|
|
57
|
+
["z", "vz"]
|
|
58
|
+
];
|
|
59
|
+
var TWO_PI = Math.PI * 2;
|
|
60
|
+
var isPoseKey = (k) => POSE_KEYS.includes(k);
|
|
61
|
+
function wrapAngle(a) {
|
|
62
|
+
if (!Number.isFinite(a)) return 0;
|
|
63
|
+
let r = a % TWO_PI;
|
|
64
|
+
if (r <= -Math.PI) r += TWO_PI;
|
|
65
|
+
else if (r > Math.PI) r -= TWO_PI;
|
|
66
|
+
return r;
|
|
67
|
+
}
|
|
68
|
+
var validPoseNumber = (v) => typeof v === "number" && Number.isFinite(v) && Math.abs(v) <= Limits.MAX_POSE_ABS;
|
|
69
|
+
function splitPose(state) {
|
|
70
|
+
const pose = {};
|
|
71
|
+
const data = {};
|
|
72
|
+
const invalid = [];
|
|
73
|
+
for (const k of Object.keys(state)) {
|
|
74
|
+
const v = state[k];
|
|
75
|
+
if (!isPoseKey(k)) {
|
|
76
|
+
data[k] = v;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (v === void 0) continue;
|
|
80
|
+
if (k === "anim") {
|
|
81
|
+
if (typeof v === "string" && v.length <= Limits.MAX_ANIM_CHARS) pose.anim = v;
|
|
82
|
+
else invalid.push(k);
|
|
83
|
+
} else if (validPoseNumber(v)) {
|
|
84
|
+
pose[k] = v;
|
|
85
|
+
} else {
|
|
86
|
+
invalid.push(k);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { pose, data, invalid };
|
|
90
|
+
}
|
|
91
|
+
function hasPose(p) {
|
|
92
|
+
for (const k in p) if (p[k] !== void 0) return true;
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
function deriveVelocity(pose, t, prev) {
|
|
96
|
+
const out = { ...pose };
|
|
97
|
+
const memory = { t };
|
|
98
|
+
const dt = prev ? t - prev.t : 0;
|
|
99
|
+
const usable = prev !== void 0 && dt > Interpolation.MIN_GAP_MS && dt < Interpolation.MAX_GAP_MS;
|
|
100
|
+
for (const [axis, vel] of AXES) {
|
|
101
|
+
const p = pose[axis];
|
|
102
|
+
if (p === void 0) continue;
|
|
103
|
+
memory[axis] = p;
|
|
104
|
+
if (pose[vel] !== void 0) continue;
|
|
105
|
+
const before = prev?.[axis];
|
|
106
|
+
const v = usable && before !== void 0 ? (p - before) / dt * 1e3 : 0;
|
|
107
|
+
out[vel] = validPoseNumber(v) ? v : 0;
|
|
108
|
+
}
|
|
109
|
+
return { pose: out, memory };
|
|
110
|
+
}
|
|
111
|
+
function readPose(p) {
|
|
112
|
+
const n = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
113
|
+
return {
|
|
114
|
+
x: n(p.x),
|
|
115
|
+
y: n(p.y),
|
|
116
|
+
z: n(p.z),
|
|
117
|
+
yaw: n(p.yaw),
|
|
118
|
+
vx: n(p.vx),
|
|
119
|
+
vy: n(p.vy),
|
|
120
|
+
vz: n(p.vz),
|
|
121
|
+
anim: typeof p.anim === "string" ? p.anim : ""
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
var num = (o, k) => {
|
|
125
|
+
const v = o[k];
|
|
126
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
127
|
+
};
|
|
128
|
+
function samplePose(buf, r) {
|
|
129
|
+
const n = buf.length;
|
|
130
|
+
if (n === 0) return void 0;
|
|
131
|
+
let j = 0;
|
|
132
|
+
while (j < n && buf[j].t <= r) j++;
|
|
133
|
+
if (j === 0) return buf[0].state;
|
|
134
|
+
if (j >= n) {
|
|
135
|
+
const b2 = buf[n - 1];
|
|
136
|
+
const out2 = { ...b2.state };
|
|
137
|
+
const ahead = Math.min(Math.max(0, r - b2.t), Interpolation.MAX_EXTRAP_MS) / 1e3;
|
|
138
|
+
for (const [axis, vel] of AXES) out2[axis] = num(b2.state, axis) + num(b2.state, vel) * ahead;
|
|
139
|
+
if (n >= 2) {
|
|
140
|
+
const a2 = buf[n - 2];
|
|
141
|
+
const span2 = b2.t - a2.t;
|
|
142
|
+
if (span2 > 0) {
|
|
143
|
+
const k2 = Math.min(Math.max(0, r - b2.t), Interpolation.ROT_EXTRAP_MS) / span2;
|
|
144
|
+
const yb = num(b2.state, "yaw");
|
|
145
|
+
out2.yaw = yb + wrapAngle(yb - num(a2.state, "yaw")) * k2;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return out2;
|
|
149
|
+
}
|
|
150
|
+
const a = buf[j - 1];
|
|
151
|
+
const b = buf[j];
|
|
152
|
+
const span = b.t - a.t;
|
|
153
|
+
const k = span > 0 ? (r - a.t) / span : 1;
|
|
154
|
+
const out = { ...a.state };
|
|
155
|
+
for (const key of NUMERIC_POSE_KEYS) {
|
|
156
|
+
const va = num(a.state, key);
|
|
157
|
+
const vb = num(b.state, key);
|
|
158
|
+
out[key] = key === "yaw" ? va + wrapAngle(vb - va) * k : va + (vb - va) * k;
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
function easePose(prev, target, aPos, aRot) {
|
|
163
|
+
const out = { ...target };
|
|
164
|
+
for (const key of NUMERIC_POSE_KEYS) {
|
|
165
|
+
const pv = prev[key];
|
|
166
|
+
const tv = target[key];
|
|
167
|
+
if (typeof pv !== "number" || typeof tv !== "number" || !Number.isFinite(pv) || !Number.isFinite(tv)) continue;
|
|
168
|
+
out[key] = key === "yaw" ? pv + wrapAngle(tv - pv) * aRot : pv + (tv - pv) * aPos;
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
var SnappedData = class _SnappedData {
|
|
173
|
+
pending = [];
|
|
174
|
+
current;
|
|
175
|
+
/** More than this many queued changes means the render clock stalled: apply the oldest at once. */
|
|
176
|
+
static MAX_PENDING = 64;
|
|
177
|
+
push(t, state) {
|
|
178
|
+
this.pending.push({ t, state });
|
|
179
|
+
while (this.pending.length > _SnappedData.MAX_PENDING) this.current = this.pending.shift()?.state;
|
|
180
|
+
}
|
|
181
|
+
/** The data to show at render time `r` (`undefined` = no render clock yet: apply everything). */
|
|
182
|
+
at(r) {
|
|
183
|
+
while (this.pending.length > 0) {
|
|
184
|
+
const head = this.pending[0];
|
|
185
|
+
if (this.current !== void 0 && r !== void 0 && head.t !== void 0 && head.t > r) break;
|
|
186
|
+
this.current = head.state;
|
|
187
|
+
this.pending.shift();
|
|
188
|
+
}
|
|
189
|
+
return this.current;
|
|
190
|
+
}
|
|
191
|
+
flush() {
|
|
192
|
+
this.at(void 0);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// src/server-clock.ts
|
|
197
|
+
function heartbeatHandleOf(room) {
|
|
198
|
+
try {
|
|
199
|
+
const handle = room.input();
|
|
200
|
+
return handle && typeof handle.send === "function" ? handle : null;
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function startHeartbeat(handle, live, hz = ClockSync.HEARTBEAT_HZ, random = Math.random) {
|
|
206
|
+
const rate = Math.min(ClockSync.MAX_HEARTBEAT_HZ, Math.max(ClockSync.MIN_HEARTBEAT_HZ, hz));
|
|
207
|
+
const base = 1e3 / rate;
|
|
208
|
+
const minMs = 1e3 / ClockSync.MAX_HEARTBEAT_HZ;
|
|
209
|
+
const maxMs = 1e3 / ClockSync.MIN_HEARTBEAT_HZ;
|
|
210
|
+
let synced = false;
|
|
211
|
+
let stopped = false;
|
|
212
|
+
let timer;
|
|
213
|
+
const beat = () => {
|
|
214
|
+
if (stopped) return;
|
|
215
|
+
if (live()) {
|
|
216
|
+
try {
|
|
217
|
+
handle.send();
|
|
218
|
+
} catch {
|
|
219
|
+
}
|
|
220
|
+
if (!synced && handle.lastProcessed >= ClockSync.WARMUP_ACKS) synced = true;
|
|
221
|
+
}
|
|
222
|
+
const next = base * (1 + (random() * 2 - 1) * ClockSync.HEARTBEAT_DITHER);
|
|
223
|
+
timer = setTimeout(beat, Math.min(maxMs, Math.max(minMs, next)));
|
|
224
|
+
};
|
|
225
|
+
beat();
|
|
226
|
+
return {
|
|
227
|
+
get synced() {
|
|
228
|
+
if (!synced && handle.lastProcessed >= ClockSync.WARMUP_ACKS) synced = true;
|
|
229
|
+
return synced;
|
|
230
|
+
},
|
|
231
|
+
stop() {
|
|
232
|
+
stopped = true;
|
|
233
|
+
if (timer) clearTimeout(timer);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
function msUntil(epochMs, clock) {
|
|
238
|
+
if (clock.synced && clock.clockOrigin > 0 && clock.serverNow !== void 0 && Number.isFinite(clock.serverNow)) {
|
|
239
|
+
return epochMs - clock.clockOrigin - clock.serverNow;
|
|
240
|
+
}
|
|
241
|
+
return epochMs - clock.wallNow;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// src/room-lifecycle.ts
|
|
245
|
+
function useSdkReconnection(room) {
|
|
246
|
+
const r = room.reconnection;
|
|
247
|
+
r.enabled = ClientReconnection.enabled;
|
|
248
|
+
r.minUptime = ClientReconnection.minUptime;
|
|
249
|
+
r.delay = ClientReconnection.delay;
|
|
250
|
+
r.minDelay = ClientReconnection.minDelay;
|
|
251
|
+
r.maxDelay = ClientReconnection.maxDelay;
|
|
252
|
+
r.maxRetries = ClientReconnection.maxRetries;
|
|
253
|
+
r.maxEnqueuedMessages = ClientReconnection.maxEnqueuedMessages;
|
|
254
|
+
r.enqueuedMessages.length = 0;
|
|
255
|
+
return room;
|
|
256
|
+
}
|
|
257
|
+
function withoutReconnection(room) {
|
|
258
|
+
room.reconnection.enabled = false;
|
|
259
|
+
return room;
|
|
260
|
+
}
|
|
261
|
+
function waitForFirstState(room, timeoutMs) {
|
|
262
|
+
return new Promise((resolve) => {
|
|
263
|
+
const onState = () => {
|
|
264
|
+
clearTimeout(timer);
|
|
265
|
+
resolve(true);
|
|
266
|
+
};
|
|
267
|
+
const timer = setTimeout(() => {
|
|
268
|
+
room.onStateChange.remove(onState);
|
|
269
|
+
resolve(false);
|
|
270
|
+
}, timeoutMs);
|
|
271
|
+
room.onStateChange.once(onState);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
function seedsInterpolation(id, selfId, sendTime) {
|
|
275
|
+
return id !== selfId && sendTime != null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// src/voice.ts
|
|
279
|
+
var NOT_DELEGATED = "The microphone was refused by the page that frames this game, not by the player: the frame was not given it, so the browser never asked. On its Genex page a build is given the microphone only when it declares it. An up-to-date genex CLI declares it at `genex preview` and `genex publish` when the game's own source calls voice.join(); to declare it by hand, add \"microphone\" to genex.capabilities in the game's package.json. Then preview or publish again.";
|
|
280
|
+
var VoiceNotDelegatedError = class extends Error {
|
|
281
|
+
constructor(cause) {
|
|
282
|
+
super(NOT_DELEGATED, { cause });
|
|
283
|
+
this.name = "VoiceNotDelegatedError";
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
var DEFAULT_ICE = [{ urls: "stun:stun.l.google.com:19302" }];
|
|
287
|
+
var SPEAKING_POLL_MS = 120;
|
|
288
|
+
var DEFAULT_SPEAKING_THRESHOLD = 0.02;
|
|
289
|
+
var SPEAKING_RELEASE_RATIO = 0.6;
|
|
290
|
+
var VoiceMesh = class {
|
|
291
|
+
transport;
|
|
292
|
+
peers = /* @__PURE__ */ new Map();
|
|
293
|
+
listeners = /* @__PURE__ */ new Set();
|
|
294
|
+
roster = [];
|
|
295
|
+
local = null;
|
|
296
|
+
audio = null;
|
|
297
|
+
localAnalyser = null;
|
|
298
|
+
levelTimer = null;
|
|
299
|
+
iceServers = DEFAULT_ICE;
|
|
300
|
+
spatial = false;
|
|
301
|
+
threshold = DEFAULT_SPEAKING_THRESHOLD;
|
|
302
|
+
micOn = true;
|
|
303
|
+
selfSpeaking = false;
|
|
304
|
+
running = false;
|
|
305
|
+
// Peering is HELD between acquiring the mic and announcing the join, because both things
|
|
306
|
+
// that happen in that window must land first: the relay's TURN credentials (a peer built
|
|
307
|
+
// without them is stuck on STUN for its whole life), and our own `__voice:join` (the relay
|
|
308
|
+
// drops a signal from a session that is not yet in the roster, and a dropped offer is never
|
|
309
|
+
// retried). Without the hold, another player joining voice in that window patches the roster
|
|
310
|
+
// and builds us a peer that can neither relay nor negotiate.
|
|
311
|
+
peeringHeld = false;
|
|
312
|
+
constructor(transport) {
|
|
313
|
+
this.transport = transport;
|
|
314
|
+
}
|
|
315
|
+
get active() {
|
|
316
|
+
return this.running;
|
|
317
|
+
}
|
|
318
|
+
get micEnabled() {
|
|
319
|
+
return this.micOn;
|
|
320
|
+
}
|
|
321
|
+
get speaking() {
|
|
322
|
+
return this.selfSpeaking;
|
|
323
|
+
}
|
|
324
|
+
get participants() {
|
|
325
|
+
return [...this.peers.values()].map((p) => ({
|
|
326
|
+
id: p.id,
|
|
327
|
+
name: this.transport.nameOf(p.id),
|
|
328
|
+
stream: p.stream,
|
|
329
|
+
speaking: p.speaking,
|
|
330
|
+
muted: p.muted,
|
|
331
|
+
volume: p.volume,
|
|
332
|
+
connection: p.connection
|
|
333
|
+
}));
|
|
334
|
+
}
|
|
335
|
+
onChange(cb) {
|
|
336
|
+
this.listeners.add(cb);
|
|
337
|
+
return () => this.listeners.delete(cb);
|
|
338
|
+
}
|
|
339
|
+
/** Acquire the microphone. Throws if the user denies it or no device exists — the caller
|
|
340
|
+
* surfaces that; it is a normal outcome, not a bug. The one refusal that IS a bug throws a
|
|
341
|
+
* VoiceNotDelegatedError instead: the page framing the game withheld the microphone, because
|
|
342
|
+
* the build never declared it. Must be called from a user gesture: browsers gate both the
|
|
343
|
+
* permission prompt and audio playback on one. */
|
|
344
|
+
async start(options = {}, holdPeering = false) {
|
|
345
|
+
if (this.running) return;
|
|
346
|
+
this.peeringHeld = holdPeering;
|
|
347
|
+
this.iceServers = options.iceServers?.length ? options.iceServers : DEFAULT_ICE;
|
|
348
|
+
this.spatial = options.spatial === true;
|
|
349
|
+
this.threshold = options.speakingThreshold ?? DEFAULT_SPEAKING_THRESHOLD;
|
|
350
|
+
this.micOn = options.startMuted !== true;
|
|
351
|
+
try {
|
|
352
|
+
this.local = await navigator.mediaDevices.getUserMedia({
|
|
353
|
+
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
|
354
|
+
video: false
|
|
355
|
+
});
|
|
356
|
+
} catch (err) {
|
|
357
|
+
throw micRefusal(err);
|
|
358
|
+
}
|
|
359
|
+
for (const t of this.local.getAudioTracks()) t.enabled = this.micOn;
|
|
360
|
+
this.audio = new AudioContext();
|
|
361
|
+
this.localAnalyser = this.makeAnalyser(this.local);
|
|
362
|
+
this.running = true;
|
|
363
|
+
this.levelTimer = setInterval(() => this.pollLevels(), SPEAKING_POLL_MS);
|
|
364
|
+
}
|
|
365
|
+
/** Tear everything down. Safe to call when not running. */
|
|
366
|
+
stop() {
|
|
367
|
+
this.running = false;
|
|
368
|
+
this.peeringHeld = false;
|
|
369
|
+
if (this.levelTimer) {
|
|
370
|
+
clearInterval(this.levelTimer);
|
|
371
|
+
this.levelTimer = null;
|
|
372
|
+
}
|
|
373
|
+
for (const id of [...this.peers.keys()]) this.destroyPeer(id);
|
|
374
|
+
for (const t of this.local?.getTracks() ?? []) t.stop();
|
|
375
|
+
this.local = null;
|
|
376
|
+
this.localAnalyser = null;
|
|
377
|
+
void this.audio?.close().catch(() => {
|
|
378
|
+
});
|
|
379
|
+
this.audio = null;
|
|
380
|
+
this.roster = [];
|
|
381
|
+
this.selfSpeaking = false;
|
|
382
|
+
this.emit();
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Swap in ICE servers obtained after `start()` — in practice the relay's short-lived TURN
|
|
386
|
+
* credentials, which are fetched between acquiring the mic and announcing the join so the
|
|
387
|
+
* network round trip never spends the user gesture `getUserMedia` needs.
|
|
388
|
+
*
|
|
389
|
+
* Peers built from here on use them. Live ones are updated too, but `setConfiguration`
|
|
390
|
+
* governs FUTURE gathering only: an allocation already relaying media keeps the credential
|
|
391
|
+
* it was created with. That is why the credential's TTL is sized to outlast a session
|
|
392
|
+
* rather than to reach the next refresh.
|
|
393
|
+
*/
|
|
394
|
+
setIceServers(servers) {
|
|
395
|
+
if (!servers.length) return;
|
|
396
|
+
this.iceServers = servers;
|
|
397
|
+
for (const peer of this.peers.values()) {
|
|
398
|
+
try {
|
|
399
|
+
peer.pc.setConfiguration({ iceServers: servers });
|
|
400
|
+
} catch {
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Release the hold taken by `start(…, true)` and reconcile whatever roster arrived while it
|
|
406
|
+
* was up. The caller MUST reach this on every path, including the one where no credentials
|
|
407
|
+
* came back: a mesh that never peers is worse than one peering without TURN.
|
|
408
|
+
*/
|
|
409
|
+
releasePeering() {
|
|
410
|
+
if (!this.peeringHeld) return;
|
|
411
|
+
this.peeringHeld = false;
|
|
412
|
+
this.setRoster(this.roster);
|
|
413
|
+
}
|
|
414
|
+
/** The relay's authoritative roster changed: open connections to newcomers, drop leavers. */
|
|
415
|
+
setRoster(ids) {
|
|
416
|
+
this.roster = ids;
|
|
417
|
+
if (!this.running) return;
|
|
418
|
+
if (this.peeringHeld) return;
|
|
419
|
+
const self = this.transport.selfId();
|
|
420
|
+
const wanted = new Set(ids.filter((id) => id !== self));
|
|
421
|
+
for (const id of wanted) if (!this.peers.has(id)) this.createPeer(id);
|
|
422
|
+
for (const id of [...this.peers.keys()]) if (!wanted.has(id)) this.destroyPeer(id);
|
|
423
|
+
this.emit();
|
|
424
|
+
}
|
|
425
|
+
/** Push-to-talk. Gates the track rather than renegotiating — instant, and it keeps the
|
|
426
|
+
* peer connections warm so the first syllable after a key press is not clipped. */
|
|
427
|
+
setMicEnabled(on) {
|
|
428
|
+
this.micOn = on;
|
|
429
|
+
for (const t of this.local?.getAudioTracks() ?? []) t.enabled = on;
|
|
430
|
+
if (!on && this.selfSpeaking) {
|
|
431
|
+
this.selfSpeaking = false;
|
|
432
|
+
this.emit();
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
mute(id) {
|
|
436
|
+
this.setMuted(id, true);
|
|
437
|
+
}
|
|
438
|
+
unmute(id) {
|
|
439
|
+
this.setMuted(id, false);
|
|
440
|
+
}
|
|
441
|
+
setVolume(id, volume) {
|
|
442
|
+
const p = this.peers.get(id);
|
|
443
|
+
if (!p) return;
|
|
444
|
+
p.volume = Math.max(0, Math.min(1, volume));
|
|
445
|
+
if (p.el) p.el.volume = p.volume;
|
|
446
|
+
this.emit();
|
|
447
|
+
}
|
|
448
|
+
/** A signal arrived from `from` (the relay stamped it — it cannot be spoofed). */
|
|
449
|
+
async handleSignal(from, raw) {
|
|
450
|
+
if (!this.running) return;
|
|
451
|
+
const signal = raw;
|
|
452
|
+
if (!signal || typeof signal !== "object" || typeof signal.t !== "string") return;
|
|
453
|
+
const peer = this.peers.get(from) ?? (this.roster.includes(from) ? this.createPeer(from) : void 0);
|
|
454
|
+
if (!peer) return;
|
|
455
|
+
try {
|
|
456
|
+
if (signal.t === "offer") {
|
|
457
|
+
await peer.pc.setRemoteDescription(signal.sdp);
|
|
458
|
+
await this.flushIce(peer);
|
|
459
|
+
const answer = await peer.pc.createAnswer();
|
|
460
|
+
await peer.pc.setLocalDescription(answer);
|
|
461
|
+
this.transport.sendSignal(from, { t: "answer", sdp: answer });
|
|
462
|
+
} else if (signal.t === "answer") {
|
|
463
|
+
if (peer.pc.signalingState !== "have-local-offer") return;
|
|
464
|
+
await peer.pc.setRemoteDescription(signal.sdp);
|
|
465
|
+
await this.flushIce(peer);
|
|
466
|
+
} else if (signal.t === "ice") {
|
|
467
|
+
if (peer.pc.remoteDescription) await peer.pc.addIceCandidate(signal.c);
|
|
468
|
+
else peer.pendingIce.push(signal.c);
|
|
469
|
+
}
|
|
470
|
+
} catch (err) {
|
|
471
|
+
console.warn(`[multiplayer] voice signaling failed with ${from}`, err);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
// ---- internals ----
|
|
475
|
+
setMuted(id, muted) {
|
|
476
|
+
const p = this.peers.get(id);
|
|
477
|
+
if (!p || p.muted === muted) return;
|
|
478
|
+
p.muted = muted;
|
|
479
|
+
if (p.el) p.el.muted = muted || this.spatial;
|
|
480
|
+
if (muted && p.speaking) p.speaking = false;
|
|
481
|
+
this.emit();
|
|
482
|
+
}
|
|
483
|
+
createPeer(id) {
|
|
484
|
+
const pc = new RTCPeerConnection({ iceServers: this.iceServers });
|
|
485
|
+
const peer = {
|
|
486
|
+
id,
|
|
487
|
+
pc,
|
|
488
|
+
el: null,
|
|
489
|
+
stream: null,
|
|
490
|
+
analyser: null,
|
|
491
|
+
pendingIce: [],
|
|
492
|
+
speaking: false,
|
|
493
|
+
muted: false,
|
|
494
|
+
volume: 1,
|
|
495
|
+
connection: "connecting"
|
|
496
|
+
};
|
|
497
|
+
this.peers.set(id, peer);
|
|
498
|
+
for (const track of this.local?.getAudioTracks() ?? []) {
|
|
499
|
+
if (this.local) pc.addTrack(track, this.local);
|
|
500
|
+
}
|
|
501
|
+
pc.onicecandidate = (e) => {
|
|
502
|
+
if (e.candidate) this.transport.sendSignal(id, { t: "ice", c: e.candidate.toJSON() });
|
|
503
|
+
};
|
|
504
|
+
pc.ontrack = (e) => {
|
|
505
|
+
const stream = e.streams[0] ?? new MediaStream([e.track]);
|
|
506
|
+
peer.stream = stream;
|
|
507
|
+
peer.el = this.attachSink(stream, peer);
|
|
508
|
+
peer.analyser = this.makeAnalyser(stream);
|
|
509
|
+
this.emit();
|
|
510
|
+
};
|
|
511
|
+
pc.onconnectionstatechange = () => {
|
|
512
|
+
const s = pc.connectionState;
|
|
513
|
+
const next = s === "connected" ? "connected" : s === "failed" || s === "closed" ? "failed" : "connecting";
|
|
514
|
+
if (next !== peer.connection) {
|
|
515
|
+
peer.connection = next;
|
|
516
|
+
if (next === "failed") {
|
|
517
|
+
const relayed = this.iceServers.some((s2) => hasTurnUrl(s2));
|
|
518
|
+
console.warn(
|
|
519
|
+
`[multiplayer] voice connection to ${id} failed` + (relayed ? "" : " \u2014 no TURN in the ICE list (relay offered none?)")
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
this.emit();
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
if (this.transport.selfId() < id) void this.offer(peer);
|
|
526
|
+
return peer;
|
|
527
|
+
}
|
|
528
|
+
async offer(peer) {
|
|
529
|
+
try {
|
|
530
|
+
const offer = await peer.pc.createOffer();
|
|
531
|
+
await peer.pc.setLocalDescription(offer);
|
|
532
|
+
this.transport.sendSignal(peer.id, { t: "offer", sdp: offer });
|
|
533
|
+
} catch (err) {
|
|
534
|
+
console.warn(`[multiplayer] voice offer to ${peer.id} failed`, err);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
async flushIce(peer) {
|
|
538
|
+
const queued = peer.pendingIce.splice(0);
|
|
539
|
+
for (const c of queued) {
|
|
540
|
+
try {
|
|
541
|
+
await peer.pc.addIceCandidate(c);
|
|
542
|
+
} catch {
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
destroyPeer(id) {
|
|
547
|
+
const peer = this.peers.get(id);
|
|
548
|
+
if (!peer) return;
|
|
549
|
+
this.peers.delete(id);
|
|
550
|
+
peer.pc.onicecandidate = null;
|
|
551
|
+
peer.pc.ontrack = null;
|
|
552
|
+
peer.pc.onconnectionstatechange = null;
|
|
553
|
+
try {
|
|
554
|
+
peer.pc.close();
|
|
555
|
+
} catch {
|
|
556
|
+
}
|
|
557
|
+
if (peer.el) {
|
|
558
|
+
peer.el.srcObject = null;
|
|
559
|
+
peer.el.remove();
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
/** Attach the stream to an element. Required even in spatial mode (see VoiceJoinOptions). */
|
|
563
|
+
attachSink(stream, peer) {
|
|
564
|
+
const el = document.createElement("audio");
|
|
565
|
+
el.autoplay = true;
|
|
566
|
+
el.srcObject = stream;
|
|
567
|
+
el.muted = this.spatial || peer.muted;
|
|
568
|
+
el.volume = peer.volume;
|
|
569
|
+
el.style.display = "none";
|
|
570
|
+
document.body.appendChild(el);
|
|
571
|
+
void el.play().catch((err) => {
|
|
572
|
+
console.warn("[multiplayer] voice playback was blocked \u2014 join voice from a click/tap", err);
|
|
573
|
+
});
|
|
574
|
+
return el;
|
|
575
|
+
}
|
|
576
|
+
makeAnalyser(stream) {
|
|
577
|
+
if (!this.audio) return null;
|
|
578
|
+
try {
|
|
579
|
+
const src = this.audio.createMediaStreamSource(stream);
|
|
580
|
+
const analyser = this.audio.createAnalyser();
|
|
581
|
+
analyser.fftSize = 512;
|
|
582
|
+
src.connect(analyser);
|
|
583
|
+
return analyser;
|
|
584
|
+
} catch {
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
pollLevels() {
|
|
589
|
+
let changed = false;
|
|
590
|
+
const localLevel = this.micOn ? rms(this.localAnalyser) : 0;
|
|
591
|
+
const nextSelf = this.crossed(localLevel, this.selfSpeaking);
|
|
592
|
+
if (nextSelf !== this.selfSpeaking) {
|
|
593
|
+
this.selfSpeaking = nextSelf;
|
|
594
|
+
changed = true;
|
|
595
|
+
}
|
|
596
|
+
for (const peer of this.peers.values()) {
|
|
597
|
+
const next = peer.muted ? false : this.crossed(rms(peer.analyser), peer.speaking);
|
|
598
|
+
if (next !== peer.speaking) {
|
|
599
|
+
peer.speaking = next;
|
|
600
|
+
changed = true;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if (changed) this.emit();
|
|
604
|
+
}
|
|
605
|
+
/** Threshold with hysteresis so a name tag does not strobe between syllables. */
|
|
606
|
+
crossed(level, was) {
|
|
607
|
+
return level >= (was ? this.threshold * SPEAKING_RELEASE_RATIO : this.threshold);
|
|
608
|
+
}
|
|
609
|
+
emit() {
|
|
610
|
+
for (const cb of [...this.listeners]) {
|
|
611
|
+
try {
|
|
612
|
+
cb();
|
|
613
|
+
} catch (err) {
|
|
614
|
+
console.error("[multiplayer] a voice listener threw", err);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
function isFramed() {
|
|
620
|
+
try {
|
|
621
|
+
return window.self !== window.top;
|
|
622
|
+
} catch {
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
function micRefusal(err) {
|
|
627
|
+
if (err?.name !== "NotAllowedError") return err;
|
|
628
|
+
try {
|
|
629
|
+
if (!isFramed()) return err;
|
|
630
|
+
const policy = document.featurePolicy;
|
|
631
|
+
if (policy?.allowsFeature?.("microphone") !== false) return err;
|
|
632
|
+
} catch {
|
|
633
|
+
return err;
|
|
634
|
+
}
|
|
635
|
+
return new VoiceNotDelegatedError(err);
|
|
636
|
+
}
|
|
637
|
+
function hasTurnUrl(server) {
|
|
638
|
+
const urls = Array.isArray(server.urls) ? server.urls : [server.urls];
|
|
639
|
+
return urls.some((u) => typeof u === "string" && u.startsWith("turn"));
|
|
640
|
+
}
|
|
641
|
+
function rms(analyser) {
|
|
642
|
+
if (!analyser) return 0;
|
|
643
|
+
const buf = new Uint8Array(analyser.fftSize);
|
|
644
|
+
analyser.getByteTimeDomainData(buf);
|
|
645
|
+
let sum = 0;
|
|
646
|
+
for (let i = 0; i < buf.length; i++) {
|
|
647
|
+
const v = ((buf[i] ?? 128) - 128) / 128;
|
|
648
|
+
sum += v * v;
|
|
649
|
+
}
|
|
650
|
+
return Math.sqrt(sum / buf.length);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// src/room-ref.ts
|
|
654
|
+
var ROOM_REF = /* @__PURE__ */ Symbol("genex.roomRef");
|
|
655
|
+
var roomOf = (s) => s[ROOM_REF]();
|
|
656
|
+
|
|
657
|
+
// src/index.ts
|
|
658
|
+
var INTERP_RATIO = Interpolation.RATIO;
|
|
659
|
+
var INTERP_MIN_MS = Interpolation.MIN_MS;
|
|
660
|
+
var INTERP_MAX_MS = Interpolation.MAX_MS;
|
|
661
|
+
var INTERP_DEFAULT_MS = Interpolation.DEFAULT_MS;
|
|
662
|
+
var JITTER_SAFETY = Interpolation.JITTER_SAFETY;
|
|
663
|
+
var POISON_GUARD_MS = Interpolation.POISON_GUARD_MS;
|
|
664
|
+
var INTERP_EASE = Interpolation.EASE;
|
|
665
|
+
var MAX_EXTRAP_MS = Interpolation.MAX_EXTRAP_MS;
|
|
666
|
+
var ROT_EXTRAP_MS = Interpolation.ROT_EXTRAP_MS;
|
|
667
|
+
var CLOCK_WIN = Interpolation.CLOCK_WINDOW;
|
|
668
|
+
var CLOCK_WIN_MS = Interpolation.CLOCK_WINDOW_MS;
|
|
669
|
+
var CLOCK_WIN_MAX = Interpolation.CLOCK_WINDOW_MAX;
|
|
670
|
+
var INTERVAL_WIN = Interpolation.INTERVAL_WINDOW;
|
|
671
|
+
var MIN_GAP_MS = Interpolation.MIN_GAP_MS;
|
|
672
|
+
var MAX_GAP_MS = Interpolation.MAX_GAP_MS;
|
|
673
|
+
var HISTORY_MS = Interpolation.HISTORY_MS;
|
|
674
|
+
var SMOOTH_TAU_POS = Interpolation.SMOOTH_TAU_POS_MS;
|
|
675
|
+
var SMOOTH_TAU_ROT = Interpolation.SMOOTH_TAU_ROT_MS;
|
|
676
|
+
var MAX_DT_MS = Interpolation.MAX_HOST_TICK_DT_MS;
|
|
677
|
+
var REBASE_AFTER_GAP_MS = Interpolation.REBASE_AFTER_GAP_MS;
|
|
678
|
+
var OBJ_STREAM = "obj:";
|
|
679
|
+
var ICE_REQUEST_TIMEOUT_MS = 3e3;
|
|
680
|
+
var CONTROL_TIMEOUT_MS = 5e3;
|
|
681
|
+
var CONTROL_TIMEOUT_MAX_MS = 6e4;
|
|
682
|
+
var POSE_DATA_KEEPALIVE_MS = 2e3;
|
|
683
|
+
async function relayClient(opts) {
|
|
684
|
+
if (opts.url) return new Client(opts.url);
|
|
685
|
+
if (opts.urls.length > 1) return Client.selectByLatency(opts.urls);
|
|
686
|
+
const url = opts.urls[0];
|
|
687
|
+
if (!url) throw new Error("genex multiplayer: `urls` must contain at least one relay URL");
|
|
688
|
+
return new Client(url);
|
|
689
|
+
}
|
|
690
|
+
var resolveAuth = (auth) => typeof auth === "function" ? auth() : auth;
|
|
691
|
+
function predictSmoothing(opt) {
|
|
692
|
+
if (opt === "predict") return { mode: "lerp" };
|
|
693
|
+
if (opt && typeof opt === "object" && opt.engine === "predict") {
|
|
694
|
+
return { mode: opt.mode === "damped" ? "damped" : "lerp", delay: opt.delay, smoothMs: opt.smoothMs };
|
|
695
|
+
}
|
|
696
|
+
return void 0;
|
|
697
|
+
}
|
|
698
|
+
var loadPredict = async (opt) => predictSmoothing(opt) ? import("@colyseus/sdk/predict") : void 0;
|
|
699
|
+
function parseData(s) {
|
|
700
|
+
if (!s) return { state: {} };
|
|
701
|
+
try {
|
|
702
|
+
const v = JSON.parse(s);
|
|
703
|
+
if (!v || typeof v !== "object") return { state: {} };
|
|
704
|
+
const o = v;
|
|
705
|
+
const t = o[SEND_TIME_KEY];
|
|
706
|
+
if (typeof t === "number" && Number.isFinite(t)) {
|
|
707
|
+
const state = { ...o };
|
|
708
|
+
delete state[SEND_TIME_KEY];
|
|
709
|
+
return { state, sendTime: t };
|
|
710
|
+
}
|
|
711
|
+
return { state: o };
|
|
712
|
+
} catch {
|
|
713
|
+
return { state: {} };
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
function parseValue(s) {
|
|
717
|
+
if (s == null) return void 0;
|
|
718
|
+
try {
|
|
719
|
+
return JSON.parse(s);
|
|
720
|
+
} catch {
|
|
721
|
+
return void 0;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
var finiteReplacer = (_k, v) => {
|
|
725
|
+
if (typeof v === "number") return Number.isFinite(v) ? v : 0;
|
|
726
|
+
if (typeof v === "bigint") return Number(v);
|
|
727
|
+
return v;
|
|
728
|
+
};
|
|
729
|
+
function sanitizeFinite(obj) {
|
|
730
|
+
return JSON.parse(JSON.stringify(obj, finiteReplacer));
|
|
731
|
+
}
|
|
732
|
+
var nowMs = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
733
|
+
function slerp(a, b, t) {
|
|
734
|
+
let [bx, by, bz, bw] = b;
|
|
735
|
+
const [ax, ay, az, aw] = a;
|
|
736
|
+
let dot = ax * bx + ay * by + az * bz + aw * bw;
|
|
737
|
+
if (dot < 0) {
|
|
738
|
+
bx = -bx;
|
|
739
|
+
by = -by;
|
|
740
|
+
bz = -bz;
|
|
741
|
+
bw = -bw;
|
|
742
|
+
dot = -dot;
|
|
743
|
+
}
|
|
744
|
+
if (dot > 0.9995) {
|
|
745
|
+
const x = ax + (bx - ax) * t, y = ay + (by - ay) * t, z = az + (bz - az) * t, w = aw + (bw - aw) * t;
|
|
746
|
+
const l = Math.hypot(x, y, z, w) || 1;
|
|
747
|
+
return [x / l, y / l, z / l, w / l];
|
|
748
|
+
}
|
|
749
|
+
const theta0 = Math.acos(dot);
|
|
750
|
+
const sin0 = Math.sin(theta0);
|
|
751
|
+
const s0 = Math.sin(theta0 - theta0 * t) / sin0;
|
|
752
|
+
const s1 = Math.sin(theta0 * t) / sin0;
|
|
753
|
+
return [ax * s0 + bx * s1, ay * s0 + by * s1, az * s0 + bz * s1, aw * s0 + bw * s1];
|
|
754
|
+
}
|
|
755
|
+
function isQuat(v) {
|
|
756
|
+
return Array.isArray(v) && v.length === 4 && v.every((n) => typeof n === "number" && Number.isFinite(n));
|
|
757
|
+
}
|
|
758
|
+
function blend(a, b, tNum, tQuat) {
|
|
759
|
+
const out = {};
|
|
760
|
+
const interpolating = tNum < 1;
|
|
761
|
+
for (const k in b) {
|
|
762
|
+
const av = a[k];
|
|
763
|
+
const bv = b[k];
|
|
764
|
+
if (typeof av === "number" && typeof bv === "number" && Number.isFinite(av) && Number.isFinite(bv)) {
|
|
765
|
+
out[k] = av + (bv - av) * tNum;
|
|
766
|
+
} else if (isQuat(av) && isQuat(bv)) {
|
|
767
|
+
out[k] = slerp(av, bv, tQuat);
|
|
768
|
+
} else {
|
|
769
|
+
out[k] = interpolating && k in a ? av : bv;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
return out;
|
|
773
|
+
}
|
|
774
|
+
function easeState(prev, target, aNum, aQuat) {
|
|
775
|
+
const out = {};
|
|
776
|
+
for (const k in target) {
|
|
777
|
+
const tv = target[k];
|
|
778
|
+
const pv = prev[k];
|
|
779
|
+
if (typeof pv === "number" && typeof tv === "number" && Number.isFinite(pv) && Number.isFinite(tv)) {
|
|
780
|
+
out[k] = pv + (tv - pv) * aNum;
|
|
781
|
+
} else if (isQuat(pv) && isQuat(tv)) {
|
|
782
|
+
out[k] = slerp(pv, tv, aQuat);
|
|
783
|
+
} else {
|
|
784
|
+
out[k] = tv;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return out;
|
|
788
|
+
}
|
|
789
|
+
function sampleBuffer(buf, renderTime) {
|
|
790
|
+
const n = buf.length;
|
|
791
|
+
if (n === 0) return void 0;
|
|
792
|
+
if (n === 1) return buf[0].state;
|
|
793
|
+
let j = 0;
|
|
794
|
+
while (j < n && buf[j].t <= renderTime) j++;
|
|
795
|
+
if (j === 0) return buf[0].state;
|
|
796
|
+
if (j >= n) {
|
|
797
|
+
const a2 = buf[n - 2];
|
|
798
|
+
const b2 = buf[n - 1];
|
|
799
|
+
const span2 = b2.t - a2.t;
|
|
800
|
+
if (span2 <= 0) return b2.state;
|
|
801
|
+
const tPos = (Math.min(renderTime, b2.t + MAX_EXTRAP_MS) - a2.t) / span2;
|
|
802
|
+
const tRot = (Math.min(renderTime, b2.t + ROT_EXTRAP_MS) - a2.t) / span2;
|
|
803
|
+
return blend(a2.state, b2.state, tPos, tRot);
|
|
804
|
+
}
|
|
805
|
+
const a = buf[j - 1];
|
|
806
|
+
const b = buf[j];
|
|
807
|
+
const span = b.t - a.t;
|
|
808
|
+
const t = span > 0 ? (renderTime - a.t) / span : 1;
|
|
809
|
+
return blend(a.state, b.state, t, t);
|
|
810
|
+
}
|
|
811
|
+
function median(xs) {
|
|
812
|
+
if (xs.length === 0) return 66;
|
|
813
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
814
|
+
return s[s.length >> 1];
|
|
815
|
+
}
|
|
816
|
+
function quantile(xs, q) {
|
|
817
|
+
if (xs.length === 0) return 0;
|
|
818
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
819
|
+
const i = Math.min(s.length - 1, Math.max(0, Math.round(q * (s.length - 1))));
|
|
820
|
+
return s[i];
|
|
821
|
+
}
|
|
822
|
+
async function connect(opts) {
|
|
823
|
+
const client = await relayClient(opts);
|
|
824
|
+
const predict = await loadPredict(opts.smoothing);
|
|
825
|
+
const room = useSdkReconnection(
|
|
826
|
+
await client.joinOrCreate(RoomType.PLAY, {
|
|
827
|
+
...joinOptions(opts.room, opts.name, opts.auth),
|
|
828
|
+
...opts.options,
|
|
829
|
+
// Last, so no extra option can misreport it (PROTOCOL.md §2: missing or wrong = 426).
|
|
830
|
+
protocol: PROTOCOL_VERSION
|
|
831
|
+
}).catch(explainJoinRefusal)
|
|
832
|
+
);
|
|
833
|
+
return attachSession(room, { pose: opts.pose, smoothing: opts.smoothing, predict });
|
|
834
|
+
}
|
|
835
|
+
function joinOptions(gameId, name, auth) {
|
|
836
|
+
return {
|
|
837
|
+
gameId,
|
|
838
|
+
name,
|
|
839
|
+
embedToken: resolveAuth(auth)?.embedToken,
|
|
840
|
+
protocol: PROTOCOL_VERSION
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
var isProtocolRefusal = (err) => !!err && typeof err === "object" && err.code === JoinError.PROTOCOL_UNSUPPORTED;
|
|
844
|
+
var warnProtocolRefusal = () => console.error(
|
|
845
|
+
`[multiplayer] the relay refused protocol ${PROTOCOL_VERSION} (code 426) \u2014 this game was built with a @genex-ai/multiplayer that this relay no longer serves. Update the package and republish.`
|
|
846
|
+
);
|
|
847
|
+
var explainJoinRefusal = (err) => {
|
|
848
|
+
if (isProtocolRefusal(err)) warnProtocolRefusal();
|
|
849
|
+
throw err;
|
|
850
|
+
};
|
|
851
|
+
var isSendableEventType = (type) => typeof type === "string" && type.length > 0 && type.length <= Limits.MAX_EVENT_TYPE_CHARS && !isReservedEventType(type);
|
|
852
|
+
var LOCAL_EVENTS = /* @__PURE__ */ new Set([
|
|
853
|
+
"join",
|
|
854
|
+
"leave",
|
|
855
|
+
"change",
|
|
856
|
+
"shared",
|
|
857
|
+
"object",
|
|
858
|
+
"host",
|
|
859
|
+
"reconnecting",
|
|
860
|
+
"reconnected",
|
|
861
|
+
"disconnect",
|
|
862
|
+
"room:open",
|
|
863
|
+
"voice",
|
|
864
|
+
"voice:rejected"
|
|
865
|
+
]);
|
|
866
|
+
var SDK_CHANNELS = /* @__PURE__ */ new Set([
|
|
867
|
+
ServerMessage.OBJECT_RESULT,
|
|
868
|
+
ServerMessage.INPUT,
|
|
869
|
+
ServerMessage.ROOM_OPEN,
|
|
870
|
+
ServerMessage.CHAT,
|
|
871
|
+
ServerMessage.CHAT_HISTORY,
|
|
872
|
+
ServerMessage.VOICE_ICE,
|
|
873
|
+
ServerMessage.VOICE_SIGNAL,
|
|
874
|
+
ServerMessage.VOICE_REJECTED,
|
|
875
|
+
ServerMessage.RATE_DROP,
|
|
876
|
+
...Object.values(ClientMessage)
|
|
877
|
+
]);
|
|
878
|
+
var QUEUE_RESEARCH_DELAYS_MS = [500, 1e3, 2e3, 4e3, 8e3];
|
|
879
|
+
function attachSession(room, options = {}) {
|
|
880
|
+
const buffers = /* @__PURE__ */ new Map();
|
|
881
|
+
const lastStamp = /* @__PURE__ */ new Map();
|
|
882
|
+
const clockOffsets = /* @__PURE__ */ new Map();
|
|
883
|
+
const senderClocks = /* @__PURE__ */ new Map();
|
|
884
|
+
const jitterWin = /* @__PURE__ */ new Map();
|
|
885
|
+
const streamSender = /* @__PURE__ */ new Map();
|
|
886
|
+
const posedStreams = /* @__PURE__ */ new Set();
|
|
887
|
+
const smoothed = /* @__PURE__ */ new Map();
|
|
888
|
+
const smoothedAt = /* @__PURE__ */ new Map();
|
|
889
|
+
const intervalsById = /* @__PURE__ */ new Map();
|
|
890
|
+
const autoDelayById = /* @__PURE__ */ new Map();
|
|
891
|
+
let localState;
|
|
892
|
+
const objOwners = /* @__PURE__ */ new Map();
|
|
893
|
+
const objConfirmedOwners = /* @__PURE__ */ new Map();
|
|
894
|
+
const objEpochs = /* @__PURE__ */ new Map();
|
|
895
|
+
const objSeqs = /* @__PURE__ */ new Map();
|
|
896
|
+
const objStamps = /* @__PURE__ */ new Map();
|
|
897
|
+
const objText = /* @__PURE__ */ new Map();
|
|
898
|
+
const objRaw = /* @__PURE__ */ new Map();
|
|
899
|
+
const localObjState = /* @__PURE__ */ new Map();
|
|
900
|
+
const warnedUnownedWrites = /* @__PURE__ */ new Set();
|
|
901
|
+
const pendingBridge = /* @__PURE__ */ new Map();
|
|
902
|
+
let hostId;
|
|
903
|
+
let requestCounter = 0;
|
|
904
|
+
const pendingControls = /* @__PURE__ */ new Map();
|
|
905
|
+
const hasPendingControl = (id) => {
|
|
906
|
+
for (const pending of pendingControls.values()) if (pending.id === id) return true;
|
|
907
|
+
return false;
|
|
908
|
+
};
|
|
909
|
+
const testSkew = typeof globalThis !== "undefined" && globalThis.__GENEX_MP_TEST_SKEW__ || 0;
|
|
910
|
+
const now = testSkew ? () => nowMs() + testSkew : nowMs;
|
|
911
|
+
const rejectUnownedWrite = (operation, id) => {
|
|
912
|
+
if (objOwners.get(id) === room.sessionId) return false;
|
|
913
|
+
const warningKey = `${operation}:${id}`;
|
|
914
|
+
if (!warnedUnownedWrites.has(warningKey)) {
|
|
915
|
+
warnedUnownedWrites.add(warningKey);
|
|
916
|
+
console.warn(
|
|
917
|
+
`[multiplayer] objects.${operation}('${id}') ignored because this session does not own the object \u2014 await objects.claimConfirmed('${id}') and check accepted before publishing`
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
return true;
|
|
921
|
+
};
|
|
922
|
+
const pushSnap = (id, state, sendTime, sender) => {
|
|
923
|
+
const arrival = now();
|
|
924
|
+
const t = sendTime;
|
|
925
|
+
const sample = arrival - sendTime;
|
|
926
|
+
let sc = senderClocks.get(sender);
|
|
927
|
+
if (!sc) senderClocks.set(sender, sc = { samples: [], mins: [], n: 0 });
|
|
928
|
+
const n = ++sc.n;
|
|
929
|
+
sc.samples.push({ at: arrival, n });
|
|
930
|
+
while (sc.mins.length > 0 && sc.mins[sc.mins.length - 1].v >= sample) sc.mins.pop();
|
|
931
|
+
sc.mins.push({ v: sample, n });
|
|
932
|
+
while (sc.samples.length > CLOCK_WIN_MAX || sc.samples.length > CLOCK_WIN && sc.samples[0].at < arrival - CLOCK_WIN_MS) {
|
|
933
|
+
sc.samples.shift();
|
|
934
|
+
}
|
|
935
|
+
const firstKept = sc.samples[0].n;
|
|
936
|
+
while (sc.mins[0].n < firstKept) sc.mins.shift();
|
|
937
|
+
clockOffsets.set(sender, sc.mins[0].v);
|
|
938
|
+
let jw = jitterWin.get(id);
|
|
939
|
+
if (!jw) jitterWin.set(id, jw = []);
|
|
940
|
+
jw.push(sample);
|
|
941
|
+
if (jw.length > CLOCK_WIN) jw.shift();
|
|
942
|
+
streamSender.set(id, sender);
|
|
943
|
+
const prev = lastStamp.get(id);
|
|
944
|
+
lastStamp.set(id, t);
|
|
945
|
+
if (prev != null) {
|
|
946
|
+
const gap = t - prev;
|
|
947
|
+
if (gap > MIN_GAP_MS && gap < MAX_GAP_MS) {
|
|
948
|
+
let iv = intervalsById.get(id);
|
|
949
|
+
if (!iv) intervalsById.set(id, iv = []);
|
|
950
|
+
iv.push(gap);
|
|
951
|
+
if (iv.length > INTERVAL_WIN) iv.shift();
|
|
952
|
+
const jitter = jw.length >= 8 ? Math.max(0, quantile(jw, 0.9) - sc.mins[0].v) : 0;
|
|
953
|
+
const target = Math.min(
|
|
954
|
+
INTERP_MAX_MS,
|
|
955
|
+
Math.max(INTERP_MIN_MS, INTERP_RATIO * median(iv), JITTER_SAFETY * jitter)
|
|
956
|
+
);
|
|
957
|
+
const cur = autoDelayById.get(id) ?? INTERP_DEFAULT_MS;
|
|
958
|
+
autoDelayById.set(id, cur + (target - cur) * INTERP_EASE);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
let buf = buffers.get(id);
|
|
962
|
+
if (!buf) buffers.set(id, buf = []);
|
|
963
|
+
const bridge = pendingBridge.get(id);
|
|
964
|
+
if (bridge && buf.length === 0) {
|
|
965
|
+
const delay = autoDelayById.get(id) ?? INTERP_DEFAULT_MS;
|
|
966
|
+
buf.push({ t: t - Math.max(1, delay), state: bridge });
|
|
967
|
+
}
|
|
968
|
+
pendingBridge.delete(id);
|
|
969
|
+
buf.push({ t, state });
|
|
970
|
+
const cutoff = t - HISTORY_MS;
|
|
971
|
+
while (buf.length > 2 && buf[0].t < cutoff) buf.shift();
|
|
972
|
+
while (buf.length > 1 && buf[0].t > buf[buf.length - 1].t + POISON_GUARD_MS) buf.shift();
|
|
973
|
+
};
|
|
974
|
+
const renderTimeOf = (streamKey, localNow) => {
|
|
975
|
+
const sender = streamSender.get(streamKey);
|
|
976
|
+
const offset = sender === void 0 ? void 0 : clockOffsets.get(sender);
|
|
977
|
+
if (offset === void 0) return void 0;
|
|
978
|
+
return localNow - offset - (autoDelayById.get(streamKey) ?? INTERP_DEFAULT_MS);
|
|
979
|
+
};
|
|
980
|
+
const sampleSmoothed = (streamKey, localNow) => {
|
|
981
|
+
const buf = buffers.get(streamKey);
|
|
982
|
+
const renderTime = renderTimeOf(streamKey, localNow) ?? localNow - INTERP_DEFAULT_MS;
|
|
983
|
+
const posed = posedStreams.has(streamKey);
|
|
984
|
+
const sampled = buf && (posed ? samplePose(buf, renderTime) : sampleBuffer(buf, renderTime));
|
|
985
|
+
if (!sampled) return smoothed.get(streamKey);
|
|
986
|
+
const prev = smoothed.get(streamKey);
|
|
987
|
+
let out;
|
|
988
|
+
if (prev) {
|
|
989
|
+
const dt = Math.min(MAX_DT_MS, Math.max(0, localNow - (smoothedAt.get(streamKey) ?? localNow)));
|
|
990
|
+
const aPos = 1 - Math.exp(-dt / SMOOTH_TAU_POS);
|
|
991
|
+
const aRot = 1 - Math.exp(-dt / SMOOTH_TAU_ROT);
|
|
992
|
+
out = posed ? easePose(prev, sampled, aPos, aRot) : easeState(prev, sampled, aPos, aRot);
|
|
993
|
+
} else {
|
|
994
|
+
out = sampled;
|
|
995
|
+
}
|
|
996
|
+
smoothed.set(streamKey, out);
|
|
997
|
+
smoothedAt.set(streamKey, localNow);
|
|
998
|
+
return out;
|
|
999
|
+
};
|
|
1000
|
+
const clearStream = (streamKey) => {
|
|
1001
|
+
buffers.delete(streamKey);
|
|
1002
|
+
lastStamp.delete(streamKey);
|
|
1003
|
+
streamSender.delete(streamKey);
|
|
1004
|
+
smoothed.delete(streamKey);
|
|
1005
|
+
smoothedAt.delete(streamKey);
|
|
1006
|
+
intervalsById.delete(streamKey);
|
|
1007
|
+
autoDelayById.delete(streamKey);
|
|
1008
|
+
jitterWin.delete(streamKey);
|
|
1009
|
+
pendingBridge.delete(streamKey);
|
|
1010
|
+
};
|
|
1011
|
+
const clearStreamClock = (streamKey) => {
|
|
1012
|
+
buffers.delete(streamKey);
|
|
1013
|
+
lastStamp.delete(streamKey);
|
|
1014
|
+
streamSender.delete(streamKey);
|
|
1015
|
+
intervalsById.delete(streamKey);
|
|
1016
|
+
autoDelayById.delete(streamKey);
|
|
1017
|
+
jitterWin.delete(streamKey);
|
|
1018
|
+
};
|
|
1019
|
+
const clearSenderClock = (sender) => {
|
|
1020
|
+
clockOffsets.delete(sender);
|
|
1021
|
+
senderClocks.delete(sender);
|
|
1022
|
+
};
|
|
1023
|
+
const emitters = /* @__PURE__ */ new Map();
|
|
1024
|
+
const bridged = /* @__PURE__ */ new Set();
|
|
1025
|
+
const emit = (event, ...args) => emitters.get(event)?.forEach((cb) => {
|
|
1026
|
+
try {
|
|
1027
|
+
cb(...args);
|
|
1028
|
+
} catch (err) {
|
|
1029
|
+
console.error(`[multiplayer] a '${event}' listener threw:`, err);
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
const lastShared = /* @__PURE__ */ new Map();
|
|
1033
|
+
const handleShared = (value, key) => {
|
|
1034
|
+
if (lastShared.get(key) === value) return;
|
|
1035
|
+
lastShared.set(key, value);
|
|
1036
|
+
emit("shared", key, parseValue(value));
|
|
1037
|
+
};
|
|
1038
|
+
const applyHost = (wire) => {
|
|
1039
|
+
const next = wire || void 0;
|
|
1040
|
+
if (next === hostId) return;
|
|
1041
|
+
hostId = next;
|
|
1042
|
+
emit("host", hostId);
|
|
1043
|
+
};
|
|
1044
|
+
let lastRoster = "";
|
|
1045
|
+
const applyVoiceRoster = () => {
|
|
1046
|
+
const ids = room.state?.voice ? [...room.state.voice.keys()].sort() : [];
|
|
1047
|
+
const key = ids.join("\n");
|
|
1048
|
+
if (key === lastRoster) return;
|
|
1049
|
+
lastRoster = key;
|
|
1050
|
+
voice.setRoster(ids);
|
|
1051
|
+
emit("voice", voice.participants);
|
|
1052
|
+
};
|
|
1053
|
+
const applyObject = (id, o) => {
|
|
1054
|
+
const key = OBJ_STREAM + id;
|
|
1055
|
+
const me = room.sessionId;
|
|
1056
|
+
const known = objSeqs.has(id);
|
|
1057
|
+
const owner = o.owner || void 0;
|
|
1058
|
+
const seq = Number.isFinite(o.seq) ? o.seq : 0;
|
|
1059
|
+
const epoch = Number.isFinite(o.epoch) ? o.epoch : 0;
|
|
1060
|
+
const t = Number.isFinite(o.t) ? o.t : 0;
|
|
1061
|
+
const text = typeof o.d === "string" ? o.d : "";
|
|
1062
|
+
const ownerChanged = owner !== objConfirmedOwners.get(id);
|
|
1063
|
+
const handoff = ownerChanged || known && seq !== objSeqs.get(id);
|
|
1064
|
+
const epochChanged = objEpochs.has(id) && epoch !== objEpochs.get(id);
|
|
1065
|
+
const stampChanged = t !== objStamps.get(id);
|
|
1066
|
+
objSeqs.set(id, seq);
|
|
1067
|
+
objEpochs.set(id, epoch);
|
|
1068
|
+
objStamps.set(id, t);
|
|
1069
|
+
if (text !== objText.get(id)) {
|
|
1070
|
+
objText.set(id, text);
|
|
1071
|
+
const parsed = text ? parseValue(text) : void 0;
|
|
1072
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) objRaw.set(id, parsed);
|
|
1073
|
+
}
|
|
1074
|
+
if (owner) objConfirmedOwners.set(id, owner);
|
|
1075
|
+
else objConfirmedOwners.delete(id);
|
|
1076
|
+
if (handoff || objOwners.get(id) !== owner && !hasPendingControl(id)) {
|
|
1077
|
+
if (owner) objOwners.set(id, owner);
|
|
1078
|
+
else objOwners.delete(id);
|
|
1079
|
+
}
|
|
1080
|
+
if (epochChanged) clearStream(key);
|
|
1081
|
+
if (handoff && owner !== me) {
|
|
1082
|
+
localObjState.delete(id);
|
|
1083
|
+
if (!epochChanged) clearStreamClock(key);
|
|
1084
|
+
}
|
|
1085
|
+
const d = objRaw.get(id);
|
|
1086
|
+
if (d && owner !== me) {
|
|
1087
|
+
const sample = t !== 0 && stampChanged && owner !== void 0;
|
|
1088
|
+
if (epochChanged) {
|
|
1089
|
+
smoothed.set(key, d);
|
|
1090
|
+
smoothedAt.set(key, now());
|
|
1091
|
+
if (sample) pushSnap(key, d, t, owner);
|
|
1092
|
+
} else {
|
|
1093
|
+
if (handoff) {
|
|
1094
|
+
pendingBridge.set(key, smoothed.get(key) ?? d);
|
|
1095
|
+
}
|
|
1096
|
+
if (sample) pushSnap(key, d, t, owner);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
if (!known || handoff) emit("object", id);
|
|
1100
|
+
};
|
|
1101
|
+
const dropObject = (id) => {
|
|
1102
|
+
const known = objSeqs.has(id) || objOwners.has(id) || objRaw.has(id) || localObjState.has(id);
|
|
1103
|
+
clearStream(OBJ_STREAM + id);
|
|
1104
|
+
objRaw.delete(id);
|
|
1105
|
+
objOwners.delete(id);
|
|
1106
|
+
objConfirmedOwners.delete(id);
|
|
1107
|
+
objEpochs.delete(id);
|
|
1108
|
+
objSeqs.delete(id);
|
|
1109
|
+
objStamps.delete(id);
|
|
1110
|
+
objText.delete(id);
|
|
1111
|
+
localObjState.delete(id);
|
|
1112
|
+
if (known) emit("object", id);
|
|
1113
|
+
};
|
|
1114
|
+
const parseCache = /* @__PURE__ */ new Map();
|
|
1115
|
+
const playerEpochs = /* @__PURE__ */ new Map();
|
|
1116
|
+
const playerData = /* @__PURE__ */ new Map();
|
|
1117
|
+
const cachedParse = (id, raw) => {
|
|
1118
|
+
if (!raw) return {};
|
|
1119
|
+
const hit = parseCache.get(id);
|
|
1120
|
+
if (hit && hit.raw === raw) return hit.state;
|
|
1121
|
+
const { state } = parseData(raw);
|
|
1122
|
+
parseCache.set(id, { raw, state });
|
|
1123
|
+
return state;
|
|
1124
|
+
};
|
|
1125
|
+
const posedData = /* @__PURE__ */ new Map();
|
|
1126
|
+
const playerPoseT = /* @__PURE__ */ new Map();
|
|
1127
|
+
const predictCfg = options.predict ? predictSmoothing(options.smoothing) : void 0;
|
|
1128
|
+
let predict;
|
|
1129
|
+
const predictAttached = /* @__PURE__ */ new Map();
|
|
1130
|
+
const attachPredict = (id, player) => {
|
|
1131
|
+
if (!predictCfg || !options.predict) return;
|
|
1132
|
+
predict ??= options.predict.Predict.get(room);
|
|
1133
|
+
const lin = { mode: predictCfg.mode, delay: predictCfg.delay, smoothMs: predictCfg.smoothMs };
|
|
1134
|
+
predict.attach(player, { x: lin, y: lin, z: lin, vx: lin, vy: lin, vz: lin });
|
|
1135
|
+
predict.attach(player, { yaw: { ...lin, angle: true } });
|
|
1136
|
+
predictAttached.set(id, player);
|
|
1137
|
+
};
|
|
1138
|
+
const detachPredict = (id) => {
|
|
1139
|
+
const player = predictAttached.get(id);
|
|
1140
|
+
if (player && predict) predict.detach(player);
|
|
1141
|
+
predictAttached.delete(id);
|
|
1142
|
+
};
|
|
1143
|
+
const predictedPose = (player) => {
|
|
1144
|
+
const p = predict;
|
|
1145
|
+
return {
|
|
1146
|
+
x: p.value(player, "x"),
|
|
1147
|
+
y: p.value(player, "y"),
|
|
1148
|
+
z: p.value(player, "z"),
|
|
1149
|
+
yaw: p.value(player, "yaw"),
|
|
1150
|
+
vx: p.value(player, "vx"),
|
|
1151
|
+
vy: p.value(player, "vy"),
|
|
1152
|
+
vz: p.value(player, "vz"),
|
|
1153
|
+
anim: player.anim ?? ""
|
|
1154
|
+
// Predict smooths numbers only: anim is the latest, not held (§15 A/B)
|
|
1155
|
+
};
|
|
1156
|
+
};
|
|
1157
|
+
let intentionalLeave = false;
|
|
1158
|
+
let dead = false;
|
|
1159
|
+
let connected = true;
|
|
1160
|
+
let reconnecting = false;
|
|
1161
|
+
let reconnectAttempt = 0;
|
|
1162
|
+
let reconnectStartedAt = 0;
|
|
1163
|
+
let dropGeneration = 0;
|
|
1164
|
+
const hostTickSyncs = /* @__PURE__ */ new Set();
|
|
1165
|
+
const syncHostTicks = () => hostTickSyncs.forEach((sync) => sync());
|
|
1166
|
+
const settleControl = (pending, result) => {
|
|
1167
|
+
pendingControls.delete(pending.requestId);
|
|
1168
|
+
if (pending.timeout) clearTimeout(pending.timeout);
|
|
1169
|
+
pending.abort?.();
|
|
1170
|
+
if (pending.operation === "claim" && pending.optimistic && !result.accepted) {
|
|
1171
|
+
const owner = result.owner ?? objConfirmedOwners.get(pending.id);
|
|
1172
|
+
if (owner) objOwners.set(pending.id, owner);
|
|
1173
|
+
else objOwners.delete(pending.id);
|
|
1174
|
+
if (owner !== room.sessionId) localObjState.delete(pending.id);
|
|
1175
|
+
}
|
|
1176
|
+
if (pending.operation === "claim" && result.accepted) {
|
|
1177
|
+
objOwners.set(pending.id, room.sessionId);
|
|
1178
|
+
clearStreamClock(OBJ_STREAM + pending.id);
|
|
1179
|
+
}
|
|
1180
|
+
if (pending.operation === "release" && result.accepted) {
|
|
1181
|
+
localObjState.delete(pending.id);
|
|
1182
|
+
if (objOwners.get(pending.id) === room.sessionId) objOwners.delete(pending.id);
|
|
1183
|
+
clearStreamClock(OBJ_STREAM + pending.id);
|
|
1184
|
+
}
|
|
1185
|
+
if (pending.operation === "remove" && result.accepted) {
|
|
1186
|
+
localObjState.delete(pending.id);
|
|
1187
|
+
objRaw.delete(pending.id);
|
|
1188
|
+
objOwners.delete(pending.id);
|
|
1189
|
+
clearStream(OBJ_STREAM + pending.id);
|
|
1190
|
+
}
|
|
1191
|
+
pending.resolve(result);
|
|
1192
|
+
};
|
|
1193
|
+
const localResult = (pending, reason) => {
|
|
1194
|
+
const o = room.state?.objects?.get(pending.id);
|
|
1195
|
+
return {
|
|
1196
|
+
requestId: pending.requestId,
|
|
1197
|
+
operation: pending.operation,
|
|
1198
|
+
id: pending.id,
|
|
1199
|
+
accepted: false,
|
|
1200
|
+
owner: o?.owner || void 0,
|
|
1201
|
+
sequence: o && Number.isFinite(o.seq) ? o.seq : 0,
|
|
1202
|
+
reason
|
|
1203
|
+
};
|
|
1204
|
+
};
|
|
1205
|
+
const settleAllControls = (reason) => {
|
|
1206
|
+
for (const pending of [...pendingControls.values()]) settleControl(pending, localResult(pending, reason));
|
|
1207
|
+
};
|
|
1208
|
+
const sendPendingControl = (pending, force = false) => {
|
|
1209
|
+
if (!force && !connected) return;
|
|
1210
|
+
const type = pending.operation === "claim" ? ClientMessage.CLAIM : pending.operation === "release" ? ClientMessage.RELEASE : ClientMessage.DESTROY;
|
|
1211
|
+
send0(type, pending.payload);
|
|
1212
|
+
};
|
|
1213
|
+
const confirmedControl = (operation, id, options2 = {}) => {
|
|
1214
|
+
const key = String(id);
|
|
1215
|
+
const requestId = `${room.sessionId.slice(0, 12)}:${Date.now().toString(36)}:${(++requestCounter).toString(36)}`;
|
|
1216
|
+
const timeoutMs = Math.max(250, Math.min(options2.timeoutMs ?? CONTROL_TIMEOUT_MS, CONTROL_TIMEOUT_MAX_MS));
|
|
1217
|
+
return new Promise((resolve) => {
|
|
1218
|
+
const pending = {
|
|
1219
|
+
operation,
|
|
1220
|
+
id: key,
|
|
1221
|
+
requestId,
|
|
1222
|
+
payload: {
|
|
1223
|
+
id: key,
|
|
1224
|
+
requestId,
|
|
1225
|
+
...options2.authority === "host" ? { authority: "host" } : {}
|
|
1226
|
+
},
|
|
1227
|
+
optimistic: operation === "claim" && options2.optimistic === true,
|
|
1228
|
+
resolve,
|
|
1229
|
+
timeout: null,
|
|
1230
|
+
abort: void 0
|
|
1231
|
+
};
|
|
1232
|
+
if (intentionalLeave || dead) {
|
|
1233
|
+
resolve(localResult(pending, intentionalLeave ? "aborted" : "disconnected"));
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
if (pending.optimistic) objOwners.set(key, room.sessionId);
|
|
1237
|
+
pending.timeout = setTimeout(() => settleControl(pending, localResult(pending, "timeout")), timeoutMs);
|
|
1238
|
+
if (options2.signal) {
|
|
1239
|
+
const onAbort = () => settleControl(pending, localResult(pending, "aborted"));
|
|
1240
|
+
options2.signal.addEventListener("abort", onAbort, { once: true });
|
|
1241
|
+
pending.abort = () => options2.signal?.removeEventListener("abort", onAbort);
|
|
1242
|
+
}
|
|
1243
|
+
pendingControls.set(requestId, pending);
|
|
1244
|
+
if (options2.signal?.aborted) {
|
|
1245
|
+
settleControl(pending, localResult(pending, "aborted"));
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
sendPendingControl(pending);
|
|
1249
|
+
});
|
|
1250
|
+
};
|
|
1251
|
+
let droppedWhileOffline = 0;
|
|
1252
|
+
const send0 = (type, payload, countIfDropped = false) => {
|
|
1253
|
+
if (room.connection?.isOpen) {
|
|
1254
|
+
try {
|
|
1255
|
+
room.send(type, payload);
|
|
1256
|
+
return true;
|
|
1257
|
+
} catch {
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
if (countIfDropped) droppedWhileOffline++;
|
|
1261
|
+
return false;
|
|
1262
|
+
};
|
|
1263
|
+
let lastDataText;
|
|
1264
|
+
let lastDataSentAt = 0;
|
|
1265
|
+
let velocity;
|
|
1266
|
+
const warnedPoseKeys = /* @__PURE__ */ new Set();
|
|
1267
|
+
const publishPosed = (clean, snap) => {
|
|
1268
|
+
const { pose, data, invalid } = splitPose(clean);
|
|
1269
|
+
for (const k of invalid) {
|
|
1270
|
+
if (warnedPoseKeys.has(k)) continue;
|
|
1271
|
+
warnedPoseKeys.add(k);
|
|
1272
|
+
console.warn(
|
|
1273
|
+
`[multiplayer] me.set: '${k}' is a typed pose key (pose: true) and its value is not a ` + (k === "anim" ? `string of \u2264${Limits.MAX_ANIM_CHARS} chars` : "finite number") + " \u2014 it is not sent."
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
if (!hasPose(pose)) return false;
|
|
1277
|
+
const t = now();
|
|
1278
|
+
const derived = deriveVelocity(pose, t, snap ? void 0 : velocity);
|
|
1279
|
+
velocity = derived.memory;
|
|
1280
|
+
const text = JSON.stringify(data);
|
|
1281
|
+
const dataDue = snap || text !== lastDataText || t - lastDataSentAt >= POSE_DATA_KEEPALIVE_MS;
|
|
1282
|
+
if (dataDue && send0(ClientMessage.STATE, JSON.stringify({ ...data, [SEND_TIME_KEY]: t }))) {
|
|
1283
|
+
lastDataText = text;
|
|
1284
|
+
lastDataSentAt = t;
|
|
1285
|
+
} else if (dataDue) {
|
|
1286
|
+
lastDataText = void 0;
|
|
1287
|
+
}
|
|
1288
|
+
if (!send0(snap ? ClientMessage.POSE_SNAP : ClientMessage.POSE, { ...derived.pose, t })) lastDataText = void 0;
|
|
1289
|
+
return true;
|
|
1290
|
+
};
|
|
1291
|
+
const voice = new VoiceMesh({
|
|
1292
|
+
selfId: () => room.sessionId,
|
|
1293
|
+
nameOf: (id) => room.state?.players?.get(id)?.name ?? "",
|
|
1294
|
+
sendSignal: (to, data) => send0(ClientMessage.VOICE_SIGNAL, { to, data }, true)
|
|
1295
|
+
});
|
|
1296
|
+
voice.onChange(() => emit("voice", voice.participants));
|
|
1297
|
+
let iceRefreshTimer = null;
|
|
1298
|
+
const ice = createIceRequester(() => send0(ClientMessage.VOICE_ICE_REQUEST, {}, true), ICE_REQUEST_TIMEOUT_MS);
|
|
1299
|
+
const clearIceRefresh = () => {
|
|
1300
|
+
if (iceRefreshTimer) clearTimeout(iceRefreshTimer);
|
|
1301
|
+
iceRefreshTimer = null;
|
|
1302
|
+
};
|
|
1303
|
+
const scheduleIceRefresh = (ttl) => {
|
|
1304
|
+
clearIceRefresh();
|
|
1305
|
+
if (typeof ttl !== "number" || !Number.isFinite(ttl) || ttl <= 0) return;
|
|
1306
|
+
iceRefreshTimer = setTimeout(() => {
|
|
1307
|
+
if (!voice.active) return;
|
|
1308
|
+
void ice.request().then((cfg) => {
|
|
1309
|
+
if (!voice.active || !cfg?.iceServers?.length) return;
|
|
1310
|
+
voice.setIceServers(cfg.iceServers);
|
|
1311
|
+
scheduleIceRefresh(cfg.ttl);
|
|
1312
|
+
});
|
|
1313
|
+
}, Math.max(6e4, Math.floor(ttl * 800)));
|
|
1314
|
+
};
|
|
1315
|
+
const chatHistory = [];
|
|
1316
|
+
const chatSeen = /* @__PURE__ */ new Set();
|
|
1317
|
+
const chatListeners = /* @__PURE__ */ new Set();
|
|
1318
|
+
const chatMuted = /* @__PURE__ */ new Set();
|
|
1319
|
+
const isChatMessage = (m) => !!m && typeof m === "object" && typeof m.id === "number" && typeof m.from === "string" && typeof m.name === "string" && typeof m.text === "string";
|
|
1320
|
+
const acceptChat = (message) => {
|
|
1321
|
+
if (!isChatMessage(message)) return;
|
|
1322
|
+
if (chatSeen.has(message.id)) return;
|
|
1323
|
+
if (chatMuted.has(message.from)) return;
|
|
1324
|
+
chatSeen.add(message.id);
|
|
1325
|
+
const last = chatHistory[chatHistory.length - 1];
|
|
1326
|
+
if (!last || message.id > last.id) chatHistory.push(message);
|
|
1327
|
+
else {
|
|
1328
|
+
let i = chatHistory.length;
|
|
1329
|
+
while (i > 0 && (chatHistory[i - 1]?.id ?? 0) > message.id) i--;
|
|
1330
|
+
chatHistory.splice(i, 0, message);
|
|
1331
|
+
}
|
|
1332
|
+
for (const cb of [...chatListeners]) {
|
|
1333
|
+
try {
|
|
1334
|
+
cb(message);
|
|
1335
|
+
} catch (err) {
|
|
1336
|
+
console.error("[multiplayer] a chat listener threw", err);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
};
|
|
1340
|
+
const $ = getStateCallbacks(room);
|
|
1341
|
+
if (!$) throw new Error("genex multiplayer: the relay room has no schema state");
|
|
1342
|
+
const applyPlayer = (id, player, joining) => {
|
|
1343
|
+
const self = id === room.sessionId;
|
|
1344
|
+
const { state: data, sendTime } = parseData(player.data);
|
|
1345
|
+
const epoch = Number.isFinite(player.epoch) ? player.epoch : 0;
|
|
1346
|
+
const epochChanged = playerEpochs.has(id) && epoch !== playerEpochs.get(id);
|
|
1347
|
+
playerEpochs.set(id, epoch);
|
|
1348
|
+
const dataChanged = joining || player.data !== playerData.get(id);
|
|
1349
|
+
playerData.set(id, player.data);
|
|
1350
|
+
const poseT = Number.isFinite(player.poseT) ? player.poseT : 0;
|
|
1351
|
+
const poseChanged = poseT !== (playerPoseT.get(id) ?? 0);
|
|
1352
|
+
playerPoseT.set(id, poseT);
|
|
1353
|
+
const posed = player.posed === true;
|
|
1354
|
+
if (!self && posed && !posedStreams.has(id)) {
|
|
1355
|
+
clearStream(id);
|
|
1356
|
+
posedStreams.add(id);
|
|
1357
|
+
posedData.set(id, new SnappedData());
|
|
1358
|
+
}
|
|
1359
|
+
if (!self && epochChanged) {
|
|
1360
|
+
clearStream(id);
|
|
1361
|
+
if (predictAttached.has(id)) detachPredict(id);
|
|
1362
|
+
}
|
|
1363
|
+
emit(joining ? "join" : "change", id, posed ? { ...data, ...readPose(player) } : data);
|
|
1364
|
+
if (self) return;
|
|
1365
|
+
if (posed) {
|
|
1366
|
+
const snapped = posedData.get(id);
|
|
1367
|
+
if (dataChanged || epochChanged) {
|
|
1368
|
+
snapped.push(sendTime, data);
|
|
1369
|
+
if (epochChanged || predictCfg) snapped.flush();
|
|
1370
|
+
}
|
|
1371
|
+
if (predictCfg) {
|
|
1372
|
+
if (!predictAttached.has(id)) attachPredict(id, player);
|
|
1373
|
+
} else if (poseT !== 0 && (poseChanged || epochChanged)) {
|
|
1374
|
+
pushSnap(id, readPose(player), poseT, id);
|
|
1375
|
+
}
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
if ((dataChanged || epochChanged) && seedsInterpolation(id, room.sessionId, sendTime)) {
|
|
1379
|
+
pushSnap(id, data, sendTime, id);
|
|
1380
|
+
}
|
|
1381
|
+
};
|
|
1382
|
+
$(room.state).players.onAdd((player, id) => {
|
|
1383
|
+
applyPlayer(id, player, true);
|
|
1384
|
+
$(player).onChange(() => applyPlayer(id, player, false));
|
|
1385
|
+
});
|
|
1386
|
+
const dropPlayer = (id) => {
|
|
1387
|
+
emit("leave", id);
|
|
1388
|
+
clearStream(id);
|
|
1389
|
+
clearSenderClock(id);
|
|
1390
|
+
detachPredict(id);
|
|
1391
|
+
parseCache.delete(id);
|
|
1392
|
+
playerEpochs.delete(id);
|
|
1393
|
+
playerData.delete(id);
|
|
1394
|
+
playerPoseT.delete(id);
|
|
1395
|
+
posedStreams.delete(id);
|
|
1396
|
+
posedData.delete(id);
|
|
1397
|
+
};
|
|
1398
|
+
$(room.state).players.onRemove((_player, id) => dropPlayer(id));
|
|
1399
|
+
$(room.state).shared.onAdd(handleShared);
|
|
1400
|
+
$(room.state).shared.onChange(handleShared);
|
|
1401
|
+
$(room.state).objects.onAdd((obj, id) => {
|
|
1402
|
+
applyObject(id, obj);
|
|
1403
|
+
$(obj).onChange(() => applyObject(id, obj));
|
|
1404
|
+
});
|
|
1405
|
+
$(room.state).objects.onRemove((_obj, id) => dropObject(id));
|
|
1406
|
+
$(room.state).listen("host", (value) => applyHost(value));
|
|
1407
|
+
$(room.state).voice.onAdd(applyVoiceRoster);
|
|
1408
|
+
$(room.state).voice.onRemove(applyVoiceRoster);
|
|
1409
|
+
const reconcileWithState = () => {
|
|
1410
|
+
const state = room.state;
|
|
1411
|
+
if (!state) return;
|
|
1412
|
+
for (const id of /* @__PURE__ */ new Set([...objOwners.keys(), ...objRaw.keys(), ...objSeqs.keys()])) {
|
|
1413
|
+
if (!state.objects?.has(id)) dropObject(id);
|
|
1414
|
+
}
|
|
1415
|
+
for (const id of [...playerEpochs.keys()]) {
|
|
1416
|
+
if (!state.players?.has(id)) dropPlayer(id);
|
|
1417
|
+
}
|
|
1418
|
+
applyHost(state.host);
|
|
1419
|
+
applyVoiceRoster();
|
|
1420
|
+
};
|
|
1421
|
+
room.onMessage(ServerMessage.VOICE_SIGNAL, (msg) => {
|
|
1422
|
+
if (msg && typeof msg.from === "string") void voice.handleSignal(msg.from, msg.data);
|
|
1423
|
+
});
|
|
1424
|
+
room.onMessage(ServerMessage.VOICE_REJECTED, (msg) => {
|
|
1425
|
+
clearIceRefresh();
|
|
1426
|
+
voice.stop();
|
|
1427
|
+
emit("voice:rejected", msg ?? { reason: "full" });
|
|
1428
|
+
});
|
|
1429
|
+
room.onMessage(ServerMessage.VOICE_ICE, (msg) => {
|
|
1430
|
+
ice.deliver(msg ?? null);
|
|
1431
|
+
});
|
|
1432
|
+
room.onMessage(ServerMessage.CHAT, (msg) => acceptChat(msg));
|
|
1433
|
+
room.onMessage(ServerMessage.CHAT_HISTORY, (msg) => {
|
|
1434
|
+
if (!Array.isArray(msg?.messages)) return;
|
|
1435
|
+
for (const m of msg.messages) acceptChat(m);
|
|
1436
|
+
});
|
|
1437
|
+
room.onMessage(ServerMessage.OBJECT_RESULT, (msg) => {
|
|
1438
|
+
if (!msg || typeof msg.requestId !== "string") return;
|
|
1439
|
+
const pending = pendingControls.get(msg.requestId);
|
|
1440
|
+
if (!pending || msg.operation !== pending.operation || msg.id !== pending.id) return;
|
|
1441
|
+
settleControl(pending, msg);
|
|
1442
|
+
});
|
|
1443
|
+
room.onMessage(ServerMessage.INPUT, (msg) => {
|
|
1444
|
+
if (msg && typeof msg.from === "string") emit(ServerMessage.INPUT, msg.from, msg.d);
|
|
1445
|
+
});
|
|
1446
|
+
room.onMessage(ServerMessage.ROOM_OPEN, (msg) => {
|
|
1447
|
+
if (typeof msg?.open === "boolean") emit("room:open", { open: msg.open });
|
|
1448
|
+
});
|
|
1449
|
+
room.onMessage(ServerMessage.RATE_DROP, (msg) => {
|
|
1450
|
+
console.warn(
|
|
1451
|
+
`[multiplayer] the relay dropped ${msg?.dropped ?? "some"} of your messages in the last second \u2014 you are publishing over budget. Publish idle/unchanged objects at \u22642Hz keepalive, not every tick (see the multiplayer skill's "message budget" section).`
|
|
1452
|
+
);
|
|
1453
|
+
});
|
|
1454
|
+
room.onMessage(ServerMessage.SERVER_RESTART, () => {
|
|
1455
|
+
});
|
|
1456
|
+
send0(ClientMessage.CHAT_HISTORY_REQUEST, {});
|
|
1457
|
+
const heartbeatHandle = heartbeatHandleOf(room);
|
|
1458
|
+
const heartbeat = heartbeatHandle ? startHeartbeat(heartbeatHandle, () => !dead && !intentionalLeave) : void 0;
|
|
1459
|
+
const serverNow = () => heartbeat ? room.clock.serverNow() : void 0;
|
|
1460
|
+
const timeUntil = (epochMs) => msUntil(epochMs, {
|
|
1461
|
+
synced: heartbeat?.synced === true,
|
|
1462
|
+
clockOrigin: Number.isFinite(room.state?.clockOrigin) ? room.state?.clockOrigin : 0,
|
|
1463
|
+
serverNow: serverNow(),
|
|
1464
|
+
wallNow: Date.now()
|
|
1465
|
+
});
|
|
1466
|
+
const stopTimers = () => {
|
|
1467
|
+
heartbeat?.stop();
|
|
1468
|
+
predict?.dispose();
|
|
1469
|
+
predict = void 0;
|
|
1470
|
+
predictAttached.clear();
|
|
1471
|
+
};
|
|
1472
|
+
room.onDrop((code) => {
|
|
1473
|
+
if (intentionalLeave || dead) return;
|
|
1474
|
+
dropGeneration++;
|
|
1475
|
+
if (!reconnecting) {
|
|
1476
|
+
reconnecting = true;
|
|
1477
|
+
reconnectAttempt = 0;
|
|
1478
|
+
reconnectStartedAt = nowMs();
|
|
1479
|
+
}
|
|
1480
|
+
connected = false;
|
|
1481
|
+
syncHostTicks();
|
|
1482
|
+
const { isReconnecting, retryCount, maxRetries } = room.reconnection;
|
|
1483
|
+
if (isReconnecting && retryCount >= maxRetries) return;
|
|
1484
|
+
emit("reconnecting", { attempt: ++reconnectAttempt, code });
|
|
1485
|
+
});
|
|
1486
|
+
room.onReconnect(() => {
|
|
1487
|
+
if (dead) return;
|
|
1488
|
+
if (intentionalLeave) {
|
|
1489
|
+
void room.leave().catch(() => {
|
|
1490
|
+
});
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
const generation = dropGeneration;
|
|
1494
|
+
const gap = reconnectStartedAt ? nowMs() - reconnectStartedAt : 0;
|
|
1495
|
+
if (gap > REBASE_AFTER_GAP_MS) {
|
|
1496
|
+
for (const streamKey of [...buffers.keys()]) clearStream(streamKey);
|
|
1497
|
+
for (const sender of [...senderClocks.keys()]) clearSenderClock(sender);
|
|
1498
|
+
}
|
|
1499
|
+
void (async () => {
|
|
1500
|
+
await waitForFirstState(room, ClientReconnection.firstStateTimeoutMs);
|
|
1501
|
+
if (generation !== dropGeneration || dead || intentionalLeave) return;
|
|
1502
|
+
reconcileWithState();
|
|
1503
|
+
for (const pending of [...pendingControls.values()]) sendPendingControl(pending, true);
|
|
1504
|
+
send0(ClientMessage.CHAT_HISTORY_REQUEST, {});
|
|
1505
|
+
reconnecting = false;
|
|
1506
|
+
reconnectStartedAt = 0;
|
|
1507
|
+
connected = true;
|
|
1508
|
+
syncHostTicks();
|
|
1509
|
+
emit("reconnected");
|
|
1510
|
+
if (droppedWhileOffline > 0) {
|
|
1511
|
+
console.warn(
|
|
1512
|
+
`[multiplayer] ${droppedWhileOffline} fire-and-forget event(s)/input(s) were dropped while reconnecting \u2014 they are not buffered; player/object state re-synced automatically.`
|
|
1513
|
+
);
|
|
1514
|
+
droppedWhileOffline = 0;
|
|
1515
|
+
}
|
|
1516
|
+
})();
|
|
1517
|
+
});
|
|
1518
|
+
room.onLeave((code) => {
|
|
1519
|
+
if (dead) return;
|
|
1520
|
+
dead = true;
|
|
1521
|
+
connected = false;
|
|
1522
|
+
reconnecting = false;
|
|
1523
|
+
stopTimers();
|
|
1524
|
+
syncHostTicks();
|
|
1525
|
+
if (intentionalLeave) return;
|
|
1526
|
+
settleAllControls("disconnected");
|
|
1527
|
+
emit("disconnect", code ?? 1006);
|
|
1528
|
+
});
|
|
1529
|
+
room.onError(() => {
|
|
1530
|
+
});
|
|
1531
|
+
const on = ((event, cb) => {
|
|
1532
|
+
let set = emitters.get(event);
|
|
1533
|
+
if (!set) emitters.set(event, set = /* @__PURE__ */ new Set());
|
|
1534
|
+
set.add(cb);
|
|
1535
|
+
if (!LOCAL_EVENTS.has(event) && !SDK_CHANNELS.has(event) && !bridged.has(event)) {
|
|
1536
|
+
bridged.add(event);
|
|
1537
|
+
room.onMessage(event, (msg) => emit(event, msg));
|
|
1538
|
+
}
|
|
1539
|
+
return () => {
|
|
1540
|
+
set?.delete(cb);
|
|
1541
|
+
};
|
|
1542
|
+
});
|
|
1543
|
+
const session = {
|
|
1544
|
+
id: room.sessionId,
|
|
1545
|
+
get players() {
|
|
1546
|
+
const out = /* @__PURE__ */ new Map();
|
|
1547
|
+
const localNow = now();
|
|
1548
|
+
if (predict && predictAttached.size > 0) predict.tick(nowMs());
|
|
1549
|
+
room.state?.players?.forEach((p, id) => {
|
|
1550
|
+
const latest = cachedParse(id, p.data);
|
|
1551
|
+
let state = latest;
|
|
1552
|
+
let raw = latest;
|
|
1553
|
+
const snapped = posedData.get(id);
|
|
1554
|
+
if (id !== room.sessionId && snapped) {
|
|
1555
|
+
const rawPose = readPose(p);
|
|
1556
|
+
raw = { ...latest, ...rawPose };
|
|
1557
|
+
if (predictAttached.has(id)) {
|
|
1558
|
+
state = { ...snapped.at(void 0) ?? latest, ...predictedPose(p) };
|
|
1559
|
+
} else {
|
|
1560
|
+
const pose = sampleSmoothed(id, localNow) ?? rawPose;
|
|
1561
|
+
state = { ...snapped.at(renderTimeOf(id, localNow)) ?? latest, ...pose };
|
|
1562
|
+
}
|
|
1563
|
+
} else if (id === room.sessionId) {
|
|
1564
|
+
if (localState) {
|
|
1565
|
+
state = localState;
|
|
1566
|
+
raw = localState;
|
|
1567
|
+
}
|
|
1568
|
+
} else {
|
|
1569
|
+
state = sampleSmoothed(id, localNow) ?? latest;
|
|
1570
|
+
}
|
|
1571
|
+
out.set(id, {
|
|
1572
|
+
id,
|
|
1573
|
+
name: p.name ?? "",
|
|
1574
|
+
avatarUrl: p.avatarUrl ?? "",
|
|
1575
|
+
connected: p.connected !== false,
|
|
1576
|
+
uid: p.uid ?? "",
|
|
1577
|
+
team: Number.isFinite(p.team) ? p.team : -1,
|
|
1578
|
+
state,
|
|
1579
|
+
stateRaw: raw
|
|
1580
|
+
});
|
|
1581
|
+
});
|
|
1582
|
+
return out;
|
|
1583
|
+
},
|
|
1584
|
+
get activePlayers() {
|
|
1585
|
+
return new Map([...session.players].filter(([, player]) => player.connected));
|
|
1586
|
+
},
|
|
1587
|
+
me: {
|
|
1588
|
+
set(state) {
|
|
1589
|
+
if (SEND_TIME_KEY in state) {
|
|
1590
|
+
console.warn(`[multiplayer] '${SEND_TIME_KEY}' is a reserved field name and will be overwritten by the send timestamp \u2014 rename it.`);
|
|
1591
|
+
}
|
|
1592
|
+
const clean = sanitizeFinite(state);
|
|
1593
|
+
localState = clean;
|
|
1594
|
+
if (options.pose && publishPosed(clean, false)) return;
|
|
1595
|
+
send0(ClientMessage.STATE, JSON.stringify({ ...clean, [SEND_TIME_KEY]: now() }));
|
|
1596
|
+
},
|
|
1597
|
+
snap(state) {
|
|
1598
|
+
if (SEND_TIME_KEY in state) {
|
|
1599
|
+
console.warn(`[multiplayer] '${SEND_TIME_KEY}' is a reserved field name and will be overwritten by the send timestamp \u2014 rename it.`);
|
|
1600
|
+
}
|
|
1601
|
+
const clean = sanitizeFinite(state);
|
|
1602
|
+
localState = clean;
|
|
1603
|
+
if (options.pose && publishPosed(clean, true)) return;
|
|
1604
|
+
send0(ClientMessage.STATE_SNAP, JSON.stringify({ ...clean, [SEND_TIME_KEY]: now() }));
|
|
1605
|
+
}
|
|
1606
|
+
},
|
|
1607
|
+
shared: {
|
|
1608
|
+
get(key) {
|
|
1609
|
+
return parseValue(room.state?.shared?.get(key));
|
|
1610
|
+
},
|
|
1611
|
+
set(key, value) {
|
|
1612
|
+
if (key.startsWith(RESERVED_SHARED_PREFIX)) return;
|
|
1613
|
+
send0(ClientMessage.SHARED, { key, value: JSON.stringify(sanitizeFinite(value)) });
|
|
1614
|
+
},
|
|
1615
|
+
keys() {
|
|
1616
|
+
return room.state?.shared ? Array.from(room.state.shared.keys()) : [];
|
|
1617
|
+
}
|
|
1618
|
+
},
|
|
1619
|
+
objects: {
|
|
1620
|
+
claim(id) {
|
|
1621
|
+
void confirmedControl("claim", id, { optimistic: true });
|
|
1622
|
+
},
|
|
1623
|
+
claimConfirmed(id, options2) {
|
|
1624
|
+
return confirmedControl("claim", id, options2);
|
|
1625
|
+
},
|
|
1626
|
+
release(id) {
|
|
1627
|
+
const key = String(id);
|
|
1628
|
+
localObjState.delete(key);
|
|
1629
|
+
if (objOwners.get(key) === room.sessionId) objOwners.delete(key);
|
|
1630
|
+
clearStreamClock(OBJ_STREAM + key);
|
|
1631
|
+
void confirmedControl("release", key);
|
|
1632
|
+
},
|
|
1633
|
+
releaseConfirmed(id, options2) {
|
|
1634
|
+
return confirmedControl("release", id, options2);
|
|
1635
|
+
},
|
|
1636
|
+
remove(id) {
|
|
1637
|
+
const key = String(id);
|
|
1638
|
+
if (objOwners.get(key) === room.sessionId) {
|
|
1639
|
+
localObjState.delete(key);
|
|
1640
|
+
objRaw.delete(key);
|
|
1641
|
+
objOwners.delete(key);
|
|
1642
|
+
clearStream(OBJ_STREAM + key);
|
|
1643
|
+
}
|
|
1644
|
+
void confirmedControl("remove", key);
|
|
1645
|
+
},
|
|
1646
|
+
removeConfirmed(id, options2) {
|
|
1647
|
+
return confirmedControl("remove", id, options2);
|
|
1648
|
+
},
|
|
1649
|
+
set(id, state) {
|
|
1650
|
+
const key = String(id);
|
|
1651
|
+
if (rejectUnownedWrite("set", key)) return;
|
|
1652
|
+
const clean = sanitizeFinite(state);
|
|
1653
|
+
if (objOwners.get(key) === room.sessionId) localObjState.set(key, clean);
|
|
1654
|
+
send0(ClientMessage.OBJECT, { id: key, d: clean, t: now() });
|
|
1655
|
+
},
|
|
1656
|
+
snap(id, state) {
|
|
1657
|
+
const key = String(id);
|
|
1658
|
+
if (rejectUnownedWrite("snap", key)) return;
|
|
1659
|
+
const clean = sanitizeFinite(state);
|
|
1660
|
+
if (objOwners.get(key) === room.sessionId) localObjState.set(key, clean);
|
|
1661
|
+
send0(ClientMessage.OBJECT_SNAP, { id: key, d: clean, t: now() });
|
|
1662
|
+
},
|
|
1663
|
+
get(id) {
|
|
1664
|
+
const key = String(id);
|
|
1665
|
+
const owner = objOwners.get(key);
|
|
1666
|
+
const mine = localObjState.get(key);
|
|
1667
|
+
if (owner === void 0 && !objRaw.has(key) && !mine) return void 0;
|
|
1668
|
+
const isMine = owner !== void 0 && owner === room.sessionId;
|
|
1669
|
+
let state;
|
|
1670
|
+
let raw;
|
|
1671
|
+
if (isMine && mine) {
|
|
1672
|
+
state = mine;
|
|
1673
|
+
raw = mine;
|
|
1674
|
+
smoothed.set(OBJ_STREAM + key, mine);
|
|
1675
|
+
smoothedAt.set(OBJ_STREAM + key, now());
|
|
1676
|
+
} else {
|
|
1677
|
+
raw = objRaw.get(key) ?? {};
|
|
1678
|
+
state = sampleSmoothed(OBJ_STREAM + key, now()) ?? raw;
|
|
1679
|
+
}
|
|
1680
|
+
return {
|
|
1681
|
+
id: key,
|
|
1682
|
+
epoch: objEpochs.get(key) ?? 0,
|
|
1683
|
+
owner,
|
|
1684
|
+
isMine,
|
|
1685
|
+
state,
|
|
1686
|
+
stateRaw: raw
|
|
1687
|
+
};
|
|
1688
|
+
},
|
|
1689
|
+
ids() {
|
|
1690
|
+
return [.../* @__PURE__ */ new Set([...objOwners.keys(), ...objRaw.keys(), ...localObjState.keys()])];
|
|
1691
|
+
}
|
|
1692
|
+
},
|
|
1693
|
+
// Gated like onHostTick (PROTOCOL.md §6.3): between a reconnect's socket opening and its first
|
|
1694
|
+
// snapshot, `hostId` still holds the pre-drop value, so a failed-over ex-host would otherwise
|
|
1695
|
+
// read true for a round trip and write stale host facts into `shared`.
|
|
1696
|
+
get isHost() {
|
|
1697
|
+
return connected && !intentionalLeave && !dead && hostId !== void 0 && hostId === room.sessionId;
|
|
1698
|
+
},
|
|
1699
|
+
get host() {
|
|
1700
|
+
return hostId;
|
|
1701
|
+
},
|
|
1702
|
+
inputs: {
|
|
1703
|
+
send(payload) {
|
|
1704
|
+
send0(ClientMessage.INPUT, sanitizeFinite(payload), true);
|
|
1705
|
+
},
|
|
1706
|
+
on(cb) {
|
|
1707
|
+
let set = emitters.get(ServerMessage.INPUT);
|
|
1708
|
+
if (!set) emitters.set(ServerMessage.INPUT, set = /* @__PURE__ */ new Set());
|
|
1709
|
+
const listener = (from, payload) => cb(from, payload);
|
|
1710
|
+
set.add(listener);
|
|
1711
|
+
return () => void set?.delete(listener);
|
|
1712
|
+
}
|
|
1713
|
+
},
|
|
1714
|
+
onHostTick(hz, cb) {
|
|
1715
|
+
let timer = null;
|
|
1716
|
+
let last = 0;
|
|
1717
|
+
const safeHz = Number.isFinite(hz) && hz > 0 ? Math.min(hz, 100) : 20;
|
|
1718
|
+
const intervalMs = Math.max(10, Math.round(1e3 / safeHz));
|
|
1719
|
+
const sync = () => {
|
|
1720
|
+
const shouldRun = connected && !intentionalLeave && !dead && hostId !== void 0 && hostId === room.sessionId;
|
|
1721
|
+
if (shouldRun && !timer) {
|
|
1722
|
+
last = nowMs();
|
|
1723
|
+
timer = setInterval(() => {
|
|
1724
|
+
const t = nowMs();
|
|
1725
|
+
cb(Math.min(t - last, MAX_DT_MS));
|
|
1726
|
+
last = t;
|
|
1727
|
+
}, intervalMs);
|
|
1728
|
+
} else if (!shouldRun && timer) {
|
|
1729
|
+
clearInterval(timer);
|
|
1730
|
+
timer = null;
|
|
1731
|
+
}
|
|
1732
|
+
};
|
|
1733
|
+
const off = on("host", sync);
|
|
1734
|
+
hostTickSyncs.add(sync);
|
|
1735
|
+
sync();
|
|
1736
|
+
return () => {
|
|
1737
|
+
off();
|
|
1738
|
+
hostTickSyncs.delete(sync);
|
|
1739
|
+
if (timer) clearInterval(timer);
|
|
1740
|
+
timer = null;
|
|
1741
|
+
};
|
|
1742
|
+
},
|
|
1743
|
+
on,
|
|
1744
|
+
send(type, payload) {
|
|
1745
|
+
if (!isSendableEventType(type)) {
|
|
1746
|
+
console.warn(`[multiplayer] send('${type}') collides with a relay-internal channel \u2014 use a different event name`);
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
send0(type, payload, true);
|
|
1750
|
+
},
|
|
1751
|
+
chat: {
|
|
1752
|
+
send(text) {
|
|
1753
|
+
if (typeof text !== "string") return;
|
|
1754
|
+
if (!text.trim()) return;
|
|
1755
|
+
send0(ClientMessage.CHAT, { text }, true);
|
|
1756
|
+
},
|
|
1757
|
+
on(cb) {
|
|
1758
|
+
chatListeners.add(cb);
|
|
1759
|
+
return () => chatListeners.delete(cb);
|
|
1760
|
+
},
|
|
1761
|
+
get history() {
|
|
1762
|
+
return [...chatHistory];
|
|
1763
|
+
},
|
|
1764
|
+
mute(playerId) {
|
|
1765
|
+
if (typeof playerId === "string" && playerId) chatMuted.add(playerId);
|
|
1766
|
+
},
|
|
1767
|
+
unmute(playerId) {
|
|
1768
|
+
chatMuted.delete(playerId);
|
|
1769
|
+
},
|
|
1770
|
+
get muted() {
|
|
1771
|
+
return [...chatMuted];
|
|
1772
|
+
}
|
|
1773
|
+
},
|
|
1774
|
+
voice: {
|
|
1775
|
+
async join(options2) {
|
|
1776
|
+
await voice.start(options2, true);
|
|
1777
|
+
if (!options2?.iceServers?.length) {
|
|
1778
|
+
const cfg = await ice.request();
|
|
1779
|
+
if (voice.active && cfg?.iceServers?.length) {
|
|
1780
|
+
voice.setIceServers(cfg.iceServers);
|
|
1781
|
+
scheduleIceRefresh(cfg.ttl);
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
if (!voice.active) return;
|
|
1785
|
+
send0(ClientMessage.VOICE_JOIN, {}, true);
|
|
1786
|
+
voice.releasePeering();
|
|
1787
|
+
},
|
|
1788
|
+
leave() {
|
|
1789
|
+
clearIceRefresh();
|
|
1790
|
+
send0(ClientMessage.VOICE_LEAVE, {}, true);
|
|
1791
|
+
voice.stop();
|
|
1792
|
+
},
|
|
1793
|
+
get active() {
|
|
1794
|
+
return voice.active;
|
|
1795
|
+
},
|
|
1796
|
+
get participants() {
|
|
1797
|
+
return voice.participants;
|
|
1798
|
+
},
|
|
1799
|
+
setMicEnabled(on2) {
|
|
1800
|
+
voice.setMicEnabled(on2 === true);
|
|
1801
|
+
},
|
|
1802
|
+
get micEnabled() {
|
|
1803
|
+
return voice.micEnabled;
|
|
1804
|
+
},
|
|
1805
|
+
get speaking() {
|
|
1806
|
+
return voice.speaking;
|
|
1807
|
+
},
|
|
1808
|
+
mute(playerId) {
|
|
1809
|
+
voice.mute(playerId);
|
|
1810
|
+
},
|
|
1811
|
+
unmute(playerId) {
|
|
1812
|
+
voice.unmute(playerId);
|
|
1813
|
+
},
|
|
1814
|
+
setVolume(playerId, volume) {
|
|
1815
|
+
voice.setVolume(playerId, volume);
|
|
1816
|
+
},
|
|
1817
|
+
get muted() {
|
|
1818
|
+
return voice.participants.filter((p) => p.muted).map((p) => p.id);
|
|
1819
|
+
}
|
|
1820
|
+
},
|
|
1821
|
+
setRoomOpen(open) {
|
|
1822
|
+
send0(ClientMessage.ROOM_OPEN, { open: open === true });
|
|
1823
|
+
},
|
|
1824
|
+
leave() {
|
|
1825
|
+
intentionalLeave = true;
|
|
1826
|
+
stopTimers();
|
|
1827
|
+
voice.stop();
|
|
1828
|
+
clearIceRefresh();
|
|
1829
|
+
connected = false;
|
|
1830
|
+
syncHostTicks();
|
|
1831
|
+
settleAllControls("aborted");
|
|
1832
|
+
room.reconnection.enabled = false;
|
|
1833
|
+
void room.leave().catch(() => {
|
|
1834
|
+
});
|
|
1835
|
+
},
|
|
1836
|
+
serverNow,
|
|
1837
|
+
get rtt() {
|
|
1838
|
+
if (!heartbeat) return void 0;
|
|
1839
|
+
const rtt = room.clock.smoothedRtt();
|
|
1840
|
+
return rtt > 0 ? rtt : void 0;
|
|
1841
|
+
},
|
|
1842
|
+
get clockSynced() {
|
|
1843
|
+
return heartbeat?.synced === true;
|
|
1844
|
+
},
|
|
1845
|
+
timeUntil
|
|
1846
|
+
};
|
|
1847
|
+
Object.defineProperty(session, ROOM_REF, { value: () => room, enumerable: false });
|
|
1848
|
+
Object.defineProperty(session, /* @__PURE__ */ Symbol.for("genex.multiplayer.internals"), {
|
|
1849
|
+
enumerable: false,
|
|
1850
|
+
value: {
|
|
1851
|
+
senderOf: (streamKey) => streamSender.get(streamKey),
|
|
1852
|
+
offsetOf: (streamKey) => {
|
|
1853
|
+
const sender = streamSender.get(streamKey);
|
|
1854
|
+
return sender === void 0 ? void 0 : clockOffsets.get(sender);
|
|
1855
|
+
},
|
|
1856
|
+
offsetSamples: (sender) => senderClocks.get(sender)?.samples.length ?? 0,
|
|
1857
|
+
bufferLength: (streamKey) => buffers.get(streamKey)?.length ?? 0
|
|
1858
|
+
}
|
|
1859
|
+
});
|
|
1860
|
+
return session;
|
|
1861
|
+
}
|
|
1862
|
+
var SEARCH_BACKOFF_MS = [1e3, 2e3, 4e3];
|
|
1863
|
+
var matchJoinOptions = (opts) => joinOptions(opts.room, opts.name, opts.auth);
|
|
1864
|
+
function buildView(matchRoom, queueInfo, timeUntil = (epochMs) => epochMs - Date.now()) {
|
|
1865
|
+
const raw = matchRoom?.state?.match;
|
|
1866
|
+
const parsed = raw ? parseValue(raw) : null;
|
|
1867
|
+
const m = parsed && typeof parsed === "object" ? parsed : null;
|
|
1868
|
+
if (!m) {
|
|
1869
|
+
return {
|
|
1870
|
+
status: matchRoom ? "waiting" : "searching",
|
|
1871
|
+
role: "player",
|
|
1872
|
+
queue: queueInfo,
|
|
1873
|
+
players: [],
|
|
1874
|
+
connectedPlayers: [],
|
|
1875
|
+
opponents: [],
|
|
1876
|
+
teams: {},
|
|
1877
|
+
myTeam: null,
|
|
1878
|
+
scores: {},
|
|
1879
|
+
config: {},
|
|
1880
|
+
winCondition: "lastStanding",
|
|
1881
|
+
championId: null,
|
|
1882
|
+
challengerId: null,
|
|
1883
|
+
roundId: 0,
|
|
1884
|
+
winnerId: null,
|
|
1885
|
+
message: matchRoom ? "Waiting for players\u2026" : "Finding a match\u2026",
|
|
1886
|
+
startsAt: null,
|
|
1887
|
+
endsBy: null,
|
|
1888
|
+
startsInMs: null,
|
|
1889
|
+
endsInMs: null
|
|
1890
|
+
};
|
|
1891
|
+
}
|
|
1892
|
+
const myId = matchRoom?.sessionId;
|
|
1893
|
+
const championId = m.championId ?? null;
|
|
1894
|
+
const challengerId = m.challengerId ?? null;
|
|
1895
|
+
const teams = m.teams ?? {};
|
|
1896
|
+
const players = Array.isArray(m.players) ? m.players : [];
|
|
1897
|
+
const connectedPlayers = m.connectedPlayers ?? players;
|
|
1898
|
+
const isPlayer = myId != null && players.includes(myId);
|
|
1899
|
+
const status = isPlayer ? m.phase : "spectating";
|
|
1900
|
+
const opponents = players.filter((id) => id !== myId);
|
|
1901
|
+
const myTeam = myId ? Object.keys(teams).find((t) => teams[t]?.includes(myId)) ?? null : null;
|
|
1902
|
+
const { winCondition: wc, ...numericCfg } = m.cfg ?? {};
|
|
1903
|
+
const deadline = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : null;
|
|
1904
|
+
const startsAt = deadline(m.startsAt);
|
|
1905
|
+
const endsBy = deadline(m.endsBy);
|
|
1906
|
+
const remaining = (at) => at === null ? null : Math.max(0, timeUntil(at));
|
|
1907
|
+
return {
|
|
1908
|
+
status,
|
|
1909
|
+
role: isPlayer ? "player" : "spectator",
|
|
1910
|
+
queue: { position: 0, size: 0 },
|
|
1911
|
+
players,
|
|
1912
|
+
connectedPlayers,
|
|
1913
|
+
opponents,
|
|
1914
|
+
teams,
|
|
1915
|
+
myTeam,
|
|
1916
|
+
scores: m.scores ?? {},
|
|
1917
|
+
config: numericCfg,
|
|
1918
|
+
winCondition: wc?.type ?? "lastStanding",
|
|
1919
|
+
championId,
|
|
1920
|
+
challengerId,
|
|
1921
|
+
roundId: m.roundId ?? 0,
|
|
1922
|
+
winnerId: m.winnerId ?? null,
|
|
1923
|
+
message: m.message ?? "",
|
|
1924
|
+
startsAt,
|
|
1925
|
+
endsBy,
|
|
1926
|
+
startsInMs: remaining(startsAt),
|
|
1927
|
+
endsInMs: remaining(endsBy)
|
|
1928
|
+
};
|
|
1929
|
+
}
|
|
1930
|
+
function matchSend(room, type, payload) {
|
|
1931
|
+
if (!room?.connection?.isOpen) return;
|
|
1932
|
+
try {
|
|
1933
|
+
room.send(type, payload);
|
|
1934
|
+
} catch {
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
async function matchmake(opts) {
|
|
1938
|
+
const client = await relayClient(opts);
|
|
1939
|
+
const predict = await loadPredict(opts.smoothing);
|
|
1940
|
+
const emitters = /* @__PURE__ */ new Map();
|
|
1941
|
+
const emit = (event, ...args) => emitters.get(event)?.forEach((cb) => cb(...args));
|
|
1942
|
+
const on = ((event, cb) => {
|
|
1943
|
+
let set = emitters.get(event);
|
|
1944
|
+
if (!set) emitters.set(event, set = /* @__PURE__ */ new Set());
|
|
1945
|
+
set.add(cb);
|
|
1946
|
+
return () => void set?.delete(cb);
|
|
1947
|
+
});
|
|
1948
|
+
let session = null;
|
|
1949
|
+
let matchRoom = null;
|
|
1950
|
+
let queueRoom = null;
|
|
1951
|
+
let queueInfo = { position: 0, size: 0 };
|
|
1952
|
+
let cancelled = false;
|
|
1953
|
+
let searching = false;
|
|
1954
|
+
let lifecycleEpoch = 0;
|
|
1955
|
+
let seating = false;
|
|
1956
|
+
let requeueing = false;
|
|
1957
|
+
const search = async () => {
|
|
1958
|
+
if (cancelled || searching) return;
|
|
1959
|
+
searching = true;
|
|
1960
|
+
const epoch = ++lifecycleEpoch;
|
|
1961
|
+
try {
|
|
1962
|
+
session = null;
|
|
1963
|
+
matchRoom = null;
|
|
1964
|
+
for (let attempt = 0; ; attempt++) {
|
|
1965
|
+
if (queueRoom) {
|
|
1966
|
+
void queueRoom.leave();
|
|
1967
|
+
queueRoom = null;
|
|
1968
|
+
}
|
|
1969
|
+
queueInfo = { position: 0, size: 0 };
|
|
1970
|
+
let q;
|
|
1971
|
+
try {
|
|
1972
|
+
q = withoutReconnection(await client.joinOrCreate(RoomType.QUEUE, matchJoinOptions(opts)));
|
|
1973
|
+
} catch (err) {
|
|
1974
|
+
if (cancelled) return;
|
|
1975
|
+
const refused = isProtocolRefusal(err);
|
|
1976
|
+
if (refused) warnProtocolRefusal();
|
|
1977
|
+
const delay = refused ? void 0 : SEARCH_BACKOFF_MS[attempt];
|
|
1978
|
+
if (delay == null) {
|
|
1979
|
+
if (attempt === 0) await new Promise((r) => setTimeout(r, 0));
|
|
1980
|
+
if (cancelled) return;
|
|
1981
|
+
emit("error", err);
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
1985
|
+
if (cancelled) return;
|
|
1986
|
+
continue;
|
|
1987
|
+
}
|
|
1988
|
+
if (cancelled || epoch !== lifecycleEpoch) {
|
|
1989
|
+
void q.leave();
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
queueRoom = q;
|
|
1993
|
+
q.onMessage(ServerMessage.QUEUE, (msg) => {
|
|
1994
|
+
queueInfo = { position: msg.position, size: msg.size };
|
|
1995
|
+
emit("queue", msg);
|
|
1996
|
+
});
|
|
1997
|
+
q.onMessage(ServerMessage.SEAT, (msg) => {
|
|
1998
|
+
if (cancelled || epoch !== lifecycleEpoch || queueRoom !== q || seating) return;
|
|
1999
|
+
seating = true;
|
|
2000
|
+
queueRoom = null;
|
|
2001
|
+
void q.leave();
|
|
2002
|
+
void consumeSeat(msg.reservation, msg.role, epoch);
|
|
2003
|
+
});
|
|
2004
|
+
q.onLeave(() => {
|
|
2005
|
+
if (cancelled || queueRoom !== q) return;
|
|
2006
|
+
queueRoom = null;
|
|
2007
|
+
void (async () => {
|
|
2008
|
+
for (const delayMs of QUEUE_RESEARCH_DELAYS_MS) {
|
|
2009
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
2010
|
+
if (cancelled || queueRoom || session) return;
|
|
2011
|
+
await search();
|
|
2012
|
+
if (queueRoom || session) return;
|
|
2013
|
+
}
|
|
2014
|
+
})();
|
|
2015
|
+
});
|
|
2016
|
+
emit("searching");
|
|
2017
|
+
return;
|
|
2018
|
+
}
|
|
2019
|
+
} finally {
|
|
2020
|
+
searching = false;
|
|
2021
|
+
}
|
|
2022
|
+
};
|
|
2023
|
+
const consumeSeat = async (reservation, role, epoch) => {
|
|
2024
|
+
try {
|
|
2025
|
+
const mr = useSdkReconnection(await client.consumeSeatReservation(reservation));
|
|
2026
|
+
if (cancelled || epoch !== lifecycleEpoch) {
|
|
2027
|
+
void mr.leave();
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
matchRoom = mr;
|
|
2031
|
+
const s = attachSession(mr, { pose: opts.pose, smoothing: opts.smoothing, predict });
|
|
2032
|
+
session = s;
|
|
2033
|
+
s.on(ServerMessage.MATCH_START, (m) => emit("matchStart", m));
|
|
2034
|
+
s.on(ServerMessage.MATCH_ENDED, (m) => emit("matchEnded", m));
|
|
2035
|
+
s.on(ServerMessage.MATCH_REQUEUE, () => {
|
|
2036
|
+
if (session === s) void requeue();
|
|
2037
|
+
});
|
|
2038
|
+
s.on("disconnect", (code) => {
|
|
2039
|
+
if (cancelled || session !== s) return;
|
|
2040
|
+
if (code === CloseCode.SUPERSEDED || code === CloseCode.REVOKED) {
|
|
2041
|
+
lifecycleEpoch++;
|
|
2042
|
+
seating = false;
|
|
2043
|
+
matchRoom = null;
|
|
2044
|
+
session = null;
|
|
2045
|
+
emit("disconnect", code);
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
2048
|
+
void requeue();
|
|
2049
|
+
});
|
|
2050
|
+
if (queueRoom) {
|
|
2051
|
+
void queueRoom.leave();
|
|
2052
|
+
queueRoom = null;
|
|
2053
|
+
}
|
|
2054
|
+
emit("matched");
|
|
2055
|
+
if (role === "spectator") emit("spectate");
|
|
2056
|
+
} catch {
|
|
2057
|
+
if (!cancelled && epoch === lifecycleEpoch) {
|
|
2058
|
+
seating = false;
|
|
2059
|
+
void search();
|
|
2060
|
+
}
|
|
2061
|
+
} finally {
|
|
2062
|
+
if (epoch === lifecycleEpoch) seating = false;
|
|
2063
|
+
}
|
|
2064
|
+
};
|
|
2065
|
+
const requeue = async () => {
|
|
2066
|
+
if (cancelled || requeueing) return;
|
|
2067
|
+
requeueing = true;
|
|
2068
|
+
try {
|
|
2069
|
+
lifecycleEpoch++;
|
|
2070
|
+
seating = false;
|
|
2071
|
+
session?.leave();
|
|
2072
|
+
matchRoom = null;
|
|
2073
|
+
session = null;
|
|
2074
|
+
await search();
|
|
2075
|
+
} finally {
|
|
2076
|
+
requeueing = false;
|
|
2077
|
+
}
|
|
2078
|
+
};
|
|
2079
|
+
void search();
|
|
2080
|
+
const liveRoom = () => session ? roomOf(session) : null;
|
|
2081
|
+
let viewSession = null;
|
|
2082
|
+
let lastHydratedView = null;
|
|
2083
|
+
const stableView = () => {
|
|
2084
|
+
if (session !== viewSession) {
|
|
2085
|
+
viewSession = session;
|
|
2086
|
+
lastHydratedView = null;
|
|
2087
|
+
}
|
|
2088
|
+
const live = liveRoom();
|
|
2089
|
+
const hydrated = !!live?.state?.match;
|
|
2090
|
+
if (!hydrated && session && lastHydratedView) return lastHydratedView;
|
|
2091
|
+
const next = buildView(live, queueInfo, session ? session.timeUntil : void 0);
|
|
2092
|
+
if (hydrated) lastHydratedView = next;
|
|
2093
|
+
return next;
|
|
2094
|
+
};
|
|
2095
|
+
return {
|
|
2096
|
+
get session() {
|
|
2097
|
+
return session;
|
|
2098
|
+
},
|
|
2099
|
+
get matchmaking() {
|
|
2100
|
+
return stableView();
|
|
2101
|
+
},
|
|
2102
|
+
on,
|
|
2103
|
+
eliminated() {
|
|
2104
|
+
matchSend(liveRoom(), ClientMessage.MATCH_ELIMINATED, {});
|
|
2105
|
+
},
|
|
2106
|
+
score(delta = 1) {
|
|
2107
|
+
matchSend(liveRoom(), ClientMessage.MATCH_SCORE, { delta });
|
|
2108
|
+
},
|
|
2109
|
+
finish() {
|
|
2110
|
+
matchSend(liveRoom(), ClientMessage.MATCH_FINISH, {});
|
|
2111
|
+
},
|
|
2112
|
+
retry() {
|
|
2113
|
+
if (cancelled || searching || queueRoom || matchRoom) return;
|
|
2114
|
+
void search();
|
|
2115
|
+
},
|
|
2116
|
+
cancel() {
|
|
2117
|
+
cancelled = true;
|
|
2118
|
+
lifecycleEpoch++;
|
|
2119
|
+
seating = false;
|
|
2120
|
+
session?.leave();
|
|
2121
|
+
if (!session && matchRoom) void matchRoom.leave();
|
|
2122
|
+
if (queueRoom) void queueRoom.leave();
|
|
2123
|
+
matchRoom = null;
|
|
2124
|
+
queueRoom = null;
|
|
2125
|
+
session = null;
|
|
2126
|
+
}
|
|
2127
|
+
};
|
|
2128
|
+
}
|
|
2129
|
+
function wirePrivate(room, options) {
|
|
2130
|
+
const emitters = /* @__PURE__ */ new Map();
|
|
2131
|
+
const emit = (event, ...args) => emitters.get(event)?.forEach((cb) => cb(...args));
|
|
2132
|
+
const on = ((event, cb) => {
|
|
2133
|
+
let set = emitters.get(event);
|
|
2134
|
+
if (!set) emitters.set(event, set = /* @__PURE__ */ new Set());
|
|
2135
|
+
set.add(cb);
|
|
2136
|
+
return () => void set?.delete(cb);
|
|
2137
|
+
});
|
|
2138
|
+
let live = true;
|
|
2139
|
+
const session = attachSession(room, options);
|
|
2140
|
+
session.on(ServerMessage.MATCH_START, (m) => emit("matchStart", m));
|
|
2141
|
+
session.on(ServerMessage.MATCH_ENDED, (m) => emit("matchEnded", m));
|
|
2142
|
+
session.on(ServerMessage.MATCH_REQUEUE, () => void (live = false));
|
|
2143
|
+
session.on("disconnect", () => void (live = false));
|
|
2144
|
+
const liveRoom = () => live ? roomOf(session) : null;
|
|
2145
|
+
return {
|
|
2146
|
+
get session() {
|
|
2147
|
+
return live ? session : null;
|
|
2148
|
+
},
|
|
2149
|
+
get matchmaking() {
|
|
2150
|
+
return buildView(liveRoom(), { position: 0, size: 0 }, session.timeUntil);
|
|
2151
|
+
},
|
|
2152
|
+
on,
|
|
2153
|
+
eliminated() {
|
|
2154
|
+
matchSend(liveRoom(), ClientMessage.MATCH_ELIMINATED, {});
|
|
2155
|
+
},
|
|
2156
|
+
score(delta = 1) {
|
|
2157
|
+
matchSend(liveRoom(), ClientMessage.MATCH_SCORE, { delta });
|
|
2158
|
+
},
|
|
2159
|
+
finish() {
|
|
2160
|
+
matchSend(liveRoom(), ClientMessage.MATCH_FINISH, {});
|
|
2161
|
+
},
|
|
2162
|
+
retry() {
|
|
2163
|
+
},
|
|
2164
|
+
cancel() {
|
|
2165
|
+
session.leave();
|
|
2166
|
+
live = false;
|
|
2167
|
+
}
|
|
2168
|
+
};
|
|
2169
|
+
}
|
|
2170
|
+
async function createPrivate(opts) {
|
|
2171
|
+
const client = await relayClient(opts);
|
|
2172
|
+
const predict = await loadPredict(opts.smoothing);
|
|
2173
|
+
const room = useSdkReconnection(await client.create(RoomType.MATCH, matchJoinOptions(opts)));
|
|
2174
|
+
const handle = wirePrivate(room, { pose: opts.pose, smoothing: opts.smoothing, predict });
|
|
2175
|
+
Object.defineProperty(handle, "code", { value: room.roomId, enumerable: true });
|
|
2176
|
+
return handle;
|
|
2177
|
+
}
|
|
2178
|
+
async function joinPrivate(code, opts) {
|
|
2179
|
+
const client = await relayClient(opts);
|
|
2180
|
+
const predict = await loadPredict(opts.smoothing);
|
|
2181
|
+
const room = useSdkReconnection(await client.joinById(code, matchJoinOptions(opts)));
|
|
2182
|
+
return wirePrivate(room, { pose: opts.pose, smoothing: opts.smoothing, predict });
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
export {
|
|
2186
|
+
roomOf,
|
|
2187
|
+
connect,
|
|
2188
|
+
matchmake,
|
|
2189
|
+
createPrivate,
|
|
2190
|
+
joinPrivate
|
|
2191
|
+
};
|