@lokiplay/sdk 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,8 +1,75 @@
1
1
  import { ClientEnvelopeSchema, ServerEnvelopeSchema, PROTOCOL_VERSION, } from "@lokiplay/protocol";
2
2
  import { Client, Session } from "@heroiclabs/nakama-js";
3
+ import { SynchronizedRoom, } from "./synchronized-room.js";
4
+ export { SYNCHRONIZED_ROOM_ACTION_TTL_MS, SYNCHRONIZED_ROOM_COMMIT_TIMEOUT_MS, SYNCHRONIZED_ROOM_MAX_MESSAGE_BYTES, SYNCHRONIZED_ROOM_MAX_PENDING, SYNCHRONIZED_ROOM_MAX_RECENT_ACTIONS, SYNCHRONIZED_ROOM_MAX_REDUCER_MS, SynchronizedRoom, SynchronizedRoomError, } from "./synchronized-room.js";
3
5
  export const LOKI_API_ORIGIN = "https://api.lokiplay.cc";
4
6
  const textDecoder = new TextDecoder();
5
7
  const payload = (response) => response.payload;
8
+ export async function lokiErrorFromUnknown(error) {
9
+ if (typeof Response !== "undefined" && error instanceof Response) {
10
+ const status = error.status;
11
+ let detail = "";
12
+ try {
13
+ const text = await error.clone().text();
14
+ if (text) {
15
+ try {
16
+ const parsed = JSON.parse(text);
17
+ detail = parsed.message || parsed.error || text;
18
+ }
19
+ catch {
20
+ detail = text;
21
+ }
22
+ }
23
+ }
24
+ catch {
25
+ // The body may already have been consumed by the transport.
26
+ }
27
+ return new Error(detail
28
+ ? `Loki request failed (${status}): ${detail}`
29
+ : `Loki request failed (${status})`);
30
+ }
31
+ if (error instanceof Error) {
32
+ if (error.message === "[object Response]") {
33
+ return new Error("Loki request failed");
34
+ }
35
+ return error;
36
+ }
37
+ if (error && typeof error === "object" && "status" in error) {
38
+ const status = Number(error.status);
39
+ return new Error(Number.isFinite(status) ? `Loki request failed (${status})` : "Loki request failed");
40
+ }
41
+ return new Error("Loki request failed");
42
+ }
43
+ const notifyListeners = (listeners, value) => {
44
+ for (const listener of listeners) {
45
+ try {
46
+ listener(value);
47
+ }
48
+ catch {
49
+ // Listener exceptions must not prevent remaining subscribers from running.
50
+ }
51
+ }
52
+ };
53
+ const protocolRejectionMessage = (message) => {
54
+ const text = message.trim() || "action rejected";
55
+ return text.length > 200 ? text.slice(0, 200) : text;
56
+ };
57
+ const wrapLokiCall = async (operation) => {
58
+ try {
59
+ return await operation();
60
+ }
61
+ catch (error) {
62
+ throw await lokiErrorFromUnknown(error);
63
+ }
64
+ };
65
+ const normalizeInviteCode = (value) => value.trim().toUpperCase();
66
+ const requireInviteCode = (value) => {
67
+ const inviteCode = normalizeInviteCode(value);
68
+ if (!/^[A-F0-9]{16}$/.test(inviteCode)) {
69
+ throw new Error("INVITE_INVALID: invite codes are 16 letters or digits issued by Loki");
70
+ }
71
+ return inviteCode;
72
+ };
6
73
  export class LokiClient {
7
74
  #transport;
8
75
  #projectId;
@@ -10,8 +77,17 @@ export class LokiClient {
10
77
  #unsubscribe;
11
78
  #playerId;
12
79
  #roomId;
80
+ #joining = false;
81
+ #joinBuffer = [];
13
82
  #sendSequence = 0;
14
83
  #receiveSequence = 0;
84
+ #inviteCode = "";
85
+ #connectionListeners = new Set();
86
+ #unsubscribeConnection;
87
+ #lifecycle = Promise.resolve();
88
+ #lifecycleGeneration = 0;
89
+ #reconnectPromise;
90
+ #leaveFailed = false;
15
91
  constructor(options) {
16
92
  if (!/^[0-9a-f-]{36}$/i.test(options.projectId)) {
17
93
  throw new Error("invalid project id");
@@ -19,19 +95,51 @@ export class LokiClient {
19
95
  this.#projectId = options.projectId;
20
96
  this.#transport = options.transport;
21
97
  }
98
+ get playerId() {
99
+ return this.#playerId;
100
+ }
101
+ get roomId() {
102
+ return this.#roomId;
103
+ }
104
+ get inviteCode() {
105
+ return this.#inviteCode;
106
+ }
22
107
  initialize() {
23
108
  if (this.#unsubscribe)
24
109
  return;
25
110
  this.#unsubscribe = this.#transport.subscribe((raw) => {
26
111
  const message = ServerEnvelopeSchema.parse(raw);
27
- if (message.roomId !== this.#roomId ||
28
- message.sequence < this.#receiveSequence)
112
+ if (this.#joining) {
113
+ this.#joinBuffer.push(message);
29
114
  return;
30
- this.#receiveSequence = message.sequence;
31
- for (const listener of this.#listeners)
32
- listener(message);
115
+ }
116
+ this.#dispatch(message);
117
+ });
118
+ this.#unsubscribeConnection = this.#transport.subscribeConnection?.((event) => {
119
+ notifyListeners(this.#connectionListeners, event);
33
120
  });
34
121
  }
122
+ createSynchronizedRoom(options) {
123
+ if (!this.#unsubscribe)
124
+ this.initialize();
125
+ return new SynchronizedRoom({
126
+ playerId: () => this.#playerId,
127
+ sendAction: (payload, extras) => this.sendAction(payload, extras),
128
+ sendHostState: (expectedVersion, state, extras) => this.sendHostState(expectedVersion, state, extras),
129
+ sendActionRejection: (actionId, outcome, message, extras) => this.sendActionRejection(actionId, outcome, message, extras),
130
+ requestSnapshot: () => this.requestSnapshot(),
131
+ createRoom: () => this.createRoom(),
132
+ joinRoom: (input) => this.joinRoom(input),
133
+ leaveRoom: (roomId) => this.leaveRoom(roomId),
134
+ reconnect: () => this.reconnect(),
135
+ onMessage: (listener) => this.onMessage(listener),
136
+ onConnection: (listener) => this.onConnection(listener),
137
+ }, options);
138
+ }
139
+ onConnection(listener) {
140
+ this.#connectionListeners.add(listener);
141
+ return () => this.#connectionListeners.delete(listener);
142
+ }
35
143
  async authenticate(token) {
36
144
  if (!this.#unsubscribe)
37
145
  this.initialize();
@@ -39,23 +147,81 @@ export class LokiClient {
39
147
  this.#playerId = session.playerId;
40
148
  return session;
41
149
  }
42
- async joinRoom(roomKey) {
150
+ async createRoom() {
151
+ if (!this.#playerId)
152
+ throw new Error("authenticate before creating a room");
153
+ return this.#serialize(() => this.#enterRoom(() => this.#transport.createRoom({ projectId: this.#projectId })));
154
+ }
155
+ async joinRoom(input) {
43
156
  if (!this.#playerId)
44
157
  throw new Error("authenticate before joining a room");
45
- const joined = await this.#transport.joinRoom({
158
+ return this.#serialize(() => this.#enterRoom(() => this.#transport.joinRoom({
46
159
  projectId: this.#projectId,
47
- roomKey,
48
- });
49
- const snapshot = ServerEnvelopeSchema.parse(joined.snapshot);
50
- if (snapshot.roomId !== joined.roomId || snapshot.type !== "snapshot") {
51
- throw new Error("invalid join snapshot");
160
+ inviteCode: requireInviteCode(input.inviteCode),
161
+ })));
162
+ }
163
+ async #serialize(operation) {
164
+ const run = this.#lifecycle.then(operation, operation);
165
+ this.#lifecycle = run.then(() => undefined, () => undefined);
166
+ return run;
167
+ }
168
+ async #enterRoom(join) {
169
+ if (this.#leaveFailed) {
170
+ throw new Error("resolve the failed leave before joining another room");
171
+ }
172
+ const generation = ++this.#lifecycleGeneration;
173
+ this.#joining = true;
174
+ this.#joinBuffer = [];
175
+ let joinedRoomId = "";
176
+ try {
177
+ const joined = await join();
178
+ joinedRoomId = joined.roomId;
179
+ const snapshot = ServerEnvelopeSchema.parse(joined.snapshot);
180
+ if (snapshot.roomId !== joined.roomId || snapshot.type !== "snapshot") {
181
+ throw new Error("invalid join snapshot");
182
+ }
183
+ if (generation !== this.#lifecycleGeneration) {
184
+ await this.#transport.leaveRoom?.(joined.roomId).catch(() => undefined);
185
+ throw new Error("stale room join abandoned");
186
+ }
187
+ this.#roomId = joined.roomId;
188
+ this.#inviteCode = joined.inviteCode;
189
+ this.#sendSequence = 0;
190
+ this.#receiveSequence = snapshot.sequence;
191
+ const buffered = this.#joinBuffer;
192
+ this.#joinBuffer = [];
193
+ this.#joining = false;
194
+ for (const message of buffered)
195
+ this.#dispatch(message);
196
+ return {
197
+ roomId: joined.roomId,
198
+ inviteCode: joined.inviteCode,
199
+ snapshot,
200
+ };
201
+ }
202
+ catch (error) {
203
+ if (joinedRoomId) {
204
+ await this.#transport.leaveRoom?.(joinedRoomId).catch(() => undefined);
205
+ if (this.#roomId === joinedRoomId) {
206
+ this.#roomId = undefined;
207
+ this.#inviteCode = "";
208
+ }
209
+ }
210
+ throw error;
52
211
  }
53
- this.#roomId = joined.roomId;
54
- this.#sendSequence = 0;
55
- this.#receiveSequence = snapshot.sequence;
56
- return snapshot;
212
+ finally {
213
+ this.#joining = false;
214
+ this.#joinBuffer = [];
215
+ }
216
+ }
217
+ #dispatch(message) {
218
+ if (message.roomId !== this.#roomId ||
219
+ message.sequence < this.#receiveSequence)
220
+ return;
221
+ this.#receiveSequence = message.sequence;
222
+ notifyListeners(this.#listeners, message);
57
223
  }
58
- async sendAction(payload) {
224
+ async sendAction(payload, options) {
59
225
  if (!this.#roomId)
60
226
  throw new Error("join a room before sending actions");
61
227
  const message = ClientEnvelopeSchema.parse({
@@ -64,6 +230,7 @@ export class LokiClient {
64
230
  sequence: ++this.#sendSequence,
65
231
  type: "action",
66
232
  payload,
233
+ actionId: options?.actionId,
67
234
  });
68
235
  await this.#transport.send(message);
69
236
  }
@@ -80,7 +247,7 @@ export class LokiClient {
80
247
  });
81
248
  await this.#transport.send(message);
82
249
  }
83
- async sendHostState(expectedVersion, state) {
250
+ async sendHostState(expectedVersion, state, options) {
84
251
  if (!this.#roomId)
85
252
  throw new Error("join a room before sending state");
86
253
  const message = ClientEnvelopeSchema.parse({
@@ -89,10 +256,28 @@ export class LokiClient {
89
256
  sequence: ++this.#sendSequence,
90
257
  type: "host_state",
91
258
  expectedVersion,
259
+ expectedStateVersion: options?.expectedStateVersion,
260
+ actionId: options?.actionId,
261
+ senderId: options?.senderId,
92
262
  state,
93
263
  });
94
264
  await this.#transport.send(message);
95
265
  }
266
+ async sendActionRejection(actionId, outcome, message, options) {
267
+ if (!this.#roomId)
268
+ throw new Error("join a room before rejecting actions");
269
+ const envelope = ClientEnvelopeSchema.parse({
270
+ protocolVersion: PROTOCOL_VERSION,
271
+ roomId: this.#roomId,
272
+ sequence: ++this.#sendSequence,
273
+ type: "action_reject",
274
+ actionId,
275
+ senderId: options?.senderId,
276
+ outcome,
277
+ message: protocolRejectionMessage(message),
278
+ });
279
+ await this.#transport.send(envelope);
280
+ }
96
281
  async requestSnapshot() {
97
282
  await this.#sendRoomMessage({ type: "snapshot_request" });
98
283
  }
@@ -110,33 +295,56 @@ export class LokiClient {
110
295
  async resolveInvite(inviteCode) {
111
296
  if (!this.#transport.resolveInvite)
112
297
  throw new Error("invites are unsupported");
113
- return this.#transport.resolveInvite(inviteCode);
298
+ return this.#transport.resolveInvite(requireInviteCode(inviteCode));
114
299
  }
115
300
  async matchmake(input) {
116
301
  if (!this.#playerId)
117
302
  throw new Error("authenticate before matchmaking");
118
303
  if (!this.#transport.matchmake)
119
304
  throw new Error("matchmaking is unsupported");
120
- const joined = await this.#transport.matchmake(input);
121
- const snapshot = ServerEnvelopeSchema.parse(joined.snapshot);
122
- this.#roomId = joined.roomId;
123
- this.#sendSequence = 0;
124
- this.#receiveSequence = snapshot.sequence;
125
- return snapshot;
126
- }
127
- async leaveRoom() {
128
- if (!this.#roomId)
129
- return;
130
- await this.#transport.leaveRoom?.(this.#roomId);
131
- this.#roomId = undefined;
132
- this.#sendSequence = 0;
133
- this.#receiveSequence = 0;
305
+ return this.#serialize(() => this.#enterRoom(() => this.#transport.matchmake(input)));
306
+ }
307
+ async leaveRoom(roomId) {
308
+ this.#lifecycleGeneration += 1;
309
+ return this.#serialize(async () => {
310
+ const target = roomId || this.#roomId;
311
+ if (!target)
312
+ return;
313
+ try {
314
+ await this.#transport.leaveRoom?.(target);
315
+ if (this.#roomId === target) {
316
+ this.#roomId = undefined;
317
+ this.#inviteCode = "";
318
+ this.#sendSequence = 0;
319
+ this.#receiveSequence = 0;
320
+ this.#leaveFailed = false;
321
+ }
322
+ }
323
+ catch (error) {
324
+ if (this.#roomId === target || !this.#roomId) {
325
+ this.#roomId = target;
326
+ this.#leaveFailed = true;
327
+ }
328
+ throw error;
329
+ }
330
+ });
134
331
  }
135
332
  async reconnect() {
136
- if (!this.#transport.reconnect)
137
- throw new Error("reconnect is unsupported");
138
- await this.#transport.reconnect();
139
- await this.requestSnapshot();
333
+ if (this.#reconnectPromise)
334
+ return this.#reconnectPromise;
335
+ this.#reconnectPromise = this.#serialize(async () => {
336
+ if (!this.#transport.reconnect)
337
+ throw new Error("reconnect is unsupported");
338
+ if (this.#leaveFailed)
339
+ throw new Error("resolve the failed leave before reconnecting");
340
+ if (!this.#roomId)
341
+ throw new Error("join a room before reconnecting");
342
+ await this.#transport.reconnect();
343
+ await this.requestSnapshot();
344
+ }).finally(() => {
345
+ this.#reconnectPromise = undefined;
346
+ });
347
+ return this.#reconnectPromise;
140
348
  }
141
349
  async refresh() {
142
350
  if (!this.#transport.refresh)
@@ -159,9 +367,16 @@ export class LokiClient {
159
367
  await this.#transport.send(message);
160
368
  }
161
369
  async close() {
370
+ this.#lifecycleGeneration += 1;
371
+ this.#leaveFailed = false;
372
+ this.#roomId = undefined;
373
+ this.#inviteCode = "";
162
374
  this.#unsubscribe?.();
163
375
  this.#unsubscribe = undefined;
376
+ this.#unsubscribeConnection?.();
377
+ this.#unsubscribeConnection = undefined;
164
378
  this.#listeners.clear();
379
+ this.#connectionListeners.clear();
165
380
  await this.#transport.close();
166
381
  }
167
382
  }
@@ -178,6 +393,9 @@ export class FirstPartyTransport {
178
393
  #roomId;
179
394
  #roomKey;
180
395
  #closed = false;
396
+ #connectionListeners = new Set();
397
+ #ignoreDisconnect = false;
398
+ #reconnectPromise;
181
399
  constructor(options = {}) {
182
400
  this.#apiOrigin = (options.apiOrigin ?? LOKI_API_ORIGIN).replace(/\/+$/, "");
183
401
  this.#secure = options.secure ?? true;
@@ -188,16 +406,16 @@ export class FirstPartyTransport {
188
406
  async authenticate(token) {
189
407
  const value = this.#sessionProvider
190
408
  ? await this.#sessionProvider(token)
191
- : await (async () => {
409
+ : await wrapLokiCall(async () => {
192
410
  const response = await this.#fetch(`${this.#apiOrigin}/v1/nakama-session`, {
193
411
  method: "POST",
194
412
  headers: { authorization: `Bearer ${token}` },
195
413
  });
196
414
  if (!response.ok) {
197
- throw new Error(`Loki authentication failed (${response.status})`);
415
+ throw await lokiErrorFromUnknown(response);
198
416
  }
199
417
  return (await response.json());
200
- })();
418
+ });
201
419
  const checked = value;
202
420
  if (!checked.token || !checked.playerId)
203
421
  throw new Error("invalid Loki session response");
@@ -206,25 +424,68 @@ export class FirstPartyTransport {
206
424
  await this.#connectSocket();
207
425
  return { playerId: checked.playerId };
208
426
  }
209
- async joinRoom(input) {
427
+ async createRoom(_input) {
210
428
  const session = this.#requireSession();
211
429
  const socket = await this.#connectSocket();
212
- const created = payload(await this.#client.rpc(session, "loki_create_room", {
213
- roomKey: input.roomKey,
214
- }));
430
+ const created = payload(await wrapLokiCall(() => this.#client.rpc(session, "loki_create_room", {})));
431
+ if (!created.matchId || !created.inviteCode) {
432
+ throw new Error("Loki did not return a room invite");
433
+ }
215
434
  await socket.joinMatch(created.matchId);
216
- this.#roomId = created.matchId;
217
- this.#roomKey = input.roomKey;
218
- return {
219
- roomId: created.matchId,
220
- snapshot: await this.#snapshot(created.matchId),
221
- };
435
+ try {
436
+ const snapshot = await this.#snapshot(created.matchId);
437
+ this.#roomId = created.matchId;
438
+ this.#roomKey = created.roomKey;
439
+ return {
440
+ roomId: created.matchId,
441
+ inviteCode: created.inviteCode,
442
+ snapshot,
443
+ };
444
+ }
445
+ catch (error) {
446
+ await socket.leaveMatch(created.matchId).catch(() => undefined);
447
+ if (this.#roomId === created.matchId) {
448
+ this.#roomId = undefined;
449
+ this.#roomKey = undefined;
450
+ }
451
+ throw error;
452
+ }
453
+ }
454
+ async joinRoom(input) {
455
+ const session = this.#requireSession();
456
+ const socket = await this.#connectSocket();
457
+ const inviteCode = requireInviteCode(input.inviteCode);
458
+ const joined = payload(await wrapLokiCall(() => this.#client.rpc(session, "loki_join_room", { inviteCode })));
459
+ if (!joined.matchId)
460
+ throw new Error("Loki did not return a room");
461
+ await socket.joinMatch(joined.matchId);
462
+ try {
463
+ const snapshot = await this.#snapshot(joined.matchId);
464
+ this.#roomId = joined.matchId;
465
+ return {
466
+ roomId: joined.matchId,
467
+ inviteCode: joined.inviteCode ?? inviteCode,
468
+ snapshot,
469
+ };
470
+ }
471
+ catch (error) {
472
+ await socket.leaveMatch(joined.matchId).catch(() => undefined);
473
+ if (this.#roomId === joined.matchId)
474
+ this.#roomId = undefined;
475
+ throw error;
476
+ }
222
477
  }
223
478
  async resolveInvite(inviteCode) {
224
- const resolved = payload(await this.#client.rpc(this.#requireSession(), "loki_resolve_invite", {
225
- inviteCode,
226
- }));
227
- return { roomId: resolved.matchId };
479
+ const normalized = requireInviteCode(inviteCode);
480
+ const resolved = payload(await wrapLokiCall(() => this.#client.rpc(this.#requireSession(), "loki_resolve_invite", {
481
+ inviteCode: normalized,
482
+ })));
483
+ if (!resolved.matchId)
484
+ throw new Error("Loki did not return a room");
485
+ return {
486
+ roomId: resolved.matchId,
487
+ inviteCode: resolved.inviteCode ?? normalized,
488
+ };
228
489
  }
229
490
  async matchmake(input) {
230
491
  const socket = await this.#connectSocket();
@@ -240,9 +501,24 @@ export class FirstPartyTransport {
240
501
  });
241
502
  const result = await matched;
242
503
  const joined = await socket.joinMatch(result.match_id, result.token);
243
- this.#roomId = joined.match_id;
244
- this.#roomKey = `match-${joined.match_id.slice(0, 12).toLowerCase()}`;
245
- return { roomId: joined.match_id, snapshot: await this.#snapshot(joined.match_id) };
504
+ try {
505
+ const snapshot = await this.#snapshot(joined.match_id);
506
+ this.#roomId = joined.match_id;
507
+ this.#roomKey = `match-${joined.match_id.slice(0, 12).toLowerCase()}`;
508
+ return {
509
+ roomId: joined.match_id,
510
+ inviteCode: "",
511
+ snapshot,
512
+ };
513
+ }
514
+ catch (error) {
515
+ await socket.leaveMatch(joined.match_id).catch(() => undefined);
516
+ if (this.#roomId === joined.match_id) {
517
+ this.#roomId = undefined;
518
+ this.#roomKey = undefined;
519
+ }
520
+ throw error;
521
+ }
246
522
  }
247
523
  async send(message) {
248
524
  const socket = await this.#connectSocket();
@@ -252,22 +528,32 @@ export class FirstPartyTransport {
252
528
  ? 11
253
529
  : message.type === "host_state"
254
530
  ? 12
255
- : message.type === "snapshot_request"
256
- ? 13
257
- : message.type === "chat"
258
- ? 14
259
- : 15;
531
+ : message.type === "action_reject"
532
+ ? 16
533
+ : message.type === "snapshot_request"
534
+ ? 13
535
+ : message.type === "chat"
536
+ ? 14
537
+ : 15;
260
538
  await socket.sendMatchState(message.roomId, opCode, JSON.stringify(message));
261
539
  }
262
540
  subscribe(listener) {
263
541
  this.#listeners.add(listener);
264
542
  return () => this.#listeners.delete(listener);
265
543
  }
544
+ subscribeConnection(listener) {
545
+ this.#connectionListeners.add(listener);
546
+ return () => this.#connectionListeners.delete(listener);
547
+ }
266
548
  async leaveRoom(roomId) {
267
- await this.#socket?.leaveMatch(roomId);
268
- if (this.#roomId === roomId) {
269
- this.#roomId = undefined;
270
- this.#roomKey = undefined;
549
+ try {
550
+ await this.#socket?.leaveMatch(roomId);
551
+ }
552
+ finally {
553
+ if (this.#roomId === roomId) {
554
+ this.#roomId = undefined;
555
+ this.#roomKey = undefined;
556
+ }
271
557
  }
272
558
  }
273
559
  async refresh() {
@@ -276,19 +562,39 @@ export class FirstPartyTransport {
276
562
  async reconnect() {
277
563
  if (this.#closed)
278
564
  throw new Error("transport is closed");
279
- this.#socket?.disconnect(false);
565
+ if (this.#reconnectPromise)
566
+ return this.#reconnectPromise;
567
+ this.#reconnectPromise = this.#reconnectOnce()
568
+ .then(() => {
569
+ notifyListeners(this.#connectionListeners, "connected");
570
+ })
571
+ .finally(() => {
572
+ this.#reconnectPromise = undefined;
573
+ });
574
+ return this.#reconnectPromise;
575
+ }
576
+ async #reconnectOnce() {
577
+ this.#ignoreDisconnect = true;
578
+ const previous = this.#socket;
280
579
  this.#socket = undefined;
281
- if (this.#session?.isexpired(Math.floor(Date.now() / 1_000)))
282
- await this.refresh();
283
- const socket = await this.#connectSocket();
284
- if (this.#roomId)
285
- await socket.joinMatch(this.#roomId);
580
+ try {
581
+ previous?.disconnect(false);
582
+ if (this.#session?.isexpired(Math.floor(Date.now() / 1_000)))
583
+ await this.refresh();
584
+ const socket = await this.#connectSocket();
585
+ if (this.#roomId)
586
+ await socket.joinMatch(this.#roomId);
587
+ }
588
+ finally {
589
+ this.#ignoreDisconnect = false;
590
+ }
286
591
  }
287
592
  async close() {
288
593
  this.#closed = true;
289
594
  this.#socket?.disconnect(false);
290
595
  this.#socket = undefined;
291
596
  this.#listeners.clear();
597
+ this.#connectionListeners.clear();
292
598
  }
293
599
  async #connectSocket() {
294
600
  if (this.#closed)
@@ -301,25 +607,33 @@ export class FirstPartyTransport {
301
607
  return;
302
608
  try {
303
609
  const decoded = JSON.parse(textDecoder.decode(message.data));
304
- for (const listener of this.#listeners)
305
- listener(decoded);
610
+ notifyListeners(this.#listeners, decoded);
306
611
  }
307
612
  catch {
308
613
  // Invalid server data is ignored and cannot reach game listeners.
309
614
  }
310
615
  };
311
616
  socket.ondisconnect = () => {
312
- if (!this.#closed)
313
- void this.reconnect().catch(() => undefined);
617
+ if (this.#socket !== socket)
618
+ return;
619
+ this.#socket = undefined;
620
+ if (this.#ignoreDisconnect)
621
+ return;
622
+ notifyListeners(this.#connectionListeners, "disconnected");
623
+ if (this.#closed || this.#reconnectPromise)
624
+ return;
625
+ void this.reconnect().catch(() => {
626
+ notifyListeners(this.#connectionListeners, "reconnect_failed");
627
+ });
314
628
  };
315
629
  await socket.connect(this.#requireSession(), true);
316
630
  this.#socket = socket;
317
631
  return socket;
318
632
  }
319
633
  async #snapshot(roomId) {
320
- const state = payload(await this.#client.rpc(this.#requireSession(), "loki_room_snapshot", {
634
+ const state = payload(await wrapLokiCall(() => this.#client.rpc(this.#requireSession(), "loki_room_snapshot", {
321
635
  matchId: roomId,
322
- }));
636
+ })));
323
637
  if (!state.ok)
324
638
  throw new Error("room snapshot failed");
325
639
  return ServerEnvelopeSchema.parse({
@@ -329,6 +643,8 @@ export class FirstPartyTransport {
329
643
  type: "snapshot",
330
644
  hostId: state.hostId,
331
645
  state: state.state,
646
+ stateVersion: state.stateVersion ?? state.version,
647
+ capabilities: state.capabilities,
332
648
  });
333
649
  }
334
650
  #requireSession() {