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.
@@ -1,6 +1,7 @@
1
1
  import type { MultiplayerDestinationAudience, MultiplayerSoloReason } from "./commands";
2
2
  import { CastleError } from "./errors";
3
- import { type MultiplayerPlayer } from "./multiplayerProtocol";
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;
@@ -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 PlatformToClientFrame = ClientHelloFrame | ClientRosterFrame | ClientMessageFrame | ClientReplacedFrame | ClientByeFrame | ClientPongFrame;
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 type ClientToPlatformFrame = ClientToPlatformMessageFrame | ClientPingFrame;
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
+ }
@@ -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
  /**
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-sdk",
3
- "version": "0.4.37",
3
+ "version": "0.4.38",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/castle-xyz/castle-experimental-web.git"