@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 +35 -4
- package/dist/index.d.ts +22 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +267 -58
- package/dist/index.js.map +1 -1
- package/dist/synchronized-room.d.ts +103 -0
- package/dist/synchronized-room.d.ts.map +1 -0
- package/dist/synchronized-room.js +1029 -0
- package/dist/synchronized-room.js.map +1 -0
- package/package.json +2 -2
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.
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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
|
|
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>;
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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 (
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
roomId
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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 === "
|
|
363
|
-
?
|
|
364
|
-
: message.type === "
|
|
365
|
-
?
|
|
366
|
-
:
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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.#
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
await
|
|
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
|
-
|
|
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 (
|
|
420
|
-
|
|
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() {
|