@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 +36 -4
- package/dist/index.d.ts +22 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +270 -60
- 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.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.
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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();
|
|
@@ -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 (
|
|
53
|
-
throw new Error("INVITE_INVALID: invite codes are
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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 (
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
roomId
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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 === "
|
|
363
|
-
?
|
|
364
|
-
: message.type === "
|
|
365
|
-
?
|
|
366
|
-
:
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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.#
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
await
|
|
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
|
-
|
|
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 (
|
|
420
|
-
|
|
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() {
|