castle-web-sdk 0.4.36 → 0.4.38
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/dist/castle.d.ts +3 -3
- package/dist/commands.d.ts +46 -1
- package/dist/multiplayer.d.ts +85 -2
- package/dist/multiplayer.js +422 -12
- package/dist/multiplayerProtocol.d.ts +54 -2
- package/dist/multiplayerVoice.d.ts +26 -2
- package/dist/multiplayerVoice.js +118 -3
- package/dist/server/platformHandle.d.ts +54 -0
- package/dist/server/wrapper.d.ts +79 -1
- package/dist/server/wrapper.js +334 -0
- package/dist/transport.js +1 -0
- package/package.json +1 -1
package/dist/multiplayer.js
CHANGED
|
@@ -7,12 +7,24 @@ const HELLO_TIMEOUT_MS = 10_000;
|
|
|
7
7
|
const RECONNECT_BUDGET_MS = 30_000;
|
|
8
8
|
const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];
|
|
9
9
|
const INVALID_DEV_RECONNECT_CLOSE_CODE = 4003;
|
|
10
|
+
// The host may spend the full 15-second grant window waiting for author admission,
|
|
11
|
+
// then up to 10 seconds on the fenced control-plane request. Keep the client above
|
|
12
|
+
// that complete lifecycle so it cannot discard a valid durable receipt.
|
|
13
|
+
const CHARACTER_ADMISSION_TIMEOUT_MS = 30_000;
|
|
14
|
+
const WORLD_CHARACTER_OPERATION_ID_MAX_CHARACTERS = 120;
|
|
15
|
+
const WORLD_CHARACTER_ID_MAX_CHARACTERS = 100;
|
|
16
|
+
const WORLD_CHARACTER_INPUT_MAX_CHARACTERS = 2000;
|
|
17
|
+
let nextCharacterStatusRequestId = 1;
|
|
10
18
|
let activeConnection = null;
|
|
11
|
-
|
|
19
|
+
let latestJoinAttempt = 0;
|
|
20
|
+
let pendingDestinationJoin = null;
|
|
21
|
+
export const Multiplayer = { join, joinDestination };
|
|
12
22
|
async function join(options) {
|
|
13
23
|
const params = joinParams(options);
|
|
24
|
+
latestJoinAttempt += 1;
|
|
25
|
+
cancelPendingDestinationJoin();
|
|
14
26
|
activeConnection?.leave();
|
|
15
|
-
const connection = new MultiplayerConnectionImpl(params, () => {
|
|
27
|
+
const connection = new MultiplayerConnectionImpl({ command: "multiplayer.getSession", params }, () => {
|
|
16
28
|
if (activeConnection === connection)
|
|
17
29
|
activeConnection = null;
|
|
18
30
|
});
|
|
@@ -20,9 +32,80 @@ async function join(options) {
|
|
|
20
32
|
await connection.start();
|
|
21
33
|
return connection;
|
|
22
34
|
}
|
|
35
|
+
async function joinDestination(options) {
|
|
36
|
+
const params = destinationParams(options);
|
|
37
|
+
const attempt = ++latestJoinAttempt;
|
|
38
|
+
cancelPendingDestinationJoin();
|
|
39
|
+
if (activeConnection?.matchesDestination(params)) {
|
|
40
|
+
return {
|
|
41
|
+
status: "joined",
|
|
42
|
+
connection: activeConnection,
|
|
43
|
+
audience: activeConnection.destinationAudience,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const previousConnection = activeConnection;
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
const pending = { controller, connection: null };
|
|
49
|
+
pendingDestinationJoin = pending;
|
|
50
|
+
let acquired;
|
|
51
|
+
try {
|
|
52
|
+
acquired = await hostRequest("multiplayer.joinDestination", params, {
|
|
53
|
+
signal: controller.signal,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (pendingDestinationJoin === pending)
|
|
58
|
+
pendingDestinationJoin = null;
|
|
59
|
+
if (attempt !== latestJoinAttempt || controller.signal.aborted) {
|
|
60
|
+
return { status: "cancelled" };
|
|
61
|
+
}
|
|
62
|
+
const status = destinationStatusFromError(error);
|
|
63
|
+
if (status)
|
|
64
|
+
return { status };
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
if (attempt !== latestJoinAttempt)
|
|
68
|
+
return { status: "cancelled" };
|
|
69
|
+
if (acquired.status !== "ok") {
|
|
70
|
+
if (pendingDestinationJoin === pending)
|
|
71
|
+
pendingDestinationJoin = null;
|
|
72
|
+
return { status: acquired.status };
|
|
73
|
+
}
|
|
74
|
+
if (!acquired.url || !acquired.nonce || !acquired.audience) {
|
|
75
|
+
if (pendingDestinationJoin === pending)
|
|
76
|
+
pendingDestinationJoin = null;
|
|
77
|
+
return { status: "unavailable" };
|
|
78
|
+
}
|
|
79
|
+
const connection = new MultiplayerConnectionImpl({ command: "multiplayer.joinDestination", params }, () => {
|
|
80
|
+
if (activeConnection === connection)
|
|
81
|
+
activeConnection = null;
|
|
82
|
+
}, acquired.audience);
|
|
83
|
+
pending.connection = connection;
|
|
84
|
+
try {
|
|
85
|
+
await connection.start(acquired);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (pendingDestinationJoin === pending)
|
|
89
|
+
pendingDestinationJoin = null;
|
|
90
|
+
if (attempt !== latestJoinAttempt || controller.signal.aborted) {
|
|
91
|
+
return { status: "cancelled" };
|
|
92
|
+
}
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
if (pendingDestinationJoin === pending)
|
|
96
|
+
pendingDestinationJoin = null;
|
|
97
|
+
if (attempt !== latestJoinAttempt) {
|
|
98
|
+
connection.leave();
|
|
99
|
+
return { status: "cancelled" };
|
|
100
|
+
}
|
|
101
|
+
previousConnection?.leave();
|
|
102
|
+
activeConnection = connection;
|
|
103
|
+
return { status: "joined", connection, audience: acquired.audience };
|
|
104
|
+
}
|
|
23
105
|
class MultiplayerConnectionImpl {
|
|
24
|
-
|
|
106
|
+
request;
|
|
25
107
|
onClosed;
|
|
108
|
+
destinationAudience;
|
|
26
109
|
currentState = "acquiring";
|
|
27
110
|
currentPlayerId = "";
|
|
28
111
|
currentSessionId = "";
|
|
@@ -37,6 +120,8 @@ class MultiplayerConnectionImpl {
|
|
|
37
120
|
reAcquisitionUsed = false;
|
|
38
121
|
recovering = false;
|
|
39
122
|
roster = null;
|
|
123
|
+
sharedCharactersSupported = false;
|
|
124
|
+
pendingCharacterRequests = new Map();
|
|
40
125
|
messages = new Set();
|
|
41
126
|
rosters = new Set();
|
|
42
127
|
states = new Set();
|
|
@@ -45,9 +130,10 @@ class MultiplayerConnectionImpl {
|
|
|
45
130
|
players: () => this.roster?.players ?? [],
|
|
46
131
|
isSolo: () => this.solo,
|
|
47
132
|
});
|
|
48
|
-
constructor(
|
|
49
|
-
this.
|
|
133
|
+
constructor(request, onClosed, destinationAudience = null) {
|
|
134
|
+
this.request = request;
|
|
50
135
|
this.onClosed = onClosed;
|
|
136
|
+
this.destinationAudience = destinationAudience;
|
|
51
137
|
}
|
|
52
138
|
get state() {
|
|
53
139
|
return this.currentState;
|
|
@@ -67,9 +153,9 @@ class MultiplayerConnectionImpl {
|
|
|
67
153
|
get voice() {
|
|
68
154
|
return this.voiceImpl;
|
|
69
155
|
}
|
|
70
|
-
async start() {
|
|
156
|
+
async start(initial) {
|
|
71
157
|
try {
|
|
72
|
-
await this.acquire();
|
|
158
|
+
await this.acquire(initial);
|
|
73
159
|
}
|
|
74
160
|
catch (error) {
|
|
75
161
|
const castleError = asCastleError(error);
|
|
@@ -97,6 +183,60 @@ class MultiplayerConnectionImpl {
|
|
|
97
183
|
return;
|
|
98
184
|
this.socket.send(payload);
|
|
99
185
|
}
|
|
186
|
+
requestCharacterTurn(request) {
|
|
187
|
+
const normalized = normalizeCharacterTurnRequest(request, this.currentPlayerId);
|
|
188
|
+
return this.sendCharacterRequest(normalized.requestId, normalized.operationId, "admission", JSON.stringify({ t: "worldCharacterTurn", ...normalized }));
|
|
189
|
+
}
|
|
190
|
+
getCharacterTurnStatus(operationId) {
|
|
191
|
+
const normalizedOperationId = boundedCharacterText(operationId, 128, "operationId", WORLD_CHARACTER_OPERATION_ID_MAX_CHARACTERS);
|
|
192
|
+
const requestId = `status-${Date.now().toString(36)}-${nextCharacterStatusRequestId++}`;
|
|
193
|
+
return this.sendCharacterRequest(requestId, normalizedOperationId, "status", JSON.stringify({
|
|
194
|
+
t: "worldCharacterStatus",
|
|
195
|
+
requestId,
|
|
196
|
+
operationId: normalizedOperationId,
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
requestCharacterCommand(request) {
|
|
200
|
+
const normalized = normalizeWorldCharacterCommandRequest(request);
|
|
201
|
+
return this.sendCharacterRequest(normalized.requestId, normalized.operationId, "command", JSON.stringify({ t: "worldCharacterCommand", ...normalized }));
|
|
202
|
+
}
|
|
203
|
+
readCharacters(request) {
|
|
204
|
+
const normalized = normalizeWorldCharacterReadRequest(request);
|
|
205
|
+
return this.sendCharacterRequest(normalized.requestId, "", "read", JSON.stringify({ t: "worldCharacterRead", ...normalized }));
|
|
206
|
+
}
|
|
207
|
+
sendCharacterRequest(requestId, operationId, kind, frame) {
|
|
208
|
+
this.assertOpen();
|
|
209
|
+
if (!this.sharedCharactersSupported) {
|
|
210
|
+
return Promise.reject(characterError("CAULDRON_CHARACTER_UNAVAILABLE", "This multiplayer session does not support shared characters."));
|
|
211
|
+
}
|
|
212
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
213
|
+
return Promise.reject(characterError("MULTIPLAYER_NOT_CONNECTED", "Shared character requests require a connected session."));
|
|
214
|
+
}
|
|
215
|
+
if (this.pendingCharacterRequests.has(requestId)) {
|
|
216
|
+
return Promise.reject(characterError("CAULDRON_CHARACTER_CONFLICT", "A shared character request with this requestId is already pending."));
|
|
217
|
+
}
|
|
218
|
+
return new Promise((resolve, reject) => {
|
|
219
|
+
const timer = setTimeout(() => {
|
|
220
|
+
this.pendingCharacterRequests.delete(requestId);
|
|
221
|
+
reject(characterError("CAULDRON_CHARACTER_TIMEOUT", "Shared character admission timed out. Retry with the same operationId."));
|
|
222
|
+
}, CHARACTER_ADMISSION_TIMEOUT_MS);
|
|
223
|
+
this.pendingCharacterRequests.set(requestId, {
|
|
224
|
+
operationId,
|
|
225
|
+
kind,
|
|
226
|
+
resolve: (value) => resolve(value),
|
|
227
|
+
reject,
|
|
228
|
+
timer,
|
|
229
|
+
});
|
|
230
|
+
try {
|
|
231
|
+
this.socket.send(frame);
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
this.pendingCharacterRequests.delete(requestId);
|
|
236
|
+
reject(asCastleError(error));
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
100
240
|
onMessage(callback) {
|
|
101
241
|
this.messages.add(callback);
|
|
102
242
|
return () => this.messages.delete(callback);
|
|
@@ -117,9 +257,16 @@ class MultiplayerConnectionImpl {
|
|
|
117
257
|
return;
|
|
118
258
|
this.finish({ type: "left" });
|
|
119
259
|
}
|
|
120
|
-
|
|
260
|
+
matchesDestination(params) {
|
|
261
|
+
return (this.currentState !== "closed" &&
|
|
262
|
+
this.request.command === "multiplayer.joinDestination" &&
|
|
263
|
+
this.request.params.routeId === params.routeId &&
|
|
264
|
+
this.request.params.slot === params.slot &&
|
|
265
|
+
this.destinationAudience !== null);
|
|
266
|
+
}
|
|
267
|
+
async acquire(initial) {
|
|
121
268
|
this.transition("acquiring");
|
|
122
|
-
const result = await
|
|
269
|
+
const result = initial ?? await requestAcquisition(this.request);
|
|
123
270
|
this.assertOpen();
|
|
124
271
|
if (result.status === "solo") {
|
|
125
272
|
this.connectSolo(result);
|
|
@@ -131,8 +278,11 @@ class MultiplayerConnectionImpl {
|
|
|
131
278
|
this.solo = false;
|
|
132
279
|
this.currentSoloReason = null;
|
|
133
280
|
this.baseUrl = result.url;
|
|
134
|
-
|
|
135
|
-
?
|
|
281
|
+
const devReconnectScope = "devReconnectScope" in result
|
|
282
|
+
? result.devReconnectScope
|
|
283
|
+
: undefined;
|
|
284
|
+
this.devReconnectStorageKey = devReconnectScope
|
|
285
|
+
? `castle-dev-mp-reconnect:${result.url}:${result.sessionId}:${devReconnectScope}`
|
|
136
286
|
: null;
|
|
137
287
|
this.transition("connecting");
|
|
138
288
|
const savedToken = this.devReconnectStorageKey
|
|
@@ -255,6 +405,7 @@ class MultiplayerConnectionImpl {
|
|
|
255
405
|
this.currentPlayerId = frame.playerId;
|
|
256
406
|
this.currentSessionId = frame.sessionId;
|
|
257
407
|
this.reconnectToken = frame.reconnectToken;
|
|
408
|
+
this.sharedCharactersSupported = frame.capabilities?.sharedCharactersV1 === true;
|
|
258
409
|
if (this.devReconnectStorageKey)
|
|
259
410
|
writeDevReconnectToken(this.devReconnectStorageKey, frame.reconnectToken);
|
|
260
411
|
// Re-acquisition is limited to one per outage, not one per session. A phone
|
|
@@ -274,6 +425,9 @@ class MultiplayerConnectionImpl {
|
|
|
274
425
|
for (const callback of this.messages)
|
|
275
426
|
callListener(callback, frame.d);
|
|
276
427
|
break;
|
|
428
|
+
case "worldCharacterResult":
|
|
429
|
+
this.acceptWorldCharacterResult(frame);
|
|
430
|
+
break;
|
|
277
431
|
case "replaced":
|
|
278
432
|
this.finish({ type: "replaced" });
|
|
279
433
|
break;
|
|
@@ -312,6 +466,7 @@ class MultiplayerConnectionImpl {
|
|
|
312
466
|
if (this.recovering || this.currentState === "closed")
|
|
313
467
|
return;
|
|
314
468
|
this.recovering = true;
|
|
469
|
+
this.rejectPendingCharacterRequests("MULTIPLAYER_CONNECTION_LOST", "The connection was lost before shared character admission completed.");
|
|
315
470
|
this.transition("reconnecting", { type: "connectionLost" });
|
|
316
471
|
void this.recover()
|
|
317
472
|
.catch((error) => {
|
|
@@ -398,6 +553,7 @@ class MultiplayerConnectionImpl {
|
|
|
398
553
|
(metadata.type === "left" || metadata.type === "bye" || metadata.type === "replaced"))
|
|
399
554
|
clearDevReconnectToken(this.devReconnectStorageKey, this.reconnectToken);
|
|
400
555
|
this.stopKeepalive();
|
|
556
|
+
this.rejectPendingCharacterRequests("MULTIPLAYER_CONNECTION_CLOSED", "The multiplayer connection closed before the shared character request completed.");
|
|
401
557
|
const socket = this.socket;
|
|
402
558
|
this.socket = null;
|
|
403
559
|
this.socketAttempt += 1;
|
|
@@ -413,6 +569,68 @@ class MultiplayerConnectionImpl {
|
|
|
413
569
|
throw multiplayerError("MULTIPLAYER_CONNECTION_CLOSED", "The multiplayer connection is closed.");
|
|
414
570
|
}
|
|
415
571
|
}
|
|
572
|
+
acceptWorldCharacterResult(frame) {
|
|
573
|
+
const pending = this.pendingCharacterRequests.get(frame.requestId);
|
|
574
|
+
if (!pending)
|
|
575
|
+
return;
|
|
576
|
+
clearTimeout(pending.timer);
|
|
577
|
+
this.pendingCharacterRequests.delete(frame.requestId);
|
|
578
|
+
if (!frame.ok) {
|
|
579
|
+
pending.reject(characterError(frame.error?.code ?? "CAULDRON_CHARACTER_UNAVAILABLE", frame.error?.message ?? "Shared character admission failed."));
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
if (pending.kind === "command" || pending.kind === "read") {
|
|
583
|
+
if (pending.kind === "command" && frame.operationId !== pending.operationId) {
|
|
584
|
+
pending.reject(characterError("CAULDRON_CHARACTER_UNAVAILABLE", "The host returned an invalid command receipt."));
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
pending.resolve(frame.value);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (frame.operationId !== pending.operationId || typeof frame.replayed !== "boolean") {
|
|
591
|
+
pending.reject(characterError("CAULDRON_CHARACTER_UNAVAILABLE", "The host returned an invalid shared character receipt."));
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
if (pending.kind === "admission") {
|
|
595
|
+
if (frame.status !== "queued" && frame.status !== "completed" && frame.status !== "interrupted") {
|
|
596
|
+
pending.reject(characterError("CAULDRON_CHARACTER_UNAVAILABLE", "The host returned an invalid admission status."));
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
pending.resolve({ operationId: frame.operationId, status: frame.status, replayed: frame.replayed });
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (!frame.status ||
|
|
603
|
+
!["queued", "running", "completed", "interrupted"].includes(frame.status) ||
|
|
604
|
+
!frame.jobStatus ||
|
|
605
|
+
!["queued", "running", "completed", "interrupted"].includes(frame.jobStatus) ||
|
|
606
|
+
!frame.turnStatus ||
|
|
607
|
+
!["admitted", "reply_committed", "completed", "interrupted"].includes(frame.turnStatus) ||
|
|
608
|
+
(frame.response !== undefined && typeof frame.response !== "string") ||
|
|
609
|
+
(frame.progress !== undefined &&
|
|
610
|
+
(!isRecord(frame.progress) || typeof frame.progress.response !== "string")) ||
|
|
611
|
+
(frame.response !== undefined && frame.progress !== undefined)) {
|
|
612
|
+
pending.reject(characterError("CAULDRON_CHARACTER_UNAVAILABLE", "The host returned an invalid character status."));
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
pending.resolve({
|
|
616
|
+
operationId: frame.operationId,
|
|
617
|
+
status: frame.status,
|
|
618
|
+
jobStatus: frame.jobStatus,
|
|
619
|
+
turnStatus: frame.turnStatus,
|
|
620
|
+
...(frame.response === undefined ? {} : { response: frame.response }),
|
|
621
|
+
...(frame.progress === undefined
|
|
622
|
+
? {}
|
|
623
|
+
: { progress: { response: frame.progress.response } }),
|
|
624
|
+
replayed: frame.replayed,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
rejectPendingCharacterRequests(code, message) {
|
|
628
|
+
for (const pending of this.pendingCharacterRequests.values()) {
|
|
629
|
+
clearTimeout(pending.timer);
|
|
630
|
+
pending.reject(characterError(code, message));
|
|
631
|
+
}
|
|
632
|
+
this.pendingCharacterRequests.clear();
|
|
633
|
+
}
|
|
416
634
|
}
|
|
417
635
|
class SocketClosedError extends Error {
|
|
418
636
|
code;
|
|
@@ -436,9 +654,187 @@ function joinParams(options) {
|
|
|
436
654
|
}
|
|
437
655
|
throw invalidJoinOptions();
|
|
438
656
|
}
|
|
657
|
+
function destinationParams(options) {
|
|
658
|
+
if (!options ||
|
|
659
|
+
typeof options !== "object" ||
|
|
660
|
+
typeof options.routeId !== "string" ||
|
|
661
|
+
!/^[A-Za-z0-9_-]{1,64}$/.test(options.routeId) ||
|
|
662
|
+
typeof options.slot !== "string" ||
|
|
663
|
+
options.slot.length === 0 ||
|
|
664
|
+
options.slot.includes("\0") ||
|
|
665
|
+
!isValidUnicode(options.slot) ||
|
|
666
|
+
new TextEncoder().encode(options.slot).byteLength > 128) {
|
|
667
|
+
throw multiplayerError("INVALID_ARGUMENT", "Multiplayer.joinDestination requires a valid routeId and a non-empty slot of at most 128 bytes.", "Multiplayer.joinDestination");
|
|
668
|
+
}
|
|
669
|
+
return { routeId: options.routeId, slot: options.slot };
|
|
670
|
+
}
|
|
671
|
+
function cancelPendingDestinationJoin() {
|
|
672
|
+
const pending = pendingDestinationJoin;
|
|
673
|
+
pendingDestinationJoin = null;
|
|
674
|
+
pending?.controller.abort();
|
|
675
|
+
pending?.connection?.leave();
|
|
676
|
+
}
|
|
677
|
+
async function requestAcquisition(request) {
|
|
678
|
+
if (request.command === "multiplayer.getSession") {
|
|
679
|
+
return hostRequest(request.command, request.params);
|
|
680
|
+
}
|
|
681
|
+
return hostRequest(request.command, request.params);
|
|
682
|
+
}
|
|
683
|
+
function destinationStatusFromError(error) {
|
|
684
|
+
if (!(error instanceof CastleError))
|
|
685
|
+
return null;
|
|
686
|
+
switch (error.code) {
|
|
687
|
+
case "CAULDRON_DESTINATION_NOT_FOUND":
|
|
688
|
+
return "not_found";
|
|
689
|
+
case "CAULDRON_DESTINATION_FORBIDDEN":
|
|
690
|
+
return "forbidden";
|
|
691
|
+
case "CAULDRON_DESTINATION_PARTY_REQUIRED":
|
|
692
|
+
return "party_required";
|
|
693
|
+
case "CAULDRON_DESTINATION_FULL":
|
|
694
|
+
return "full";
|
|
695
|
+
case "CAULDRON_DESTINATION_UNAVAILABLE":
|
|
696
|
+
case "CAULDRON_DESTINATION_CONFIG_CHANGED":
|
|
697
|
+
return "unavailable";
|
|
698
|
+
case "CANCELLED":
|
|
699
|
+
return "cancelled";
|
|
700
|
+
default:
|
|
701
|
+
return null;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
function isValidUnicode(value) {
|
|
705
|
+
try {
|
|
706
|
+
encodeURIComponent(value);
|
|
707
|
+
return true;
|
|
708
|
+
}
|
|
709
|
+
catch {
|
|
710
|
+
return false;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
439
713
|
function invalidJoinOptions() {
|
|
440
714
|
return multiplayerError("INVALID_ARGUMENT", "Multiplayer.join requires named, party, or public options.");
|
|
441
715
|
}
|
|
716
|
+
function normalizeCharacterTurnRequest(request, speakerPlayerId) {
|
|
717
|
+
if (!request || typeof request !== "object") {
|
|
718
|
+
throw characterError("INVALID_ARGUMENT", "requestCharacterTurn requires a request object.");
|
|
719
|
+
}
|
|
720
|
+
const requestId = boundedCharacterText(request.requestId, 128, "requestId");
|
|
721
|
+
const operationId = boundedCharacterText(request.operationId, 128, "operationId", WORLD_CHARACTER_OPERATION_ID_MAX_CHARACTERS);
|
|
722
|
+
const characterId = boundedCharacterText(request.characterId, 128, "characterId", WORLD_CHARACTER_ID_MAX_CHARACTERS);
|
|
723
|
+
const input = boundedCharacterText(request.input, 8000, "input", WORLD_CHARACTER_INPUT_MAX_CHARACTERS);
|
|
724
|
+
const disclosure = request.audience?.disclosure ?? "participants";
|
|
725
|
+
if (disclosure !== "participants" && disclosure !== "world") {
|
|
726
|
+
throw characterError("INVALID_ARGUMENT", "audience.disclosure must be participants or world.");
|
|
727
|
+
}
|
|
728
|
+
const playerIds = boundedCharacterIds(request.audience?.playerIds ?? [], 7, "audience.playerIds");
|
|
729
|
+
const characterIds = boundedCharacterIds(request.audience?.characterIds ?? [], 3, "audience.characterIds", WORLD_CHARACTER_ID_MAX_CHARACTERS);
|
|
730
|
+
if (playerIds.includes(speakerPlayerId) || characterIds.includes(characterId)) {
|
|
731
|
+
throw characterError("INVALID_ARGUMENT", "The speaker and target character are implicit audience members.");
|
|
732
|
+
}
|
|
733
|
+
return {
|
|
734
|
+
requestId,
|
|
735
|
+
operationId,
|
|
736
|
+
characterId,
|
|
737
|
+
input,
|
|
738
|
+
audience: { disclosure, playerIds, characterIds },
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
function normalizeWorldCharacterCommandRequest(request) {
|
|
742
|
+
if (!request || typeof request !== "object" || !request.command || typeof request.command !== "object") {
|
|
743
|
+
throw characterError("INVALID_ARGUMENT", "requestCharacterCommand requires a command request.");
|
|
744
|
+
}
|
|
745
|
+
const requestId = boundedCharacterText(request.requestId, 128, "requestId");
|
|
746
|
+
const operationId = boundedCharacterText(request.operationId, 128, "operationId", WORLD_CHARACTER_OPERATION_ID_MAX_CHARACTERS);
|
|
747
|
+
const command = request.command;
|
|
748
|
+
if (command.type === "perform_action") {
|
|
749
|
+
return {
|
|
750
|
+
requestId,
|
|
751
|
+
operationId,
|
|
752
|
+
command: {
|
|
753
|
+
type: "perform_action",
|
|
754
|
+
characterId: boundedCharacterText(command.characterId, 128, "command.characterId", WORLD_CHARACTER_ID_MAX_CHARACTERS),
|
|
755
|
+
actionId: boundedCharacterText(command.actionId, 128, "command.actionId", WORLD_CHARACTER_ID_MAX_CHARACTERS),
|
|
756
|
+
...(command.parameters === undefined ? {} : { parameters: normalizeJson(command.parameters, "command.parameters") }),
|
|
757
|
+
...(command.consentQuote === undefined ? {} : {
|
|
758
|
+
consentQuote: boundedCharacterText(command.consentQuote, 2 * 1024, "command.consentQuote"),
|
|
759
|
+
}),
|
|
760
|
+
},
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
if (command.type === "record_event") {
|
|
764
|
+
return {
|
|
765
|
+
requestId,
|
|
766
|
+
operationId,
|
|
767
|
+
command: {
|
|
768
|
+
type: "record_event",
|
|
769
|
+
eventTypeId: boundedCharacterText(command.eventTypeId, 128, "command.eventTypeId", WORLD_CHARACTER_ID_MAX_CHARACTERS),
|
|
770
|
+
...(command.data === undefined ? {} : { data: normalizeJson(command.data, "command.data") }),
|
|
771
|
+
},
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
throw characterError("INVALID_ARGUMENT", "Unknown shared character command type.");
|
|
775
|
+
}
|
|
776
|
+
function normalizeWorldCharacterReadRequest(request) {
|
|
777
|
+
if (!request || typeof request !== "object" || !request.query || typeof request.query !== "object") {
|
|
778
|
+
throw characterError("INVALID_ARGUMENT", "readCharacters requires a query request.");
|
|
779
|
+
}
|
|
780
|
+
const requestId = boundedCharacterText(request.requestId, 128, "requestId");
|
|
781
|
+
const query = request.query;
|
|
782
|
+
if (query.type === "inspect")
|
|
783
|
+
return { requestId, query: { type: "inspect" } };
|
|
784
|
+
if (query.type === "status") {
|
|
785
|
+
return { requestId, query: { type: "status", operationId: boundedCharacterText(query.operationId, 128, "character query operationId", WORLD_CHARACTER_OPERATION_ID_MAX_CHARACTERS) } };
|
|
786
|
+
}
|
|
787
|
+
if (query.type === "history") {
|
|
788
|
+
if (query.limit !== undefined && (!Number.isInteger(query.limit) || query.limit < 1 || query.limit > 50)) {
|
|
789
|
+
throw characterError("INVALID_ARGUMENT", "Character query limit must be an integer from 1 through 50.");
|
|
790
|
+
}
|
|
791
|
+
return {
|
|
792
|
+
requestId,
|
|
793
|
+
query: {
|
|
794
|
+
type: "history",
|
|
795
|
+
...(query.characterId === undefined ? {} : { characterId: boundedCharacterText(query.characterId, 128, "character query characterId", WORLD_CHARACTER_ID_MAX_CHARACTERS) }),
|
|
796
|
+
...(query.limit === undefined ? {} : { limit: query.limit }),
|
|
797
|
+
...(query.cursor === undefined ? {} : { cursor: boundedCharacterText(query.cursor, 128, "character query cursor", 128) }),
|
|
798
|
+
},
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
throw characterError("INVALID_ARGUMENT", "Unknown shared character query type.");
|
|
802
|
+
}
|
|
803
|
+
function normalizeJson(value, field) {
|
|
804
|
+
try {
|
|
805
|
+
const text = JSON.stringify(value);
|
|
806
|
+
if (text === undefined)
|
|
807
|
+
throw new Error("not JSON");
|
|
808
|
+
return JSON.parse(text);
|
|
809
|
+
}
|
|
810
|
+
catch {
|
|
811
|
+
throw characterError("INVALID_ARGUMENT", `${field} must be JSON-serializable.`);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
function boundedCharacterIds(value, maxItems, field, maxCharacters = 128) {
|
|
815
|
+
if (!Array.isArray(value) || value.length > maxItems) {
|
|
816
|
+
throw characterError("INVALID_ARGUMENT", `${field} may contain at most ${maxItems} ids.`);
|
|
817
|
+
}
|
|
818
|
+
const ids = value.map((item) => boundedCharacterText(item, 128, field, maxCharacters));
|
|
819
|
+
if (new Set(ids).size !== ids.length) {
|
|
820
|
+
throw characterError("INVALID_ARGUMENT", `${field} may not contain duplicate ids.`);
|
|
821
|
+
}
|
|
822
|
+
return ids;
|
|
823
|
+
}
|
|
824
|
+
function boundedCharacterText(value, maxBytes, field, maxCharacters = maxBytes) {
|
|
825
|
+
if (typeof value !== "string" ||
|
|
826
|
+
value.length === 0 ||
|
|
827
|
+
value.length > maxCharacters ||
|
|
828
|
+
value.includes("\0") ||
|
|
829
|
+
!isValidUnicode(value) ||
|
|
830
|
+
new TextEncoder().encode(value).byteLength > maxBytes) {
|
|
831
|
+
throw characterError("INVALID_ARGUMENT", `${field} must be non-empty valid text of at most ${maxCharacters} characters and ${maxBytes} bytes.`);
|
|
832
|
+
}
|
|
833
|
+
return value;
|
|
834
|
+
}
|
|
835
|
+
function characterError(code, message) {
|
|
836
|
+
return new CastleError({ code, message, operation: "Multiplayer.sharedCharacters" });
|
|
837
|
+
}
|
|
442
838
|
function serializeClientMessage(data) {
|
|
443
839
|
let text;
|
|
444
840
|
try {
|
|
@@ -521,6 +917,8 @@ function parsePlatformFrame(value) {
|
|
|
521
917
|
return Object.prototype.hasOwnProperty.call(frame, "d")
|
|
522
918
|
? frame
|
|
523
919
|
: null;
|
|
920
|
+
case "worldCharacterResult":
|
|
921
|
+
return isWorldCharacterResultFrame(frame) ? frame : null;
|
|
524
922
|
case "replaced":
|
|
525
923
|
case "pong":
|
|
526
924
|
return frame;
|
|
@@ -536,7 +934,19 @@ function isHelloFrame(frame) {
|
|
|
536
934
|
return (typeof frame.playerId === "string" &&
|
|
537
935
|
typeof frame.reconnectToken === "string" &&
|
|
538
936
|
typeof frame.sessionId === "string" &&
|
|
539
|
-
isMode(frame.mode)
|
|
937
|
+
isMode(frame.mode) &&
|
|
938
|
+
(frame.capabilities === undefined ||
|
|
939
|
+
(isRecord(frame.capabilities) &&
|
|
940
|
+
(frame.capabilities.sharedCharactersV1 === undefined ||
|
|
941
|
+
typeof frame.capabilities.sharedCharactersV1 === "boolean"))));
|
|
942
|
+
}
|
|
943
|
+
function isWorldCharacterResultFrame(frame) {
|
|
944
|
+
return (typeof frame.requestId === "string" &&
|
|
945
|
+
typeof frame.ok === "boolean" &&
|
|
946
|
+
(frame.ok === true ||
|
|
947
|
+
(isRecord(frame.error) &&
|
|
948
|
+
typeof frame.error.code === "string" &&
|
|
949
|
+
typeof frame.error.message === "string")));
|
|
540
950
|
}
|
|
541
951
|
function isRosterFrame(frame) {
|
|
542
952
|
return Array.isArray(frame.players) && frame.players.every(isPlayer);
|
|
@@ -19,6 +19,9 @@ export interface ClientHelloFrame {
|
|
|
19
19
|
reconnectToken: string;
|
|
20
20
|
sessionId: string;
|
|
21
21
|
mode: SessionType;
|
|
22
|
+
capabilities?: {
|
|
23
|
+
sharedCharactersV1?: boolean;
|
|
24
|
+
};
|
|
22
25
|
}
|
|
23
26
|
export interface ClientRosterFrame {
|
|
24
27
|
t: "roster";
|
|
@@ -38,7 +41,28 @@ export interface ClientByeFrame {
|
|
|
38
41
|
export interface ClientPongFrame {
|
|
39
42
|
t: "pong";
|
|
40
43
|
}
|
|
41
|
-
export type
|
|
44
|
+
export type MultiplayerCharacterDisclosure = "participants" | "world";
|
|
45
|
+
export type MultiplayerCharacterTurnStatus = "queued" | "running" | "completed" | "interrupted";
|
|
46
|
+
export interface ClientWorldCharacterResultFrame {
|
|
47
|
+
t: "worldCharacterResult";
|
|
48
|
+
requestId: string;
|
|
49
|
+
ok: boolean;
|
|
50
|
+
operationId?: string;
|
|
51
|
+
status?: MultiplayerCharacterTurnStatus;
|
|
52
|
+
jobStatus?: MultiplayerCharacterTurnStatus;
|
|
53
|
+
turnStatus?: "admitted" | "reply_committed" | "completed" | "interrupted";
|
|
54
|
+
response?: string;
|
|
55
|
+
progress?: {
|
|
56
|
+
response: string;
|
|
57
|
+
};
|
|
58
|
+
replayed?: boolean;
|
|
59
|
+
value?: unknown;
|
|
60
|
+
error?: {
|
|
61
|
+
code: string;
|
|
62
|
+
message: string;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export type PlatformToClientFrame = ClientHelloFrame | ClientRosterFrame | ClientMessageFrame | ClientReplacedFrame | ClientByeFrame | ClientWorldCharacterResultFrame | ClientPongFrame;
|
|
42
66
|
export interface ClientToPlatformMessageFrame {
|
|
43
67
|
t: "m";
|
|
44
68
|
d: unknown;
|
|
@@ -46,7 +70,35 @@ export interface ClientToPlatformMessageFrame {
|
|
|
46
70
|
export interface ClientPingFrame {
|
|
47
71
|
t: "ping";
|
|
48
72
|
}
|
|
49
|
-
export
|
|
73
|
+
export interface ClientWorldCharacterTurnFrame {
|
|
74
|
+
t: "worldCharacterTurn";
|
|
75
|
+
requestId: string;
|
|
76
|
+
operationId: string;
|
|
77
|
+
characterId: string;
|
|
78
|
+
input: string;
|
|
79
|
+
audience: {
|
|
80
|
+
disclosure: MultiplayerCharacterDisclosure;
|
|
81
|
+
playerIds: string[];
|
|
82
|
+
characterIds: string[];
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export interface ClientWorldCharacterStatusFrame {
|
|
86
|
+
t: "worldCharacterStatus";
|
|
87
|
+
requestId: string;
|
|
88
|
+
operationId: string;
|
|
89
|
+
}
|
|
90
|
+
export interface ClientWorldCharacterCommandFrame {
|
|
91
|
+
t: "worldCharacterCommand";
|
|
92
|
+
requestId: string;
|
|
93
|
+
operationId: string;
|
|
94
|
+
command: unknown;
|
|
95
|
+
}
|
|
96
|
+
export interface ClientWorldCharacterReadFrame {
|
|
97
|
+
t: "worldCharacterRead";
|
|
98
|
+
requestId: string;
|
|
99
|
+
query: unknown;
|
|
100
|
+
}
|
|
101
|
+
export type ClientToPlatformFrame = ClientToPlatformMessageFrame | ClientWorldCharacterTurnFrame | ClientWorldCharacterStatusFrame | ClientWorldCharacterCommandFrame | ClientWorldCharacterReadFrame | ClientPingFrame;
|
|
50
102
|
export type MultiplayerMode = SessionType;
|
|
51
103
|
export type MultiplayerPlayer = SessionPlayer;
|
|
52
104
|
export type MultiplayerHelloFrame = ClientHelloFrame;
|