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