@lokiplay/sdk 0.1.2 → 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/README.md CHANGED
@@ -4,7 +4,7 @@ JavaScript client SDK for authenticating players, joining Loki multiplayer
4
4
  rooms, sending actions and events, and subscribing to server messages.
5
5
 
6
6
  ```sh
7
- npm install @lokiplay/sdk
7
+ npm install @lokiplay/sdk@0.2.0
8
8
  ```
9
9
 
10
10
  Use `FirstPartyTransport` for production. It defaults to
@@ -20,6 +20,37 @@ game manifest allowlist.
20
20
  `LokiClient` supports rooms, invite resolution, matchmaking, actions, events,
21
21
  host state, snapshots, presence, chat, private scores, token refresh, reconnect,
22
22
  and host-migration messages. Loki creates every room and issues the shareable
23
- invite code. Games call `createRoom()` and `joinRoom({ inviteCode })` and must
24
- not invent room keys. Host authority is suitable for casual/unranked games, not
25
- ranked anti-cheat.
23
+ invite code. Call `createRoom()` and `joinRoom({ inviteCode })` and do not invent
24
+ room keys.
25
+
26
+ Prefer `createSynchronizedRoom()` for shared state. Supply opaque state, opaque
27
+ actions, and a reducer. Loki sequences actions, commits only from the current
28
+ authority, tracks `stateVersion`, deduplicates action IDs, restores snapshots,
29
+ and exposes membership and connection status. Low-level `sendAction()` and
30
+ `sendHostState()` remain available. Host authority is suitable for casual
31
+ unranked sessions, not ranked integrity.
32
+
33
+ Generic integration steps:
34
+
35
+ 1. Install the exact published SDK version for this release.
36
+ 2. Define this project's state and action schemas.
37
+ 3. Define a synchronous, deterministic, JSON-compatible reducer.
38
+ 4. Create or join with `create()` or `join({ inviteCode })`.
39
+ 5. Subscribe to snapshots for state, members, authority, and connection.
40
+ 6. Dispatch actions and wait for authoritative confirmation.
41
+ 7. Render connection and `lastError` from the snapshot.
42
+ 8. Handle rejected actions without inventing a parallel protocol.
43
+ 9. Build and deploy from this project's own repository.
44
+
45
+ Reducers must return quickly. Loki clones reducer inputs and catches thrown
46
+ errors, but a synchronous infinite loop cannot be interrupted without changing
47
+ the reducer API. Do not perform I/O or rendering inside `reduce`. Reducers must
48
+ be synchronous, deterministic, and JSON-compatible. Only the current host runs
49
+ `reduce`; members wait for an authoritative `state` acknowledgement keyed by
50
+ `(senderId, actionId)`.
51
+
52
+ `create()`, `join({ inviteCode })`, `dispatch(action)`, `leave()`, and
53
+ `reconnect()` are the shared synchronized-room API. A failed `leave()` retains
54
+ the room identity in `leave_failed` and blocks a new join until `leave()`
55
+ succeeds or `close()` abandons the handle. 0.2.0 clients fail fast when a
56
+ snapshot does not advertise `capabilities.synchronized_rooms`.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import { type ClientEnvelope, type ServerEnvelope } from "@lokiplay/protocol";
2
+ import { SynchronizedRoom, type ConnectionEvent, type SynchronizedRoomOptions } from "./synchronized-room.js";
3
+ 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";
4
+ export type { ActionContext, CommittedTransition, ConnectionEvent, ConnectionState, RoomMember, Schema, SynchronizedRoomOutcome, SynchronizedRoomSnapshot, } from "./synchronized-room.js";
2
5
  export declare const LOKI_API_ORIGIN = "https://api.lokiplay.cc";
3
6
  export type JoinedRoom = {
4
7
  roomId: string;
@@ -32,6 +35,7 @@ export interface LokiTransport {
32
35
  reconnect?(): Promise<void>;
33
36
  refresh?(): Promise<void>;
34
37
  close(): Promise<void>;
38
+ subscribeConnection?(listener: (event: ConnectionEvent) => void): () => void;
35
39
  }
36
40
  export interface LokiClientOptions {
37
41
  projectId: string;
@@ -40,7 +44,12 @@ export interface LokiClientOptions {
40
44
  export declare class LokiClient {
41
45
  #private;
42
46
  constructor(options: LokiClientOptions);
47
+ get playerId(): string | undefined;
48
+ get roomId(): string | undefined;
49
+ get inviteCode(): string;
43
50
  initialize(): void;
51
+ createSynchronizedRoom<State, Action>(options: SynchronizedRoomOptions<State, Action>): SynchronizedRoom<State, Action>;
52
+ onConnection(listener: (event: ConnectionEvent) => void): () => void;
44
53
  authenticate(token: string): Promise<{
45
54
  playerId: string;
46
55
  }>;
@@ -48,9 +57,18 @@ export declare class LokiClient {
48
57
  joinRoom(input: {
49
58
  inviteCode: string;
50
59
  }): Promise<JoinedRoom>;
51
- sendAction(payload: unknown): Promise<void>;
60
+ sendAction(payload: unknown, options?: {
61
+ actionId?: string;
62
+ }): Promise<void>;
52
63
  sendEvent(payload: unknown, reliable?: boolean): Promise<void>;
53
- sendHostState(expectedVersion: number, state: unknown): Promise<void>;
64
+ sendHostState(expectedVersion: number, state: unknown, options?: {
65
+ actionId?: string;
66
+ expectedStateVersion?: number;
67
+ senderId?: string;
68
+ }): Promise<void>;
69
+ sendActionRejection(actionId: string, outcome: "rejected" | "invalid", message: string, options?: {
70
+ senderId?: string;
71
+ }): Promise<void>;
54
72
  requestSnapshot(): Promise<void>;
55
73
  sendChat(text: string, channel?: "lobby" | "match"): Promise<void>;
56
74
  submitScore(leaderboardId: string, score: number, subscore?: number): Promise<void>;
@@ -63,7 +81,7 @@ export declare class LokiClient {
63
81
  maxPlayers: number;
64
82
  teamSize?: number;
65
83
  }): Promise<JoinedRoom>;
66
- leaveRoom(): Promise<void>;
84
+ leaveRoom(roomId?: string): Promise<void>;
67
85
  reconnect(): Promise<void>;
68
86
  refresh(): Promise<void>;
69
87
  onMessage(listener: (message: ServerEnvelope) => void): () => void;
@@ -106,6 +124,7 @@ export declare class FirstPartyTransport implements LokiTransport {
106
124
  }): Promise<JoinedRoom>;
107
125
  send(message: ClientEnvelope): Promise<void>;
108
126
  subscribe(listener: (message: unknown) => void): () => void;
127
+ subscribeConnection(listener: (event: ConnectionEvent) => void): () => void;
109
128
  leaveRoom(roomId: string): Promise<void>;
110
129
  refresh(): Promise<void>;
111
130
  reconnect(): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
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;
@@ -38,6 +40,20 @@ export async function lokiErrorFromUnknown(error) {
38
40
  }
39
41
  return new Error("Loki request failed");
40
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
+ };
41
57
  const wrapLokiCall = async (operation) => {
42
58
  try {
43
59
  return await operation();
@@ -65,6 +81,13 @@ export class LokiClient {
65
81
  #joinBuffer = [];
66
82
  #sendSequence = 0;
67
83
  #receiveSequence = 0;
84
+ #inviteCode = "";
85
+ #connectionListeners = new Set();
86
+ #unsubscribeConnection;
87
+ #lifecycle = Promise.resolve();
88
+ #lifecycleGeneration = 0;
89
+ #reconnectPromise;
90
+ #leaveFailed = false;
68
91
  constructor(options) {
69
92
  if (!/^[0-9a-f-]{36}$/i.test(options.projectId)) {
70
93
  throw new Error("invalid project id");
@@ -72,6 +95,15 @@ export class LokiClient {
72
95
  this.#projectId = options.projectId;
73
96
  this.#transport = options.transport;
74
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
+ }
75
107
  initialize() {
76
108
  if (this.#unsubscribe)
77
109
  return;
@@ -83,6 +115,30 @@ export class LokiClient {
83
115
  }
84
116
  this.#dispatch(message);
85
117
  });
118
+ this.#unsubscribeConnection = this.#transport.subscribeConnection?.((event) => {
119
+ notifyListeners(this.#connectionListeners, event);
120
+ });
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);
86
142
  }
87
143
  async authenticate(token) {
88
144
  if (!this.#unsubscribe)
@@ -94,26 +150,42 @@ export class LokiClient {
94
150
  async createRoom() {
95
151
  if (!this.#playerId)
96
152
  throw new Error("authenticate before creating a room");
97
- return this.#enterRoom(() => this.#transport.createRoom({ projectId: this.#projectId }));
153
+ return this.#serialize(() => this.#enterRoom(() => this.#transport.createRoom({ projectId: this.#projectId })));
98
154
  }
99
155
  async joinRoom(input) {
100
156
  if (!this.#playerId)
101
157
  throw new Error("authenticate before joining a room");
102
- return this.#enterRoom(() => this.#transport.joinRoom({
158
+ return this.#serialize(() => this.#enterRoom(() => this.#transport.joinRoom({
103
159
  projectId: this.#projectId,
104
160
  inviteCode: requireInviteCode(input.inviteCode),
105
- }));
161
+ })));
162
+ }
163
+ async #serialize(operation) {
164
+ const run = this.#lifecycle.then(operation, operation);
165
+ this.#lifecycle = run.then(() => undefined, () => undefined);
166
+ return run;
106
167
  }
107
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;
108
173
  this.#joining = true;
109
174
  this.#joinBuffer = [];
175
+ let joinedRoomId = "";
110
176
  try {
111
177
  const joined = await join();
178
+ joinedRoomId = joined.roomId;
112
179
  const snapshot = ServerEnvelopeSchema.parse(joined.snapshot);
113
180
  if (snapshot.roomId !== joined.roomId || snapshot.type !== "snapshot") {
114
181
  throw new Error("invalid join snapshot");
115
182
  }
183
+ if (generation !== this.#lifecycleGeneration) {
184
+ await this.#transport.leaveRoom?.(joined.roomId).catch(() => undefined);
185
+ throw new Error("stale room join abandoned");
186
+ }
116
187
  this.#roomId = joined.roomId;
188
+ this.#inviteCode = joined.inviteCode;
117
189
  this.#sendSequence = 0;
118
190
  this.#receiveSequence = snapshot.sequence;
119
191
  const buffered = this.#joinBuffer;
@@ -127,6 +199,16 @@ export class LokiClient {
127
199
  snapshot,
128
200
  };
129
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;
211
+ }
130
212
  finally {
131
213
  this.#joining = false;
132
214
  this.#joinBuffer = [];
@@ -137,10 +219,9 @@ export class LokiClient {
137
219
  message.sequence < this.#receiveSequence)
138
220
  return;
139
221
  this.#receiveSequence = message.sequence;
140
- for (const listener of this.#listeners)
141
- listener(message);
222
+ notifyListeners(this.#listeners, message);
142
223
  }
143
- async sendAction(payload) {
224
+ async sendAction(payload, options) {
144
225
  if (!this.#roomId)
145
226
  throw new Error("join a room before sending actions");
146
227
  const message = ClientEnvelopeSchema.parse({
@@ -149,6 +230,7 @@ export class LokiClient {
149
230
  sequence: ++this.#sendSequence,
150
231
  type: "action",
151
232
  payload,
233
+ actionId: options?.actionId,
152
234
  });
153
235
  await this.#transport.send(message);
154
236
  }
@@ -165,7 +247,7 @@ export class LokiClient {
165
247
  });
166
248
  await this.#transport.send(message);
167
249
  }
168
- async sendHostState(expectedVersion, state) {
250
+ async sendHostState(expectedVersion, state, options) {
169
251
  if (!this.#roomId)
170
252
  throw new Error("join a room before sending state");
171
253
  const message = ClientEnvelopeSchema.parse({
@@ -174,10 +256,28 @@ export class LokiClient {
174
256
  sequence: ++this.#sendSequence,
175
257
  type: "host_state",
176
258
  expectedVersion,
259
+ expectedStateVersion: options?.expectedStateVersion,
260
+ actionId: options?.actionId,
261
+ senderId: options?.senderId,
177
262
  state,
178
263
  });
179
264
  await this.#transport.send(message);
180
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
+ }
181
281
  async requestSnapshot() {
182
282
  await this.#sendRoomMessage({ type: "snapshot_request" });
183
283
  }
@@ -202,21 +302,49 @@ export class LokiClient {
202
302
  throw new Error("authenticate before matchmaking");
203
303
  if (!this.#transport.matchmake)
204
304
  throw new Error("matchmaking is unsupported");
205
- return this.#enterRoom(() => this.#transport.matchmake(input));
305
+ return this.#serialize(() => this.#enterRoom(() => this.#transport.matchmake(input)));
206
306
  }
207
- async leaveRoom() {
208
- if (!this.#roomId)
209
- return;
210
- await this.#transport.leaveRoom?.(this.#roomId);
211
- this.#roomId = undefined;
212
- this.#sendSequence = 0;
213
- this.#receiveSequence = 0;
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
+ });
214
331
  }
215
332
  async reconnect() {
216
- if (!this.#transport.reconnect)
217
- throw new Error("reconnect is unsupported");
218
- await this.#transport.reconnect();
219
- 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;
220
348
  }
221
349
  async refresh() {
222
350
  if (!this.#transport.refresh)
@@ -239,9 +367,16 @@ export class LokiClient {
239
367
  await this.#transport.send(message);
240
368
  }
241
369
  async close() {
370
+ this.#lifecycleGeneration += 1;
371
+ this.#leaveFailed = false;
372
+ this.#roomId = undefined;
373
+ this.#inviteCode = "";
242
374
  this.#unsubscribe?.();
243
375
  this.#unsubscribe = undefined;
376
+ this.#unsubscribeConnection?.();
377
+ this.#unsubscribeConnection = undefined;
244
378
  this.#listeners.clear();
379
+ this.#connectionListeners.clear();
245
380
  await this.#transport.close();
246
381
  }
247
382
  }
@@ -258,6 +393,9 @@ export class FirstPartyTransport {
258
393
  #roomId;
259
394
  #roomKey;
260
395
  #closed = false;
396
+ #connectionListeners = new Set();
397
+ #ignoreDisconnect = false;
398
+ #reconnectPromise;
261
399
  constructor(options = {}) {
262
400
  this.#apiOrigin = (options.apiOrigin ?? LOKI_API_ORIGIN).replace(/\/+$/, "");
263
401
  this.#secure = options.secure ?? true;
@@ -294,13 +432,24 @@ export class FirstPartyTransport {
294
432
  throw new Error("Loki did not return a room invite");
295
433
  }
296
434
  await socket.joinMatch(created.matchId);
297
- this.#roomId = created.matchId;
298
- this.#roomKey = created.roomKey;
299
- return {
300
- roomId: created.matchId,
301
- inviteCode: created.inviteCode,
302
- snapshot: await this.#snapshot(created.matchId),
303
- };
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
+ }
304
453
  }
305
454
  async joinRoom(input) {
306
455
  const session = this.#requireSession();
@@ -310,12 +459,21 @@ export class FirstPartyTransport {
310
459
  if (!joined.matchId)
311
460
  throw new Error("Loki did not return a room");
312
461
  await socket.joinMatch(joined.matchId);
313
- this.#roomId = joined.matchId;
314
- return {
315
- roomId: joined.matchId,
316
- inviteCode: joined.inviteCode ?? inviteCode,
317
- snapshot: await this.#snapshot(joined.matchId),
318
- };
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
+ }
319
477
  }
320
478
  async resolveInvite(inviteCode) {
321
479
  const normalized = requireInviteCode(inviteCode);
@@ -343,13 +501,24 @@ export class FirstPartyTransport {
343
501
  });
344
502
  const result = await matched;
345
503
  const joined = await socket.joinMatch(result.match_id, result.token);
346
- this.#roomId = joined.match_id;
347
- this.#roomKey = `match-${joined.match_id.slice(0, 12).toLowerCase()}`;
348
- return {
349
- roomId: joined.match_id,
350
- inviteCode: "",
351
- snapshot: await this.#snapshot(joined.match_id),
352
- };
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
+ }
353
522
  }
354
523
  async send(message) {
355
524
  const socket = await this.#connectSocket();
@@ -359,22 +528,32 @@ export class FirstPartyTransport {
359
528
  ? 11
360
529
  : message.type === "host_state"
361
530
  ? 12
362
- : message.type === "snapshot_request"
363
- ? 13
364
- : message.type === "chat"
365
- ? 14
366
- : 15;
531
+ : message.type === "action_reject"
532
+ ? 16
533
+ : message.type === "snapshot_request"
534
+ ? 13
535
+ : message.type === "chat"
536
+ ? 14
537
+ : 15;
367
538
  await socket.sendMatchState(message.roomId, opCode, JSON.stringify(message));
368
539
  }
369
540
  subscribe(listener) {
370
541
  this.#listeners.add(listener);
371
542
  return () => this.#listeners.delete(listener);
372
543
  }
544
+ subscribeConnection(listener) {
545
+ this.#connectionListeners.add(listener);
546
+ return () => this.#connectionListeners.delete(listener);
547
+ }
373
548
  async leaveRoom(roomId) {
374
- await this.#socket?.leaveMatch(roomId);
375
- if (this.#roomId === roomId) {
376
- this.#roomId = undefined;
377
- 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
+ }
378
557
  }
379
558
  }
380
559
  async refresh() {
@@ -383,19 +562,39 @@ export class FirstPartyTransport {
383
562
  async reconnect() {
384
563
  if (this.#closed)
385
564
  throw new Error("transport is closed");
386
- 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;
387
579
  this.#socket = undefined;
388
- if (this.#session?.isexpired(Math.floor(Date.now() / 1_000)))
389
- await this.refresh();
390
- const socket = await this.#connectSocket();
391
- if (this.#roomId)
392
- 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
+ }
393
591
  }
394
592
  async close() {
395
593
  this.#closed = true;
396
594
  this.#socket?.disconnect(false);
397
595
  this.#socket = undefined;
398
596
  this.#listeners.clear();
597
+ this.#connectionListeners.clear();
399
598
  }
400
599
  async #connectSocket() {
401
600
  if (this.#closed)
@@ -408,16 +607,24 @@ export class FirstPartyTransport {
408
607
  return;
409
608
  try {
410
609
  const decoded = JSON.parse(textDecoder.decode(message.data));
411
- for (const listener of this.#listeners)
412
- listener(decoded);
610
+ notifyListeners(this.#listeners, decoded);
413
611
  }
414
612
  catch {
415
613
  // Invalid server data is ignored and cannot reach game listeners.
416
614
  }
417
615
  };
418
616
  socket.ondisconnect = () => {
419
- if (!this.#closed)
420
- 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
+ });
421
628
  };
422
629
  await socket.connect(this.#requireSession(), true);
423
630
  this.#socket = socket;
@@ -436,6 +643,8 @@ export class FirstPartyTransport {
436
643
  type: "snapshot",
437
644
  hostId: state.hostId,
438
645
  state: state.state,
646
+ stateVersion: state.stateVersion ?? state.version,
647
+ capabilities: state.capabilities,
439
648
  });
440
649
  }
441
650
  #requireSession() {