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.
@@ -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
- export const Multiplayer = { join };
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
- params;
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(params, onClosed) {
49
- this.params = params;
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
- async acquire() {
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 hostRequest("multiplayer.getSession", this.params);
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
- this.devReconnectStorageKey = result.devReconnectScope
135
- ? `castle-dev-mp-reconnect:${result.url}:${result.sessionId}:${result.devReconnectScope}`
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 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;