castle-web-sdk 0.4.37 → 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/multiplayer.d.ts +72 -1
- package/dist/multiplayer.js +269 -1
- package/dist/multiplayerProtocol.d.ts +54 -2
- package/dist/server/platformHandle.d.ts +41 -0
- package/dist/server/wrapper.d.ts +49 -0
- package/dist/server/wrapper.js +242 -0
- package/package.json +1 -1
package/dist/multiplayer.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { MultiplayerDestinationAudience, MultiplayerSoloReason } from "./commands";
|
|
2
2
|
import { CastleError } from "./errors";
|
|
3
|
-
import {
|
|
3
|
+
import type { Json } from "./types";
|
|
4
|
+
import { type MultiplayerPlayer, type MultiplayerCharacterDisclosure, type MultiplayerCharacterTurnStatus } from "./multiplayerProtocol";
|
|
4
5
|
import { type MultiplayerVoice } from "./multiplayerVoice";
|
|
5
6
|
export type MultiplayerJoinOptions = {
|
|
6
7
|
mode: "named";
|
|
@@ -39,6 +40,63 @@ export interface MultiplayerRoster {
|
|
|
39
40
|
players: MultiplayerPlayer[];
|
|
40
41
|
you: MultiplayerPlayer | null;
|
|
41
42
|
}
|
|
43
|
+
export interface MultiplayerCharacterAudience {
|
|
44
|
+
disclosure?: MultiplayerCharacterDisclosure;
|
|
45
|
+
playerIds?: string[];
|
|
46
|
+
characterIds?: string[];
|
|
47
|
+
}
|
|
48
|
+
export interface MultiplayerCharacterTurnRequest {
|
|
49
|
+
requestId: string;
|
|
50
|
+
operationId: string;
|
|
51
|
+
characterId: string;
|
|
52
|
+
input: string;
|
|
53
|
+
audience?: MultiplayerCharacterAudience;
|
|
54
|
+
}
|
|
55
|
+
export interface MultiplayerCharacterTurnAdmission {
|
|
56
|
+
operationId: string;
|
|
57
|
+
status: "queued" | "completed" | "interrupted";
|
|
58
|
+
replayed: boolean;
|
|
59
|
+
}
|
|
60
|
+
export interface MultiplayerCharacterTurnStatusResult {
|
|
61
|
+
operationId: string;
|
|
62
|
+
status: MultiplayerCharacterTurnStatus;
|
|
63
|
+
jobStatus: MultiplayerCharacterTurnStatus;
|
|
64
|
+
turnStatus: "admitted" | "reply_committed" | "completed" | "interrupted";
|
|
65
|
+
response?: string;
|
|
66
|
+
progress?: {
|
|
67
|
+
response: string;
|
|
68
|
+
};
|
|
69
|
+
replayed: boolean;
|
|
70
|
+
}
|
|
71
|
+
export type MultiplayerWorldCharacterCommand = {
|
|
72
|
+
type: "perform_action";
|
|
73
|
+
characterId: string;
|
|
74
|
+
actionId: string;
|
|
75
|
+
parameters?: Json;
|
|
76
|
+
consentQuote?: string;
|
|
77
|
+
} | {
|
|
78
|
+
type: "record_event";
|
|
79
|
+
eventTypeId: string;
|
|
80
|
+
data?: Json;
|
|
81
|
+
};
|
|
82
|
+
export type MultiplayerWorldCharacterQuery = {
|
|
83
|
+
type: "inspect";
|
|
84
|
+
} | {
|
|
85
|
+
type: "status";
|
|
86
|
+
operationId: string;
|
|
87
|
+
} | {
|
|
88
|
+
type: "history";
|
|
89
|
+
characterId?: string;
|
|
90
|
+
limit?: number;
|
|
91
|
+
cursor?: string;
|
|
92
|
+
};
|
|
93
|
+
export interface MultiplayerWorldCharacterCommandResult {
|
|
94
|
+
operationId: string;
|
|
95
|
+
status: "applied" | "ineligible";
|
|
96
|
+
replayed: boolean;
|
|
97
|
+
result: unknown;
|
|
98
|
+
worldRevision: string;
|
|
99
|
+
}
|
|
42
100
|
export interface MultiplayerConnection {
|
|
43
101
|
readonly state: MultiplayerConnectionState;
|
|
44
102
|
readonly playerId: string;
|
|
@@ -51,6 +109,19 @@ export interface MultiplayerConnection {
|
|
|
51
109
|
readonly soloReason: MultiplayerSoloReason | null;
|
|
52
110
|
/** Sends byte payloads as binary frames and all other payloads as JSON. */
|
|
53
111
|
send(data: unknown): void;
|
|
112
|
+
/** Requests one billed character turn through this authenticated world socket. */
|
|
113
|
+
requestCharacterTurn(request: MultiplayerCharacterTurnRequest): Promise<MultiplayerCharacterTurnAdmission>;
|
|
114
|
+
/** Recovers the durable status of a prior operation after reconnecting. */
|
|
115
|
+
getCharacterTurnStatus(operationId: string): Promise<MultiplayerCharacterTurnStatusResult>;
|
|
116
|
+
requestCharacterCommand(request: {
|
|
117
|
+
requestId: string;
|
|
118
|
+
operationId: string;
|
|
119
|
+
command: MultiplayerWorldCharacterCommand;
|
|
120
|
+
}): Promise<MultiplayerWorldCharacterCommandResult>;
|
|
121
|
+
readCharacters<T = unknown>(request: {
|
|
122
|
+
requestId: string;
|
|
123
|
+
query: MultiplayerWorldCharacterQuery;
|
|
124
|
+
}): Promise<T>;
|
|
54
125
|
/** Receives binary payloads as Uint8Array values and JSON payloads otherwise. */
|
|
55
126
|
onMessage(callback: (data: unknown) => void): () => void;
|
|
56
127
|
onRoster(callback: (roster: MultiplayerRoster) => void): () => void;
|
package/dist/multiplayer.js
CHANGED
|
@@ -7,6 +7,14 @@ 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;
|
|
12
20
|
let pendingDestinationJoin = null;
|
|
@@ -112,6 +120,8 @@ class MultiplayerConnectionImpl {
|
|
|
112
120
|
reAcquisitionUsed = false;
|
|
113
121
|
recovering = false;
|
|
114
122
|
roster = null;
|
|
123
|
+
sharedCharactersSupported = false;
|
|
124
|
+
pendingCharacterRequests = new Map();
|
|
115
125
|
messages = new Set();
|
|
116
126
|
rosters = new Set();
|
|
117
127
|
states = new Set();
|
|
@@ -173,6 +183,60 @@ class MultiplayerConnectionImpl {
|
|
|
173
183
|
return;
|
|
174
184
|
this.socket.send(payload);
|
|
175
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
|
+
}
|
|
176
240
|
onMessage(callback) {
|
|
177
241
|
this.messages.add(callback);
|
|
178
242
|
return () => this.messages.delete(callback);
|
|
@@ -341,6 +405,7 @@ class MultiplayerConnectionImpl {
|
|
|
341
405
|
this.currentPlayerId = frame.playerId;
|
|
342
406
|
this.currentSessionId = frame.sessionId;
|
|
343
407
|
this.reconnectToken = frame.reconnectToken;
|
|
408
|
+
this.sharedCharactersSupported = frame.capabilities?.sharedCharactersV1 === true;
|
|
344
409
|
if (this.devReconnectStorageKey)
|
|
345
410
|
writeDevReconnectToken(this.devReconnectStorageKey, frame.reconnectToken);
|
|
346
411
|
// Re-acquisition is limited to one per outage, not one per session. A phone
|
|
@@ -360,6 +425,9 @@ class MultiplayerConnectionImpl {
|
|
|
360
425
|
for (const callback of this.messages)
|
|
361
426
|
callListener(callback, frame.d);
|
|
362
427
|
break;
|
|
428
|
+
case "worldCharacterResult":
|
|
429
|
+
this.acceptWorldCharacterResult(frame);
|
|
430
|
+
break;
|
|
363
431
|
case "replaced":
|
|
364
432
|
this.finish({ type: "replaced" });
|
|
365
433
|
break;
|
|
@@ -398,6 +466,7 @@ class MultiplayerConnectionImpl {
|
|
|
398
466
|
if (this.recovering || this.currentState === "closed")
|
|
399
467
|
return;
|
|
400
468
|
this.recovering = true;
|
|
469
|
+
this.rejectPendingCharacterRequests("MULTIPLAYER_CONNECTION_LOST", "The connection was lost before shared character admission completed.");
|
|
401
470
|
this.transition("reconnecting", { type: "connectionLost" });
|
|
402
471
|
void this.recover()
|
|
403
472
|
.catch((error) => {
|
|
@@ -484,6 +553,7 @@ class MultiplayerConnectionImpl {
|
|
|
484
553
|
(metadata.type === "left" || metadata.type === "bye" || metadata.type === "replaced"))
|
|
485
554
|
clearDevReconnectToken(this.devReconnectStorageKey, this.reconnectToken);
|
|
486
555
|
this.stopKeepalive();
|
|
556
|
+
this.rejectPendingCharacterRequests("MULTIPLAYER_CONNECTION_CLOSED", "The multiplayer connection closed before the shared character request completed.");
|
|
487
557
|
const socket = this.socket;
|
|
488
558
|
this.socket = null;
|
|
489
559
|
this.socketAttempt += 1;
|
|
@@ -499,6 +569,68 @@ class MultiplayerConnectionImpl {
|
|
|
499
569
|
throw multiplayerError("MULTIPLAYER_CONNECTION_CLOSED", "The multiplayer connection is closed.");
|
|
500
570
|
}
|
|
501
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
|
+
}
|
|
502
634
|
}
|
|
503
635
|
class SocketClosedError extends Error {
|
|
504
636
|
code;
|
|
@@ -581,6 +713,128 @@ function isValidUnicode(value) {
|
|
|
581
713
|
function invalidJoinOptions() {
|
|
582
714
|
return multiplayerError("INVALID_ARGUMENT", "Multiplayer.join requires named, party, or public options.");
|
|
583
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
|
+
}
|
|
584
838
|
function serializeClientMessage(data) {
|
|
585
839
|
let text;
|
|
586
840
|
try {
|
|
@@ -663,6 +917,8 @@ function parsePlatformFrame(value) {
|
|
|
663
917
|
return Object.prototype.hasOwnProperty.call(frame, "d")
|
|
664
918
|
? frame
|
|
665
919
|
: null;
|
|
920
|
+
case "worldCharacterResult":
|
|
921
|
+
return isWorldCharacterResultFrame(frame) ? frame : null;
|
|
666
922
|
case "replaced":
|
|
667
923
|
case "pong":
|
|
668
924
|
return frame;
|
|
@@ -678,7 +934,19 @@ function isHelloFrame(frame) {
|
|
|
678
934
|
return (typeof frame.playerId === "string" &&
|
|
679
935
|
typeof frame.reconnectToken === "string" &&
|
|
680
936
|
typeof frame.sessionId === "string" &&
|
|
681
|
-
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")));
|
|
682
950
|
}
|
|
683
951
|
function isRosterFrame(frame) {
|
|
684
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;
|
|
@@ -34,6 +34,9 @@ export interface PlatformHandle {
|
|
|
34
34
|
on(event: "binaryMessage", callback: (connId: string, bytes: Uint8Array) => unknown): void;
|
|
35
35
|
on(event: "replaced", callback: () => unknown): void;
|
|
36
36
|
on(event: "shutdown", callback: (reason?: string) => unknown): void;
|
|
37
|
+
on(event: "worldCharacterTurn", callback: (request: PlatformWorldCharacterTurn) => unknown): void;
|
|
38
|
+
on(event: "worldCharacterCommand", callback: (request: PlatformWorldCharacterCommand) => unknown): void;
|
|
39
|
+
on(event: "worldCharacterRead", callback: (request: PlatformWorldCharacterRead) => unknown): void;
|
|
37
40
|
/**
|
|
38
41
|
* Deck storage. Strings in and strings out, like the rest of this handle, so a
|
|
39
42
|
* runtime other than the Node shim can implement it without knowing what storage is.
|
|
@@ -47,5 +50,43 @@ export interface PlatformHandle {
|
|
|
47
50
|
/** Persistent-world checkpoint broker. The host stamps the hidden world binding. */
|
|
48
51
|
worldCheckpointRead?(): Promise<string>;
|
|
49
52
|
worldCheckpointCommit?(argsJson: string): Promise<string>;
|
|
53
|
+
worldCharacterSend?(grantId: string): Promise<string>;
|
|
54
|
+
worldCharacterCommand?(grantId: string): Promise<string>;
|
|
55
|
+
worldCharacterRead?(grantId: string): Promise<string>;
|
|
56
|
+
worldCharacterReject?(grantId: string, code?: string): void;
|
|
50
57
|
readonly config: PlatformSessionConfig;
|
|
51
58
|
}
|
|
59
|
+
export interface PlatformWorldCharacterCommand {
|
|
60
|
+
grantId: string;
|
|
61
|
+
requestId: string;
|
|
62
|
+
operationId: string;
|
|
63
|
+
speaker: PlayerIdentity & {
|
|
64
|
+
playerId: string;
|
|
65
|
+
};
|
|
66
|
+
command: unknown;
|
|
67
|
+
}
|
|
68
|
+
export interface PlatformWorldCharacterRead {
|
|
69
|
+
grantId: string;
|
|
70
|
+
requestId: string;
|
|
71
|
+
speaker: PlayerIdentity & {
|
|
72
|
+
playerId: string;
|
|
73
|
+
};
|
|
74
|
+
query: unknown;
|
|
75
|
+
}
|
|
76
|
+
export interface PlatformWorldCharacterTurn {
|
|
77
|
+
grantId: string;
|
|
78
|
+
requestId: string;
|
|
79
|
+
operationId: string;
|
|
80
|
+
speaker: PlayerIdentity & {
|
|
81
|
+
playerId: string;
|
|
82
|
+
};
|
|
83
|
+
characterId: string;
|
|
84
|
+
input: string;
|
|
85
|
+
audience: {
|
|
86
|
+
disclosure: "participants" | "world";
|
|
87
|
+
players: Array<PlayerIdentity & {
|
|
88
|
+
playerId: string;
|
|
89
|
+
}>;
|
|
90
|
+
characterIds: string[];
|
|
91
|
+
};
|
|
92
|
+
}
|
package/dist/server/wrapper.d.ts
CHANGED
|
@@ -52,6 +52,52 @@ export interface MultiplayerServerWorld {
|
|
|
52
52
|
read(): Promise<MultiplayerWorldSnapshot>;
|
|
53
53
|
/** Commits exactly against expectedRevision; conflicts never partially write. */
|
|
54
54
|
commit(input: MultiplayerWorldCommitInput): Promise<MultiplayerWorldCommitReceipt>;
|
|
55
|
+
readonly characters: MultiplayerServerWorldCharacters;
|
|
56
|
+
}
|
|
57
|
+
export interface MultiplayerServerWorldCharacters {
|
|
58
|
+
publicInspect<T = unknown>(): Promise<T>;
|
|
59
|
+
publicRelay(input: {
|
|
60
|
+
operationId: string;
|
|
61
|
+
sourceId: string;
|
|
62
|
+
transmittingCharacterId: string;
|
|
63
|
+
recipientCharacterIds: string[];
|
|
64
|
+
}): Promise<unknown>;
|
|
65
|
+
}
|
|
66
|
+
export interface MultiplayerWorldCharacterCommandRequest {
|
|
67
|
+
readonly requestId: string;
|
|
68
|
+
readonly operationId: string;
|
|
69
|
+
readonly speaker: MultiplayerServerPlayer;
|
|
70
|
+
readonly command: Readonly<Record<string, unknown>>;
|
|
71
|
+
apply(): Promise<unknown>;
|
|
72
|
+
reject(code?: string): void;
|
|
73
|
+
}
|
|
74
|
+
export interface MultiplayerWorldCharacterReadRequest {
|
|
75
|
+
readonly requestId: string;
|
|
76
|
+
readonly speaker: MultiplayerServerPlayer;
|
|
77
|
+
readonly query: Readonly<Record<string, unknown>>;
|
|
78
|
+
read<T = unknown>(): Promise<T>;
|
|
79
|
+
reject(code?: string): void;
|
|
80
|
+
}
|
|
81
|
+
export interface MultiplayerWorldCharacterAdmission {
|
|
82
|
+
operationId: string;
|
|
83
|
+
status: "queued" | "completed" | "interrupted";
|
|
84
|
+
replayed: boolean;
|
|
85
|
+
}
|
|
86
|
+
export interface MultiplayerWorldCharacterTurn {
|
|
87
|
+
readonly requestId: string;
|
|
88
|
+
readonly operationId: string;
|
|
89
|
+
readonly speaker: MultiplayerServerPlayer;
|
|
90
|
+
readonly characterId: string;
|
|
91
|
+
readonly input: string;
|
|
92
|
+
readonly audience: {
|
|
93
|
+
readonly disclosure: "participants" | "world";
|
|
94
|
+
readonly players: readonly MultiplayerServerPlayer[];
|
|
95
|
+
readonly characterIds: readonly string[];
|
|
96
|
+
};
|
|
97
|
+
/** Durably admits the exact host-authorized request. No arguments can broaden it. */
|
|
98
|
+
send(): Promise<MultiplayerWorldCharacterAdmission>;
|
|
99
|
+
/** Explicitly rejects the request before admission. */
|
|
100
|
+
reject(code?: string): void;
|
|
55
101
|
}
|
|
56
102
|
type CallbackResult = unknown;
|
|
57
103
|
export interface MultiplayerServerCallbacks {
|
|
@@ -60,6 +106,9 @@ export interface MultiplayerServerCallbacks {
|
|
|
60
106
|
onPlayerLeave?: (session: MultiplayerServerSession, player: MultiplayerServerPlayer) => CallbackResult;
|
|
61
107
|
/** Receives binary messages as Uint8Array values and JSON messages otherwise. */
|
|
62
108
|
onMessage?: (session: MultiplayerServerSession, player: MultiplayerServerPlayer, data: unknown) => CallbackResult;
|
|
109
|
+
onWorldCharacterTurn?: (session: MultiplayerServerSession, turn: MultiplayerWorldCharacterTurn) => CallbackResult;
|
|
110
|
+
onWorldCharacterCommand?: (session: MultiplayerServerSession, request: MultiplayerWorldCharacterCommandRequest) => CallbackResult;
|
|
111
|
+
onWorldCharacterRead?: (session: MultiplayerServerSession, request: MultiplayerWorldCharacterReadRequest) => CallbackResult;
|
|
63
112
|
onTick?: (session: MultiplayerServerSession) => CallbackResult;
|
|
64
113
|
onReplaced?: (session: MultiplayerServerSession) => CallbackResult;
|
|
65
114
|
/**
|
package/dist/server/wrapper.js
CHANGED
|
@@ -43,6 +43,109 @@ async function bootSession(platformHandle, callbacks) {
|
|
|
43
43
|
catch {
|
|
44
44
|
// Older shims may reject event names they do not recognize.
|
|
45
45
|
}
|
|
46
|
+
if (callbacks.onWorldCharacterTurn &&
|
|
47
|
+
typeof platformHandle.worldCharacterSend === "function" &&
|
|
48
|
+
typeof platformHandle.worldCharacterReject === "function") {
|
|
49
|
+
platformHandle.on("worldCharacterTurn", async (raw) => {
|
|
50
|
+
const request = assertPlatformWorldCharacterTurn(raw);
|
|
51
|
+
let settled = false;
|
|
52
|
+
const turn = Object.freeze({
|
|
53
|
+
requestId: request.requestId,
|
|
54
|
+
operationId: request.operationId,
|
|
55
|
+
speaker: Object.freeze({ ...request.speaker }),
|
|
56
|
+
characterId: request.characterId,
|
|
57
|
+
input: request.input,
|
|
58
|
+
audience: Object.freeze({
|
|
59
|
+
disclosure: request.audience.disclosure,
|
|
60
|
+
players: Object.freeze(request.audience.players.map((player) => Object.freeze({ ...player }))),
|
|
61
|
+
characterIds: Object.freeze([...request.audience.characterIds]),
|
|
62
|
+
}),
|
|
63
|
+
async send() {
|
|
64
|
+
if (settled)
|
|
65
|
+
throw new TypeError("This shared character request is already settled.");
|
|
66
|
+
settled = true;
|
|
67
|
+
const rawReceipt = await platformHandle.worldCharacterSend(request.grantId);
|
|
68
|
+
return parseWorldCharacterAdmission(rawReceipt, request.operationId);
|
|
69
|
+
},
|
|
70
|
+
reject(code) {
|
|
71
|
+
if (settled)
|
|
72
|
+
return;
|
|
73
|
+
settled = true;
|
|
74
|
+
platformHandle.worldCharacterReject(request.grantId, code);
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
try {
|
|
78
|
+
await callbacks.onWorldCharacterTurn(session, turn);
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
if (!settled)
|
|
82
|
+
turn.reject();
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (callbacks.onWorldCharacterCommand &&
|
|
87
|
+
hasWorldCharacterPlatform(platformHandle, "worldCharacterCommand")) {
|
|
88
|
+
platformHandle.on("worldCharacterCommand", async (raw) => {
|
|
89
|
+
const request = assertPlatformWorldCharacterCommand(raw);
|
|
90
|
+
let settled = false;
|
|
91
|
+
const command = Object.freeze({
|
|
92
|
+
requestId: request.requestId,
|
|
93
|
+
operationId: request.operationId,
|
|
94
|
+
speaker: Object.freeze({ ...request.speaker }),
|
|
95
|
+
command: deepFreezeJsonObject(request.command),
|
|
96
|
+
async apply() {
|
|
97
|
+
if (settled)
|
|
98
|
+
throw new TypeError("This shared character command is already settled.");
|
|
99
|
+
settled = true;
|
|
100
|
+
return parseSharedCharacterJson(await platformHandle.worldCharacterCommand(request.grantId));
|
|
101
|
+
},
|
|
102
|
+
reject(code) {
|
|
103
|
+
if (settled)
|
|
104
|
+
return;
|
|
105
|
+
settled = true;
|
|
106
|
+
platformHandle.worldCharacterReject(request.grantId, code);
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
try {
|
|
110
|
+
await callbacks.onWorldCharacterCommand(session, command);
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
if (!settled)
|
|
114
|
+
command.reject();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (callbacks.onWorldCharacterRead &&
|
|
119
|
+
hasWorldCharacterPlatform(platformHandle, "worldCharacterRead")) {
|
|
120
|
+
platformHandle.on("worldCharacterRead", async (raw) => {
|
|
121
|
+
const request = assertPlatformWorldCharacterRead(raw);
|
|
122
|
+
let settled = false;
|
|
123
|
+
const read = Object.freeze({
|
|
124
|
+
requestId: request.requestId,
|
|
125
|
+
speaker: Object.freeze({ ...request.speaker }),
|
|
126
|
+
query: deepFreezeJsonObject(request.query),
|
|
127
|
+
async read() {
|
|
128
|
+
if (settled)
|
|
129
|
+
throw new TypeError("This shared character read is already settled.");
|
|
130
|
+
settled = true;
|
|
131
|
+
return parseSharedCharacterJson(await platformHandle.worldCharacterRead(request.grantId));
|
|
132
|
+
},
|
|
133
|
+
reject(code) {
|
|
134
|
+
if (settled)
|
|
135
|
+
return;
|
|
136
|
+
settled = true;
|
|
137
|
+
platformHandle.worldCharacterReject(request.grantId, code);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
try {
|
|
141
|
+
await callbacks.onWorldCharacterRead(session, read);
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
if (!settled)
|
|
145
|
+
read.reject();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
46
149
|
platformHandle.on("replaced", async () => {
|
|
47
150
|
await callbacks.onReplaced?.(session);
|
|
48
151
|
});
|
|
@@ -121,6 +224,7 @@ function createServerWorld(platformHandle) {
|
|
|
121
224
|
const initial = parseWorldSnapshot(platformHandle.config.world, "read");
|
|
122
225
|
return Object.freeze({
|
|
123
226
|
snapshot: initial,
|
|
227
|
+
characters: createServerWorldCharacters(platformHandle),
|
|
124
228
|
async read() {
|
|
125
229
|
const result = await platformHandle.worldCheckpointRead();
|
|
126
230
|
return parseWorldSnapshot(parsePlatformJson(result, "read"), "read");
|
|
@@ -146,6 +250,38 @@ function createServerWorld(platformHandle) {
|
|
|
146
250
|
},
|
|
147
251
|
});
|
|
148
252
|
}
|
|
253
|
+
function createServerWorldCharacters(platformHandle) {
|
|
254
|
+
return Object.freeze({
|
|
255
|
+
async publicInspect() {
|
|
256
|
+
if (typeof platformHandle.storage !== "function") {
|
|
257
|
+
throw new TypeError("The shared character broker is unavailable.");
|
|
258
|
+
}
|
|
259
|
+
return parseSharedCharacterJson(await platformHandle.storage("worldCharacterPublicInspect", "{}"));
|
|
260
|
+
},
|
|
261
|
+
async publicRelay(input) {
|
|
262
|
+
if (typeof platformHandle.storage !== "function") {
|
|
263
|
+
throw new TypeError("The shared character broker is unavailable.");
|
|
264
|
+
}
|
|
265
|
+
const operationId = assertWorldOperationId(input?.operationId);
|
|
266
|
+
const sourceId = assertWorldCharacterId(input?.sourceId, "sourceId", 128);
|
|
267
|
+
const transmittingCharacterId = assertWorldCharacterId(input?.transmittingCharacterId, "transmittingCharacterId");
|
|
268
|
+
if (!Array.isArray(input?.recipientCharacterIds) ||
|
|
269
|
+
input.recipientCharacterIds.length > 3) {
|
|
270
|
+
throw new TypeError("session.world.characters.publicRelay: recipientCharacterIds may contain at most 3 ids.");
|
|
271
|
+
}
|
|
272
|
+
const recipientCharacterIds = input.recipientCharacterIds.map((id) => assertWorldCharacterId(id, "recipientCharacterIds"));
|
|
273
|
+
if (new Set(recipientCharacterIds).size !== recipientCharacterIds.length) {
|
|
274
|
+
throw new TypeError("session.world.characters.publicRelay: recipientCharacterIds may not contain duplicates.");
|
|
275
|
+
}
|
|
276
|
+
return parseSharedCharacterJson(await platformHandle.storage("worldCharacterPublicRelay", JSON.stringify({
|
|
277
|
+
operationId,
|
|
278
|
+
sourceId,
|
|
279
|
+
transmittingCharacterId,
|
|
280
|
+
recipientCharacterIds: recipientCharacterIds.sort(),
|
|
281
|
+
})));
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
}
|
|
149
285
|
function parseWorldSnapshot(value, operation) {
|
|
150
286
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
151
287
|
throw worldError("The platform returned an invalid world checkpoint.", "read");
|
|
@@ -158,6 +294,112 @@ function parseWorldSnapshot(value, operation) {
|
|
|
158
294
|
: assertWorldCheckpoint(raw.checkpoint, operation),
|
|
159
295
|
});
|
|
160
296
|
}
|
|
297
|
+
function assertPlatformWorldCharacterTurn(value) {
|
|
298
|
+
if (!value ||
|
|
299
|
+
typeof value !== "object" ||
|
|
300
|
+
typeof value.grantId !== "string" ||
|
|
301
|
+
typeof value.requestId !== "string" ||
|
|
302
|
+
typeof value.operationId !== "string" ||
|
|
303
|
+
typeof value.characterId !== "string" ||
|
|
304
|
+
typeof value.input !== "string" ||
|
|
305
|
+
!value.speaker ||
|
|
306
|
+
typeof value.speaker.playerId !== "string" ||
|
|
307
|
+
!value.audience ||
|
|
308
|
+
(value.audience.disclosure !== "participants" && value.audience.disclosure !== "world") ||
|
|
309
|
+
!Array.isArray(value.audience.players) ||
|
|
310
|
+
!Array.isArray(value.audience.characterIds)) {
|
|
311
|
+
throw new TypeError("The platform sent an invalid shared character request.");
|
|
312
|
+
}
|
|
313
|
+
return value;
|
|
314
|
+
}
|
|
315
|
+
function assertPlatformWorldCharacterCommand(value) {
|
|
316
|
+
if (!value ||
|
|
317
|
+
typeof value !== "object" ||
|
|
318
|
+
typeof value.grantId !== "string" ||
|
|
319
|
+
typeof value.requestId !== "string" ||
|
|
320
|
+
typeof value.operationId !== "string" ||
|
|
321
|
+
!value.speaker ||
|
|
322
|
+
typeof value.speaker.playerId !== "string" ||
|
|
323
|
+
!value.command ||
|
|
324
|
+
typeof value.command !== "object" ||
|
|
325
|
+
Array.isArray(value.command)) {
|
|
326
|
+
throw new TypeError("The platform sent an invalid shared character command.");
|
|
327
|
+
}
|
|
328
|
+
return value;
|
|
329
|
+
}
|
|
330
|
+
function assertPlatformWorldCharacterRead(value) {
|
|
331
|
+
if (!value ||
|
|
332
|
+
typeof value !== "object" ||
|
|
333
|
+
typeof value.grantId !== "string" ||
|
|
334
|
+
typeof value.requestId !== "string" ||
|
|
335
|
+
!value.speaker ||
|
|
336
|
+
typeof value.speaker.playerId !== "string" ||
|
|
337
|
+
!value.query ||
|
|
338
|
+
typeof value.query !== "object" ||
|
|
339
|
+
Array.isArray(value.query)) {
|
|
340
|
+
throw new TypeError("The platform sent an invalid shared character read.");
|
|
341
|
+
}
|
|
342
|
+
return value;
|
|
343
|
+
}
|
|
344
|
+
function hasWorldCharacterPlatform(platformHandle, operation) {
|
|
345
|
+
return (typeof platformHandle[operation] === "function" &&
|
|
346
|
+
typeof platformHandle.worldCharacterReject === "function");
|
|
347
|
+
}
|
|
348
|
+
function deepFreezeJsonObject(value) {
|
|
349
|
+
const normalized = JSON.parse(JSON.stringify(value));
|
|
350
|
+
deepFreeze(normalized);
|
|
351
|
+
return normalized;
|
|
352
|
+
}
|
|
353
|
+
function deepFreeze(value) {
|
|
354
|
+
if (!value || typeof value !== "object")
|
|
355
|
+
return;
|
|
356
|
+
Object.freeze(value);
|
|
357
|
+
for (const child of Object.values(value))
|
|
358
|
+
deepFreeze(child);
|
|
359
|
+
}
|
|
360
|
+
function parseSharedCharacterJson(json) {
|
|
361
|
+
try {
|
|
362
|
+
return JSON.parse(json);
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
throw new TypeError("The platform returned an invalid shared character response.");
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
function assertWorldCharacterId(value, field, maxCharacters = 100) {
|
|
369
|
+
if (typeof value !== "string" ||
|
|
370
|
+
value.length === 0 ||
|
|
371
|
+
value.length > maxCharacters ||
|
|
372
|
+
value.includes("\0") ||
|
|
373
|
+
new TextEncoder().encode(value).byteLength > 128) {
|
|
374
|
+
throw new TypeError(`session.world.characters: ${field} must be non-empty text of at most ${maxCharacters} characters and 128 bytes.`);
|
|
375
|
+
}
|
|
376
|
+
return value;
|
|
377
|
+
}
|
|
378
|
+
function parseWorldCharacterAdmission(json, operationId) {
|
|
379
|
+
let value;
|
|
380
|
+
try {
|
|
381
|
+
value = JSON.parse(json);
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
throw new TypeError("The platform returned an invalid shared character receipt.");
|
|
385
|
+
}
|
|
386
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
387
|
+
throw new TypeError("The platform returned an invalid shared character receipt.");
|
|
388
|
+
}
|
|
389
|
+
const receipt = value;
|
|
390
|
+
if (receipt.operationId !== operationId ||
|
|
391
|
+
(receipt.status !== "queued" &&
|
|
392
|
+
receipt.status !== "completed" &&
|
|
393
|
+
receipt.status !== "interrupted") ||
|
|
394
|
+
typeof receipt.replayed !== "boolean") {
|
|
395
|
+
throw new TypeError("The platform returned an invalid shared character receipt.");
|
|
396
|
+
}
|
|
397
|
+
return Object.freeze({
|
|
398
|
+
operationId,
|
|
399
|
+
status: receipt.status,
|
|
400
|
+
replayed: receipt.replayed,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
161
403
|
function assertWorldCheckpoint(value, operation = "commit") {
|
|
162
404
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
163
405
|
throw worldError("A world checkpoint requires version and value.", operation);
|