@lokiplay/sdk 0.1.2 → 0.2.1

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.1
8
8
  ```
9
9
 
10
10
  Use `FirstPartyTransport` for production. It defaults to
@@ -20,6 +20,38 @@ 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. New rooms issue a 6-digit code. `0.2.0` 16-character hex codes
24
+ still join. Call `createRoom()` and `joinRoom({ inviteCode })` and do not invent
25
+ room keys.
26
+
27
+ Prefer `createSynchronizedRoom()` for shared state. Supply opaque state, opaque
28
+ actions, and a reducer. Loki sequences actions, commits only from the current
29
+ authority, tracks `stateVersion`, deduplicates action IDs, restores snapshots,
30
+ and exposes membership and connection status. Low-level `sendAction()` and
31
+ `sendHostState()` remain available. Host authority is suitable for casual
32
+ unranked sessions, not ranked integrity.
33
+
34
+ Generic integration steps:
35
+
36
+ 1. Install the exact published SDK version for this release.
37
+ 2. Define this project's state and action schemas.
38
+ 3. Define a synchronous, deterministic, JSON-compatible reducer.
39
+ 4. Create or join with `create()` or `join({ inviteCode })`.
40
+ 5. Subscribe to snapshots for state, members, authority, and connection.
41
+ 6. Dispatch actions and wait for authoritative confirmation.
42
+ 7. Render connection and `lastError` from the snapshot.
43
+ 8. Handle rejected actions without inventing a parallel protocol.
44
+ 9. Build and deploy from this project's own repository.
45
+
46
+ Reducers must return quickly. Loki clones reducer inputs and catches thrown
47
+ errors, but a synchronous infinite loop cannot be interrupted without changing
48
+ the reducer API. Do not perform I/O or rendering inside `reduce`. Reducers must
49
+ be synchronous, deterministic, and JSON-compatible. Only the current host runs
50
+ `reduce`; members wait for an authoritative `state` acknowledgement keyed by
51
+ `(senderId, actionId)`.
52
+
53
+ `create()`, `join({ inviteCode })`, `dispatch(action)`, `leave()`, and
54
+ `reconnect()` are the shared synchronized-room API. A failed `leave()` retains
55
+ the room identity in `leave_failed` and blocks a new join until `leave()`
56
+ succeeds or `close()` abandons the handle. 0.2.0 clients fail fast when a
57
+ 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":"AAAA,OAAO,EAIL,KAAK,cAAc,EACnB,KAAK,cAAc,EACpB,MAAM,6BAA6B,CAAC;AAGrC,eAAO,MAAM,eAAe,4BAA4B,CAAC;AAMzD,MAAM,MAAM,UAAU,GAAG;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,cAAc,CAAC;CAC1B,CAAC;AAEF,wBAAsB,oBAAoB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,KAAK,CAAC,CAoCzE;AAoBD,MAAM,WAAW,aAAa;IAC5B,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC3D,UAAU,CAAC,KAAK,EAAE;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;IAC9D,QAAQ,CAAC,KAAK,EAAE;QACd,SAAS,EAAE,MAAM,CAAC;QAClB,UAAU,EAAE,MAAM,CAAC;KACpB,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;IACxB,IAAI,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7C,SAAS,CAAC,QAAQ,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IAC5D,aAAa,CAAC,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACpF,SAAS,CAAC,CAAC,KAAK,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;IACtG,SAAS,CAAC,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1C,SAAS,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5B,OAAO,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,aAAa,CAAC;CAC1B;AAED,qBAAa,UAAU;;gBAYT,OAAO,EAAE,iBAAiB;IAQtC,UAAU,IAAI,IAAI;IAYZ,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAO1D,UAAU,IAAI,OAAO,CAAC,UAAU,CAAC;IAOjC,QAAQ,CAAC,KAAK,EAAE;QAAE,UAAU,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,UAAU,CAAC;IA8C5D,UAAU,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAY3C,SAAS,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,UAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAa3D,aAAa,CAAC,eAAe,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAarE,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC;IAIhC,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,OAAO,GAAG,OAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAI3E,WAAW,CACf,aAAa,EAAE,MAAM,EACrB,KAAK,EAAE,MAAM,EACb,QAAQ,SAAI,GACX,OAAO,CAAC,IAAI,CAAC;IASV,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC;IAKlF,SAAS,CAAC,KAAK,EAAE;QACrB,UAAU,EAAE,MAAM,CAAC;QACnB,UAAU,EAAE,MAAM,CAAC;QACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;KACnB,GAAG,OAAO,CAAC,UAAU,CAAC;IAMjB,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC;IAQ1B,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC;IAM1B,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAK9B,SAAS,CAAC,QAAQ,EAAE,CAAC,OAAO,EAAE,cAAc,KAAK,IAAI,GAAG,MAAM,IAAI;IA0B5D,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAM7B;AAED,MAAM,WAAW,0BAA0B;IACzC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IAChC,eAAe,CAAC,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;QACvC,KAAK,EAAE,MAAM,CAAC;QACd,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,QAAQ,EAAE,MAAM,CAAC;KAClB,CAAC,CAAC;CACJ;AAED,qBAAa,mBAAoB,YAAW,aAAa;;gBAc3C,OAAO,GAAE,0BAA+B;IAe9C,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IA6B1D,UAAU,CAAC,MAAM,EAAE;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,UAAU,CAAC;IAuB9D,QAAQ,CAAC,KAAK,EAAE;QACpB,SAAS,EAAE,MAAM,CAAC;QAClB,UAAU,EAAE,MAAM,CAAC;KACpB,GAAG,OAAO,CAAC,UAAU,CAAC;IAsBjB,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC;IAgBlF,SAAS,CAAC,KAAK,EAAE;QACrB,UAAU,EAAE,MAAM,CAAC;QACnB,UAAU,EAAE,MAAM,CAAC;QACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;KACnB,GAAG,OAAO,CAAC,UAAU,CAAC;IAuBjB,IAAI,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC;IAiBlD,SAAS,CAAC,QAAQ,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,GAAG,MAAM,IAAI;IAKrD,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQxC,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAIxB,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC;IAS1B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAwD7B;AAED,wBAAgB,yBAAyB,CACvC,IAAI,EAAE,WAAW,EACjB,KAAK,EAAE,MAAM,EACb,SAAS,SAAS,GACjB,0BAA0B,CAAC,iBAAiB,CAAC,CA0C/C"}
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();
@@ -47,10 +63,11 @@ const wrapLokiCall = async (operation) => {
47
63
  }
48
64
  };
49
65
  const normalizeInviteCode = (value) => value.trim().toUpperCase();
66
+ const validInviteCode = (value) => /^[0-9]{6}$/.test(value) || /^[A-F0-9]{16}$/.test(value);
50
67
  const requireInviteCode = (value) => {
51
68
  const inviteCode = normalizeInviteCode(value);
52
- if (!/^[A-F0-9]{16}$/.test(inviteCode)) {
53
- throw new Error("INVITE_INVALID: invite codes are 16 letters or digits issued by Loki");
69
+ if (!validInviteCode(inviteCode)) {
70
+ throw new Error("INVITE_INVALID: invite codes are 6 digits issued by Loki");
54
71
  }
55
72
  return inviteCode;
56
73
  };
@@ -65,6 +82,13 @@ export class LokiClient {
65
82
  #joinBuffer = [];
66
83
  #sendSequence = 0;
67
84
  #receiveSequence = 0;
85
+ #inviteCode = "";
86
+ #connectionListeners = new Set();
87
+ #unsubscribeConnection;
88
+ #lifecycle = Promise.resolve();
89
+ #lifecycleGeneration = 0;
90
+ #reconnectPromise;
91
+ #leaveFailed = false;
68
92
  constructor(options) {
69
93
  if (!/^[0-9a-f-]{36}$/i.test(options.projectId)) {
70
94
  throw new Error("invalid project id");
@@ -72,6 +96,15 @@ export class LokiClient {
72
96
  this.#projectId = options.projectId;
73
97
  this.#transport = options.transport;
74
98
  }
99
+ get playerId() {
100
+ return this.#playerId;
101
+ }
102
+ get roomId() {
103
+ return this.#roomId;
104
+ }
105
+ get inviteCode() {
106
+ return this.#inviteCode;
107
+ }
75
108
  initialize() {
76
109
  if (this.#unsubscribe)
77
110
  return;
@@ -83,6 +116,30 @@ export class LokiClient {
83
116
  }
84
117
  this.#dispatch(message);
85
118
  });
119
+ this.#unsubscribeConnection = this.#transport.subscribeConnection?.((event) => {
120
+ notifyListeners(this.#connectionListeners, event);
121
+ });
122
+ }
123
+ createSynchronizedRoom(options) {
124
+ if (!this.#unsubscribe)
125
+ this.initialize();
126
+ return new SynchronizedRoom({
127
+ playerId: () => this.#playerId,
128
+ sendAction: (payload, extras) => this.sendAction(payload, extras),
129
+ sendHostState: (expectedVersion, state, extras) => this.sendHostState(expectedVersion, state, extras),
130
+ sendActionRejection: (actionId, outcome, message, extras) => this.sendActionRejection(actionId, outcome, message, extras),
131
+ requestSnapshot: () => this.requestSnapshot(),
132
+ createRoom: () => this.createRoom(),
133
+ joinRoom: (input) => this.joinRoom(input),
134
+ leaveRoom: (roomId) => this.leaveRoom(roomId),
135
+ reconnect: () => this.reconnect(),
136
+ onMessage: (listener) => this.onMessage(listener),
137
+ onConnection: (listener) => this.onConnection(listener),
138
+ }, options);
139
+ }
140
+ onConnection(listener) {
141
+ this.#connectionListeners.add(listener);
142
+ return () => this.#connectionListeners.delete(listener);
86
143
  }
87
144
  async authenticate(token) {
88
145
  if (!this.#unsubscribe)
@@ -94,26 +151,42 @@ export class LokiClient {
94
151
  async createRoom() {
95
152
  if (!this.#playerId)
96
153
  throw new Error("authenticate before creating a room");
97
- return this.#enterRoom(() => this.#transport.createRoom({ projectId: this.#projectId }));
154
+ return this.#serialize(() => this.#enterRoom(() => this.#transport.createRoom({ projectId: this.#projectId })));
98
155
  }
99
156
  async joinRoom(input) {
100
157
  if (!this.#playerId)
101
158
  throw new Error("authenticate before joining a room");
102
- return this.#enterRoom(() => this.#transport.joinRoom({
159
+ return this.#serialize(() => this.#enterRoom(() => this.#transport.joinRoom({
103
160
  projectId: this.#projectId,
104
161
  inviteCode: requireInviteCode(input.inviteCode),
105
- }));
162
+ })));
163
+ }
164
+ async #serialize(operation) {
165
+ const run = this.#lifecycle.then(operation, operation);
166
+ this.#lifecycle = run.then(() => undefined, () => undefined);
167
+ return run;
106
168
  }
107
169
  async #enterRoom(join) {
170
+ if (this.#leaveFailed) {
171
+ throw new Error("resolve the failed leave before joining another room");
172
+ }
173
+ const generation = ++this.#lifecycleGeneration;
108
174
  this.#joining = true;
109
175
  this.#joinBuffer = [];
176
+ let joinedRoomId = "";
110
177
  try {
111
178
  const joined = await join();
179
+ joinedRoomId = joined.roomId;
112
180
  const snapshot = ServerEnvelopeSchema.parse(joined.snapshot);
113
181
  if (snapshot.roomId !== joined.roomId || snapshot.type !== "snapshot") {
114
182
  throw new Error("invalid join snapshot");
115
183
  }
184
+ if (generation !== this.#lifecycleGeneration) {
185
+ await this.#transport.leaveRoom?.(joined.roomId).catch(() => undefined);
186
+ throw new Error("stale room join abandoned");
187
+ }
116
188
  this.#roomId = joined.roomId;
189
+ this.#inviteCode = joined.inviteCode;
117
190
  this.#sendSequence = 0;
118
191
  this.#receiveSequence = snapshot.sequence;
119
192
  const buffered = this.#joinBuffer;
@@ -127,6 +200,16 @@ export class LokiClient {
127
200
  snapshot,
128
201
  };
129
202
  }
203
+ catch (error) {
204
+ if (joinedRoomId) {
205
+ await this.#transport.leaveRoom?.(joinedRoomId).catch(() => undefined);
206
+ if (this.#roomId === joinedRoomId) {
207
+ this.#roomId = undefined;
208
+ this.#inviteCode = "";
209
+ }
210
+ }
211
+ throw error;
212
+ }
130
213
  finally {
131
214
  this.#joining = false;
132
215
  this.#joinBuffer = [];
@@ -137,10 +220,9 @@ export class LokiClient {
137
220
  message.sequence < this.#receiveSequence)
138
221
  return;
139
222
  this.#receiveSequence = message.sequence;
140
- for (const listener of this.#listeners)
141
- listener(message);
223
+ notifyListeners(this.#listeners, message);
142
224
  }
143
- async sendAction(payload) {
225
+ async sendAction(payload, options) {
144
226
  if (!this.#roomId)
145
227
  throw new Error("join a room before sending actions");
146
228
  const message = ClientEnvelopeSchema.parse({
@@ -149,6 +231,7 @@ export class LokiClient {
149
231
  sequence: ++this.#sendSequence,
150
232
  type: "action",
151
233
  payload,
234
+ actionId: options?.actionId,
152
235
  });
153
236
  await this.#transport.send(message);
154
237
  }
@@ -165,7 +248,7 @@ export class LokiClient {
165
248
  });
166
249
  await this.#transport.send(message);
167
250
  }
168
- async sendHostState(expectedVersion, state) {
251
+ async sendHostState(expectedVersion, state, options) {
169
252
  if (!this.#roomId)
170
253
  throw new Error("join a room before sending state");
171
254
  const message = ClientEnvelopeSchema.parse({
@@ -174,10 +257,28 @@ export class LokiClient {
174
257
  sequence: ++this.#sendSequence,
175
258
  type: "host_state",
176
259
  expectedVersion,
260
+ expectedStateVersion: options?.expectedStateVersion,
261
+ actionId: options?.actionId,
262
+ senderId: options?.senderId,
177
263
  state,
178
264
  });
179
265
  await this.#transport.send(message);
180
266
  }
267
+ async sendActionRejection(actionId, outcome, message, options) {
268
+ if (!this.#roomId)
269
+ throw new Error("join a room before rejecting actions");
270
+ const envelope = ClientEnvelopeSchema.parse({
271
+ protocolVersion: PROTOCOL_VERSION,
272
+ roomId: this.#roomId,
273
+ sequence: ++this.#sendSequence,
274
+ type: "action_reject",
275
+ actionId,
276
+ senderId: options?.senderId,
277
+ outcome,
278
+ message: protocolRejectionMessage(message),
279
+ });
280
+ await this.#transport.send(envelope);
281
+ }
181
282
  async requestSnapshot() {
182
283
  await this.#sendRoomMessage({ type: "snapshot_request" });
183
284
  }
@@ -202,21 +303,49 @@ export class LokiClient {
202
303
  throw new Error("authenticate before matchmaking");
203
304
  if (!this.#transport.matchmake)
204
305
  throw new Error("matchmaking is unsupported");
205
- return this.#enterRoom(() => this.#transport.matchmake(input));
306
+ return this.#serialize(() => this.#enterRoom(() => this.#transport.matchmake(input)));
206
307
  }
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;
308
+ async leaveRoom(roomId) {
309
+ this.#lifecycleGeneration += 1;
310
+ return this.#serialize(async () => {
311
+ const target = roomId || this.#roomId;
312
+ if (!target)
313
+ return;
314
+ try {
315
+ await this.#transport.leaveRoom?.(target);
316
+ if (this.#roomId === target) {
317
+ this.#roomId = undefined;
318
+ this.#inviteCode = "";
319
+ this.#sendSequence = 0;
320
+ this.#receiveSequence = 0;
321
+ this.#leaveFailed = false;
322
+ }
323
+ }
324
+ catch (error) {
325
+ if (this.#roomId === target || !this.#roomId) {
326
+ this.#roomId = target;
327
+ this.#leaveFailed = true;
328
+ }
329
+ throw error;
330
+ }
331
+ });
214
332
  }
215
333
  async reconnect() {
216
- if (!this.#transport.reconnect)
217
- throw new Error("reconnect is unsupported");
218
- await this.#transport.reconnect();
219
- await this.requestSnapshot();
334
+ if (this.#reconnectPromise)
335
+ return this.#reconnectPromise;
336
+ this.#reconnectPromise = this.#serialize(async () => {
337
+ if (!this.#transport.reconnect)
338
+ throw new Error("reconnect is unsupported");
339
+ if (this.#leaveFailed)
340
+ throw new Error("resolve the failed leave before reconnecting");
341
+ if (!this.#roomId)
342
+ throw new Error("join a room before reconnecting");
343
+ await this.#transport.reconnect();
344
+ await this.requestSnapshot();
345
+ }).finally(() => {
346
+ this.#reconnectPromise = undefined;
347
+ });
348
+ return this.#reconnectPromise;
220
349
  }
221
350
  async refresh() {
222
351
  if (!this.#transport.refresh)
@@ -239,9 +368,16 @@ export class LokiClient {
239
368
  await this.#transport.send(message);
240
369
  }
241
370
  async close() {
371
+ this.#lifecycleGeneration += 1;
372
+ this.#leaveFailed = false;
373
+ this.#roomId = undefined;
374
+ this.#inviteCode = "";
242
375
  this.#unsubscribe?.();
243
376
  this.#unsubscribe = undefined;
377
+ this.#unsubscribeConnection?.();
378
+ this.#unsubscribeConnection = undefined;
244
379
  this.#listeners.clear();
380
+ this.#connectionListeners.clear();
245
381
  await this.#transport.close();
246
382
  }
247
383
  }
@@ -258,6 +394,9 @@ export class FirstPartyTransport {
258
394
  #roomId;
259
395
  #roomKey;
260
396
  #closed = false;
397
+ #connectionListeners = new Set();
398
+ #ignoreDisconnect = false;
399
+ #reconnectPromise;
261
400
  constructor(options = {}) {
262
401
  this.#apiOrigin = (options.apiOrigin ?? LOKI_API_ORIGIN).replace(/\/+$/, "");
263
402
  this.#secure = options.secure ?? true;
@@ -294,13 +433,24 @@ export class FirstPartyTransport {
294
433
  throw new Error("Loki did not return a room invite");
295
434
  }
296
435
  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
- };
436
+ try {
437
+ const snapshot = await this.#snapshot(created.matchId);
438
+ this.#roomId = created.matchId;
439
+ this.#roomKey = created.roomKey;
440
+ return {
441
+ roomId: created.matchId,
442
+ inviteCode: created.inviteCode,
443
+ snapshot,
444
+ };
445
+ }
446
+ catch (error) {
447
+ await socket.leaveMatch(created.matchId).catch(() => undefined);
448
+ if (this.#roomId === created.matchId) {
449
+ this.#roomId = undefined;
450
+ this.#roomKey = undefined;
451
+ }
452
+ throw error;
453
+ }
304
454
  }
305
455
  async joinRoom(input) {
306
456
  const session = this.#requireSession();
@@ -310,12 +460,21 @@ export class FirstPartyTransport {
310
460
  if (!joined.matchId)
311
461
  throw new Error("Loki did not return a room");
312
462
  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
- };
463
+ try {
464
+ const snapshot = await this.#snapshot(joined.matchId);
465
+ this.#roomId = joined.matchId;
466
+ return {
467
+ roomId: joined.matchId,
468
+ inviteCode: joined.inviteCode ?? inviteCode,
469
+ snapshot,
470
+ };
471
+ }
472
+ catch (error) {
473
+ await socket.leaveMatch(joined.matchId).catch(() => undefined);
474
+ if (this.#roomId === joined.matchId)
475
+ this.#roomId = undefined;
476
+ throw error;
477
+ }
319
478
  }
320
479
  async resolveInvite(inviteCode) {
321
480
  const normalized = requireInviteCode(inviteCode);
@@ -343,13 +502,24 @@ export class FirstPartyTransport {
343
502
  });
344
503
  const result = await matched;
345
504
  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
- };
505
+ try {
506
+ const snapshot = await this.#snapshot(joined.match_id);
507
+ this.#roomId = joined.match_id;
508
+ this.#roomKey = `match-${joined.match_id.slice(0, 12).toLowerCase()}`;
509
+ return {
510
+ roomId: joined.match_id,
511
+ inviteCode: "",
512
+ snapshot,
513
+ };
514
+ }
515
+ catch (error) {
516
+ await socket.leaveMatch(joined.match_id).catch(() => undefined);
517
+ if (this.#roomId === joined.match_id) {
518
+ this.#roomId = undefined;
519
+ this.#roomKey = undefined;
520
+ }
521
+ throw error;
522
+ }
353
523
  }
354
524
  async send(message) {
355
525
  const socket = await this.#connectSocket();
@@ -359,22 +529,32 @@ export class FirstPartyTransport {
359
529
  ? 11
360
530
  : message.type === "host_state"
361
531
  ? 12
362
- : message.type === "snapshot_request"
363
- ? 13
364
- : message.type === "chat"
365
- ? 14
366
- : 15;
532
+ : message.type === "action_reject"
533
+ ? 16
534
+ : message.type === "snapshot_request"
535
+ ? 13
536
+ : message.type === "chat"
537
+ ? 14
538
+ : 15;
367
539
  await socket.sendMatchState(message.roomId, opCode, JSON.stringify(message));
368
540
  }
369
541
  subscribe(listener) {
370
542
  this.#listeners.add(listener);
371
543
  return () => this.#listeners.delete(listener);
372
544
  }
545
+ subscribeConnection(listener) {
546
+ this.#connectionListeners.add(listener);
547
+ return () => this.#connectionListeners.delete(listener);
548
+ }
373
549
  async leaveRoom(roomId) {
374
- await this.#socket?.leaveMatch(roomId);
375
- if (this.#roomId === roomId) {
376
- this.#roomId = undefined;
377
- this.#roomKey = undefined;
550
+ try {
551
+ await this.#socket?.leaveMatch(roomId);
552
+ }
553
+ finally {
554
+ if (this.#roomId === roomId) {
555
+ this.#roomId = undefined;
556
+ this.#roomKey = undefined;
557
+ }
378
558
  }
379
559
  }
380
560
  async refresh() {
@@ -383,19 +563,39 @@ export class FirstPartyTransport {
383
563
  async reconnect() {
384
564
  if (this.#closed)
385
565
  throw new Error("transport is closed");
386
- this.#socket?.disconnect(false);
566
+ if (this.#reconnectPromise)
567
+ return this.#reconnectPromise;
568
+ this.#reconnectPromise = this.#reconnectOnce()
569
+ .then(() => {
570
+ notifyListeners(this.#connectionListeners, "connected");
571
+ })
572
+ .finally(() => {
573
+ this.#reconnectPromise = undefined;
574
+ });
575
+ return this.#reconnectPromise;
576
+ }
577
+ async #reconnectOnce() {
578
+ this.#ignoreDisconnect = true;
579
+ const previous = this.#socket;
387
580
  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);
581
+ try {
582
+ previous?.disconnect(false);
583
+ if (this.#session?.isexpired(Math.floor(Date.now() / 1_000)))
584
+ await this.refresh();
585
+ const socket = await this.#connectSocket();
586
+ if (this.#roomId)
587
+ await socket.joinMatch(this.#roomId);
588
+ }
589
+ finally {
590
+ this.#ignoreDisconnect = false;
591
+ }
393
592
  }
394
593
  async close() {
395
594
  this.#closed = true;
396
595
  this.#socket?.disconnect(false);
397
596
  this.#socket = undefined;
398
597
  this.#listeners.clear();
598
+ this.#connectionListeners.clear();
399
599
  }
400
600
  async #connectSocket() {
401
601
  if (this.#closed)
@@ -408,16 +608,24 @@ export class FirstPartyTransport {
408
608
  return;
409
609
  try {
410
610
  const decoded = JSON.parse(textDecoder.decode(message.data));
411
- for (const listener of this.#listeners)
412
- listener(decoded);
611
+ notifyListeners(this.#listeners, decoded);
413
612
  }
414
613
  catch {
415
614
  // Invalid server data is ignored and cannot reach game listeners.
416
615
  }
417
616
  };
418
617
  socket.ondisconnect = () => {
419
- if (!this.#closed)
420
- void this.reconnect().catch(() => undefined);
618
+ if (this.#socket !== socket)
619
+ return;
620
+ this.#socket = undefined;
621
+ if (this.#ignoreDisconnect)
622
+ return;
623
+ notifyListeners(this.#connectionListeners, "disconnected");
624
+ if (this.#closed || this.#reconnectPromise)
625
+ return;
626
+ void this.reconnect().catch(() => {
627
+ notifyListeners(this.#connectionListeners, "reconnect_failed");
628
+ });
421
629
  };
422
630
  await socket.connect(this.#requireSession(), true);
423
631
  this.#socket = socket;
@@ -436,6 +644,8 @@ export class FirstPartyTransport {
436
644
  type: "snapshot",
437
645
  hostId: state.hostId,
438
646
  state: state.state,
647
+ stateVersion: state.stateVersion ?? state.version,
648
+ capabilities: state.capabilities,
439
649
  });
440
650
  }
441
651
  #requireSession() {