castle-web-sdk 0.4.45 → 0.4.47

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/hub.js ADDED
@@ -0,0 +1,120 @@
1
+ import { CastleError } from "./errors";
2
+ import { hostRequest, setHubEventHandler } from "./transport";
3
+ export const Hub = {
4
+ go,
5
+ back,
6
+ doors,
7
+ friends,
8
+ hearts,
9
+ setHearts,
10
+ jump,
11
+ report,
12
+ chat,
13
+ say,
14
+ reportChat,
15
+ cancel,
16
+ onBack,
17
+ };
18
+ async function go(doorId) {
19
+ if (typeof doorId !== "string" || doorId.length === 0) {
20
+ throw new CastleError({
21
+ code: "INVALID_ARGUMENT",
22
+ message: "Hub.go requires a doorId.",
23
+ operation: "Hub.go",
24
+ });
25
+ }
26
+ return hostRequest("hub.go", { doorId });
27
+ }
28
+ async function back() {
29
+ return hostRequest("hub.back", {});
30
+ }
31
+ async function doors() {
32
+ return hostRequest("hub.doors", {});
33
+ }
34
+ async function friends() {
35
+ return hostRequest("hub.friends", {});
36
+ }
37
+ async function hearts() {
38
+ return hostRequest("hub.hearts", {});
39
+ }
40
+ async function setHearts(doorIds) {
41
+ if (!Array.isArray(doorIds) || doorIds.length > 8 ||
42
+ doorIds.some((id) => typeof id !== "string" || !id.length || id.length > 64) ||
43
+ new Set(doorIds).size !== doorIds.length) {
44
+ throw new CastleError({ code: "INVALID_ARGUMENT", message: "Hub.setHearts requires at most eight distinct door ids.", operation: "Hub.setHearts" });
45
+ }
46
+ return hostRequest("hub.setHearts", { doorIds });
47
+ }
48
+ async function jump(userId) {
49
+ if (typeof userId !== "string" || userId.length === 0) {
50
+ throw new CastleError({
51
+ code: "INVALID_ARGUMENT",
52
+ message: "Hub.jump requires a userId.",
53
+ operation: "Hub.jump",
54
+ });
55
+ }
56
+ return hostRequest("hub.jump", { userId });
57
+ }
58
+ async function report(userId, reason) {
59
+ if (typeof userId !== "string" || userId.length === 0) {
60
+ throw new CastleError({
61
+ code: "INVALID_ARGUMENT",
62
+ message: "Hub.report requires a userId.",
63
+ operation: "Hub.report",
64
+ });
65
+ }
66
+ return hostRequest("hub.report", typeof reason === "string" && reason.length > 0 ? { userId, reason } : { userId });
67
+ }
68
+ async function chat(after) {
69
+ return hostRequest("hub.chat", typeof after === "string" && after.length > 0 ? { after } : {});
70
+ }
71
+ async function say(text) {
72
+ if (typeof text !== "string" || text.length === 0) {
73
+ throw new CastleError({
74
+ code: "INVALID_ARGUMENT",
75
+ message: "Hub.say requires a text.",
76
+ operation: "Hub.say",
77
+ });
78
+ }
79
+ return hostRequest("hub.say", { text });
80
+ }
81
+ async function reportChat(messageId) {
82
+ if (typeof messageId !== "string" || messageId.length === 0) {
83
+ throw new CastleError({
84
+ code: "INVALID_ARGUMENT",
85
+ message: "Hub.reportChat requires a messageId.",
86
+ operation: "Hub.reportChat",
87
+ });
88
+ }
89
+ return hostRequest("hub.reportChat", { messageId });
90
+ }
91
+ async function cancel() {
92
+ return hostRequest("hub.cancel", {});
93
+ }
94
+ const backListeners = new Set();
95
+ function onBack(listener) {
96
+ if (typeof listener !== "function") {
97
+ throw new CastleError({
98
+ code: "INVALID_ARGUMENT",
99
+ message: "Hub.onBack requires a function.",
100
+ operation: "Hub.onBack",
101
+ });
102
+ }
103
+ backListeners.add(listener);
104
+ setHubEventHandler(dispatchHubEvent);
105
+ return () => {
106
+ backListeners.delete(listener);
107
+ if (backListeners.size === 0)
108
+ setHubEventHandler(null);
109
+ };
110
+ }
111
+ function dispatchHubEvent() {
112
+ for (const listener of [...backListeners]) {
113
+ try {
114
+ listener();
115
+ }
116
+ catch (error) {
117
+ console.error("Castle Hub.onBack listener threw:", error);
118
+ }
119
+ }
120
+ }
@@ -1,4 +1,4 @@
1
- import type { MultiplayerDestinationAudience, MultiplayerJoinHint, MultiplayerSoloReason } from "./commands";
1
+ import type { HubHandoffResult, MultiplayerDestinationAudience, MultiplayerJoinHint, MultiplayerSoloReason } from "./commands";
2
2
  import { CastleError } from "./errors";
3
3
  import type { Json } from "./types";
4
4
  import { type MultiplayerPlayer, type MultiplayerCharacterDisclosure, type MultiplayerCharacterTurnStatus } from "./multiplayerProtocol";
@@ -11,6 +11,9 @@ export type MultiplayerJoinOptions = {
11
11
  mode: "party";
12
12
  } | {
13
13
  mode: "public";
14
+ } | {
15
+ mode: "hub";
16
+ handoff: HubHandoffResult;
14
17
  };
15
18
  export interface MultiplayerDestinationOptions {
16
19
  routeId: string;
@@ -79,7 +82,45 @@ export type MultiplayerWorldCharacterCommand = {
79
82
  type: "record_event";
80
83
  eventTypeId: string;
81
84
  data?: Json;
85
+ /** Characters that saw it (at most 4); each may act on its own about the recorder. */
86
+ witnesses?: string[];
87
+ } | MultiplayerEvaluateBeatsCommand | MultiplayerCompleteScheduledBeatCommand;
88
+ export type MultiplayerStoryOpportunity = {
89
+ type: "approach" | "room_entry" | "world_action";
90
+ id: string;
82
91
  };
92
+ export interface MultiplayerEvaluateBeatsCommand {
93
+ type: "evaluate_beats";
94
+ opportunityId: string;
95
+ opportunity: MultiplayerStoryOpportunity;
96
+ presentCharacterIds: string[];
97
+ }
98
+ export interface MultiplayerCompleteScheduledBeatCommand {
99
+ type: "complete_scheduled_beat";
100
+ deliveryId: string;
101
+ outcomeId: string;
102
+ }
103
+ export interface MultiplayerScheduledBeatSelection {
104
+ deliveryId: string;
105
+ beatId: string;
106
+ characterId: string;
107
+ openingText: string;
108
+ }
109
+ export interface MultiplayerScheduledBeatReason {
110
+ beatId: string;
111
+ status: "selected" | "expired" | "eligible" | "blocked";
112
+ reasonCodes: string[];
113
+ }
114
+ export interface MultiplayerEvaluateBeatsResult {
115
+ selection: MultiplayerScheduledBeatSelection | null;
116
+ reasons?: MultiplayerScheduledBeatReason[];
117
+ }
118
+ export interface MultiplayerCompleteScheduledBeatResult {
119
+ deliveryId: string;
120
+ outcomeId: string;
121
+ completed: true;
122
+ }
123
+ export type MultiplayerWorldCharacterCommandResultValue<Command extends MultiplayerWorldCharacterCommand> = Command extends MultiplayerEvaluateBeatsCommand ? MultiplayerEvaluateBeatsResult : Command extends MultiplayerCompleteScheduledBeatCommand ? MultiplayerCompleteScheduledBeatResult : Json;
83
124
  export type MultiplayerWorldCharacterQuery = {
84
125
  type: "inspect";
85
126
  } | {
@@ -90,7 +131,33 @@ export type MultiplayerWorldCharacterQuery = {
90
131
  characterId?: string;
91
132
  limit?: number;
92
133
  cursor?: string;
134
+ }
135
+ /** Autonomous-action notices after a cursor (world-visible). */
136
+ | {
137
+ type: "actions";
138
+ after?: string;
93
139
  };
140
+ /** One autonomous character decision in a shared world. */
141
+ export interface MultiplayerCharacterActionNotice {
142
+ characterId: string;
143
+ /** The participant the character acted about: the one whose deed it witnessed. */
144
+ participantId: string;
145
+ trigger: {
146
+ type: "witnessed";
147
+ id: string;
148
+ };
149
+ outcome: "acted" | "declined" | "failed";
150
+ actionId?: string;
151
+ variantId?: string;
152
+ eventOccurrenceId?: string;
153
+ probability?: number;
154
+ /** Feed cursor; pass the last one as `after` to read only newer notices. */
155
+ sequence: string;
156
+ }
157
+ export interface MultiplayerWorldCharacterActionsResult {
158
+ notices: MultiplayerCharacterActionNotice[];
159
+ after: string;
160
+ }
94
161
  /** Authenticated, participant-scoped projection of a shared character world. */
95
162
  export interface MultiplayerWorldCharacterInspectResult<Gameplay = unknown, SharedState = unknown, PrivateState = unknown, Source = unknown> {
96
163
  /** Durable identity used by gameplay holders and promise debtors. */
@@ -106,11 +173,11 @@ export interface MultiplayerWorldCharacterInspectResult<Gameplay = unknown, Shar
106
173
  }>;
107
174
  sources: Source[];
108
175
  }
109
- export interface MultiplayerWorldCharacterCommandResult {
176
+ export interface MultiplayerWorldCharacterCommandResult<Result = unknown> {
110
177
  operationId: string;
111
178
  status: "applied" | "ineligible";
112
179
  replayed: boolean;
113
- result: unknown;
180
+ result: Result;
114
181
  worldRevision: string;
115
182
  }
116
183
  export interface MultiplayerConnection {
@@ -123,17 +190,24 @@ export interface MultiplayerConnection {
123
190
  * `private_deck` and `no_server_bundle` are the creator's to fix.
124
191
  */
125
192
  readonly soloReason: MultiplayerSoloReason | null;
193
+ /**
194
+ * Whether the host brokers shared characters into this session (its hello
195
+ * advertised them): a shared-character world, or a hub place's layer with
196
+ * place characters. The character calls below refuse while this is false.
197
+ * Read it when needed: a reconnect can land on a host that says otherwise.
198
+ */
199
+ readonly sharedCharacters: boolean;
126
200
  /** Sends byte payloads as binary frames and all other payloads as JSON. */
127
201
  send(data: unknown): void;
128
202
  /** Requests one billed character turn through this authenticated world socket. */
129
203
  requestCharacterTurn(request: MultiplayerCharacterTurnRequest): Promise<MultiplayerCharacterTurnAdmission>;
130
204
  /** Recovers the durable status of a prior operation after reconnecting. */
131
205
  getCharacterTurnStatus(operationId: string): Promise<MultiplayerCharacterTurnStatusResult>;
132
- requestCharacterCommand(request: {
206
+ requestCharacterCommand<Command extends MultiplayerWorldCharacterCommand>(request: {
133
207
  requestId: string;
134
208
  operationId: string;
135
- command: MultiplayerWorldCharacterCommand;
136
- }): Promise<MultiplayerWorldCharacterCommandResult>;
209
+ command: Command;
210
+ }): Promise<MultiplayerWorldCharacterCommandResult<MultiplayerWorldCharacterCommandResultValue<Command>>>;
137
211
  readCharacters(request: {
138
212
  requestId: string;
139
213
  query: {
@@ -153,6 +227,12 @@ export interface MultiplayerConnection {
153
227
  leave(): void;
154
228
  }
155
229
  export interface CastleMultiplayerApi {
230
+ /**
231
+ * True on an SDK whose session server can address a hub place's shared
232
+ * characters from a layer (`session.characters` without `session.world`).
233
+ * Absent on older SDKs, so a kit can tell before joining.
234
+ */
235
+ readonly placeCharacters: true;
156
236
  join(options: MultiplayerJoinOptions): Promise<MultiplayerConnection>;
157
237
  joinHint(): Promise<MultiplayerJoinHint | null>;
158
238
  joinDestination(options: MultiplayerDestinationOptions): Promise<MultiplayerDestinationJoinResult>;
@@ -7,6 +7,13 @@ 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 play proxy closes a handshake it refuses with a reason (cauldron-game-server
11
+ // shared/src/constants.ts). The host that owns the session rejecting the credential is
12
+ // final. "Session ended" is ghost's routing verdict, which a host drain or a ghost
13
+ // error can also produce, so it takes two in a row before the session is given up on.
14
+ const PROXY_CREDENTIAL_REJECTED_CLOSE_CODE = 4401;
15
+ const PROXY_SESSION_ENDED_CLOSE_CODE = 4404;
16
+ const SESSION_ENDED_CONFIRMATIONS = 2;
10
17
  // The host may spend the full 15-second grant window waiting for author admission,
11
18
  // then up to 10 seconds on the fenced control-plane request. Keep the client above
12
19
  // that complete lifecycle so it cannot discard a valid durable receipt.
@@ -18,7 +25,12 @@ let nextCharacterStatusRequestId = 1;
18
25
  let activeConnection = null;
19
26
  let latestJoinAttempt = 0;
20
27
  let pendingDestinationJoin = null;
21
- export const Multiplayer = { join, joinHint, joinDestination };
28
+ export const Multiplayer = {
29
+ placeCharacters: true,
30
+ join,
31
+ joinHint,
32
+ joinDestination,
33
+ };
22
34
  async function joinHint() {
23
35
  try {
24
36
  return await hostRequest("multiplayer.getJoinHint", {});
@@ -30,18 +42,71 @@ async function joinHint() {
30
42
  }
31
43
  }
32
44
  async function join(options) {
33
- const params = joinParams(options);
45
+ const hub = hubJoin(options);
46
+ const request = hub
47
+ ? { command: "hub", nonce: hub.nonce }
48
+ : { command: "multiplayer.getSession", params: joinParams(options) };
34
49
  latestJoinAttempt += 1;
35
50
  cancelPendingDestinationJoin();
36
51
  activeConnection?.leave();
37
- const connection = new MultiplayerConnectionImpl({ command: "multiplayer.getSession", params }, () => {
52
+ const connection = new MultiplayerConnectionImpl(request, () => {
38
53
  if (activeConnection === connection)
39
54
  activeConnection = null;
40
55
  });
41
56
  activeConnection = connection;
42
- await connection.start();
57
+ await connection.start(hub ?? undefined);
58
+ // The host moves its place (and every place-scoped command with it) when the
59
+ // trip commits; the deck gets the connection once it has. A trip that did not
60
+ // commit leaves the player where they were, so its connection is closed.
61
+ if (!(await connection.hubCommitted())) {
62
+ connection.leave();
63
+ throw multiplayerError("MULTIPLAYER_UNAVAILABLE", "The hub trip could not be completed; the player is still where they were.", "Multiplayer.join");
64
+ }
65
+ connection.handedOver();
43
66
  return connection;
44
67
  }
68
+ // A hub handoff is joined as it was acquired; it cannot be acquired again.
69
+ // The host answers `unavailable` while ghost is busy with this player; the
70
+ // trip stays pending there, so asking again is safe.
71
+ const HUB_COMMIT_RETRY_MS = [1_000, 2_000, 4_000, 8_000];
72
+ async function commitHubTrip(nonce, sessionId) {
73
+ for (let attempt = 0;; attempt++) {
74
+ let status;
75
+ try {
76
+ status = (await hostRequest("hub.commit", { nonce })).status;
77
+ }
78
+ catch (error) {
79
+ status = error instanceof CastleError ? error.code : "unavailable";
80
+ }
81
+ if (status === "committed")
82
+ return true;
83
+ const delay = HUB_COMMIT_RETRY_MS[attempt];
84
+ if ((status !== "unavailable" && status !== "CASTLE_HOST_UNAVAILABLE") || delay === undefined) {
85
+ console.warn(`Castle hub trip to ${sessionId} was not committed: ${status}`);
86
+ return false;
87
+ }
88
+ await pause(delay);
89
+ }
90
+ }
91
+ function hubJoin(options) {
92
+ if (!options || typeof options !== "object" || options.mode !== "hub")
93
+ return null;
94
+ const handoff = options.handoff;
95
+ if (!handoff ||
96
+ handoff.status !== "ok" ||
97
+ typeof handoff.url !== "string" ||
98
+ typeof handoff.nonce !== "string" ||
99
+ typeof handoff.sessionId !== "string") {
100
+ throw multiplayerError("INVALID_ARGUMENT", "Multiplayer.join({ mode: \"hub\" }) requires an ok result from Hub.go or Hub.back.", "Multiplayer.join");
101
+ }
102
+ return {
103
+ status: "ok",
104
+ url: handoff.url,
105
+ nonce: handoff.nonce,
106
+ sessionId: handoff.sessionId,
107
+ soloReason: null,
108
+ };
109
+ }
45
110
  async function joinDestination(options) {
46
111
  const params = destinationParams(options);
47
112
  const attempt = ++latestJoinAttempt;
@@ -112,6 +177,8 @@ async function joinDestination(options) {
112
177
  activeConnection = connection;
113
178
  return { status: "joined", connection, audience: acquired.audience };
114
179
  }
180
+ // How many messages are kept for a deck that has not attached a listener yet.
181
+ const EARLY_MESSAGES_MAX = 512;
115
182
  class MultiplayerConnectionImpl {
116
183
  request;
117
184
  onClosed;
@@ -129,10 +196,22 @@ class MultiplayerConnectionImpl {
129
196
  keepalive = null;
130
197
  reAcquisitionUsed = false;
131
198
  recovering = false;
199
+ hubSettled = false;
200
+ hubCommit = Promise.resolve(true);
132
201
  roster = null;
133
202
  sharedCharactersSupported = false;
134
203
  pendingCharacterRequests = new Map();
135
204
  messages = new Set();
205
+ // Messages that arrived before the deck had a listener, on a hub join only.
206
+ // A hub join resolves some tasks after the socket's hello (it first commits
207
+ // with the host), and a place greets a player with a snapshot in that gap:
208
+ // with nobody listening it was dropped, and the deck never saw the state it
209
+ // joined into. They are kept, in order, for a listener attached as soon as
210
+ // the deck is handed the connection (`handedOver`); a deck that attaches one
211
+ // later gets only what arrives from then on. Null on every other join, which
212
+ // resolves in the hello's own task: nothing is kept there and delivery is
213
+ // exactly what it always was.
214
+ early = null;
136
215
  rosters = new Set();
137
216
  states = new Set();
138
217
  voiceImpl = new MultiplayerVoiceImpl({
@@ -160,6 +239,9 @@ class MultiplayerConnectionImpl {
160
239
  get soloReason() {
161
240
  return this.currentSoloReason;
162
241
  }
242
+ get sharedCharacters() {
243
+ return this.sharedCharactersSupported;
244
+ }
163
245
  get voice() {
164
246
  return this.voiceImpl;
165
247
  }
@@ -168,6 +250,7 @@ class MultiplayerConnectionImpl {
168
250
  await this.acquire(initial);
169
251
  }
170
252
  catch (error) {
253
+ this.settleHub("cancel");
171
254
  const castleError = asCastleError(error);
172
255
  if (this.currentState !== "closed") {
173
256
  this.finish({ type: "error", error: castleError });
@@ -249,8 +332,43 @@ class MultiplayerConnectionImpl {
249
332
  }
250
333
  onMessage(callback) {
251
334
  this.messages.add(callback);
335
+ const early = this.early;
336
+ if (early) {
337
+ // The first listener takes what arrived before it; later ones get only
338
+ // what arrives from now on, as before.
339
+ this.early = null;
340
+ for (const data of early) {
341
+ // A listener that leaves part-way hears no more.
342
+ if (this.currentState === "closed")
343
+ break;
344
+ callListener(callback, data);
345
+ }
346
+ }
252
347
  return () => this.messages.delete(callback);
253
348
  }
349
+ /**
350
+ * The deck is being handed this connection. Whatever arrived before that is
351
+ * kept through the deck's own next step (the code after its `await`), where
352
+ * a kit attaches its listener; after that, nothing more is kept.
353
+ */
354
+ handedOver() {
355
+ if (!this.early)
356
+ return;
357
+ setTimeout(() => {
358
+ if (this.messages.size === 0)
359
+ this.early = null;
360
+ }, 0);
361
+ }
362
+ deliver(data) {
363
+ if (this.early && this.messages.size === 0) {
364
+ // Bounded: a deck that never listens does not grow this forever.
365
+ if (this.early.length < EARLY_MESSAGES_MAX)
366
+ this.early.push(data);
367
+ return;
368
+ }
369
+ for (const callback of this.messages)
370
+ callListener(callback, data);
371
+ }
254
372
  onRoster(callback) {
255
373
  this.rosters.add(callback);
256
374
  if (this.roster)
@@ -265,8 +383,27 @@ class MultiplayerConnectionImpl {
265
383
  leave() {
266
384
  if (this.currentState === "closed")
267
385
  return;
386
+ this.settleHub("cancel");
268
387
  this.finish({ type: "left" });
269
388
  }
389
+ // A hub trip is the host's to commit or cancel, once: committed when the
390
+ // destination first says hello, cancelled if the join ends before that. The
391
+ // acquisition is named by its nonce, which no other acquisition shares.
392
+ settleHub(outcome) {
393
+ if (this.request.command !== "hub" || this.hubSettled)
394
+ return;
395
+ this.hubSettled = true;
396
+ const nonce = this.request.nonce;
397
+ if (outcome === "cancel") {
398
+ hostRequest("hub.cancel", { nonce }).catch(() => { });
399
+ return;
400
+ }
401
+ this.early = [];
402
+ this.hubCommit = commitHubTrip(nonce, this.currentSessionId);
403
+ }
404
+ hubCommitted() {
405
+ return this.hubCommit;
406
+ }
270
407
  matchesDestination(params) {
271
408
  return (this.currentState !== "closed" &&
272
409
  this.request.command === "multiplayer.joinDestination" &&
@@ -329,6 +466,8 @@ class MultiplayerConnectionImpl {
329
466
  }
330
467
  connectSolo(result) {
331
468
  this.solo = true;
469
+ // A solo session has no host, so no characters, whatever the last socket's hello offered.
470
+ this.sharedCharactersSupported = false;
332
471
  this.currentSoloReason = result.soloReason ?? "unavailable";
333
472
  this.currentPlayerId = "solo";
334
473
  this.currentSessionId = result.sessionId ?? "solo";
@@ -422,6 +561,7 @@ class MultiplayerConnectionImpl {
422
561
  // is backgrounded many times in a play, and each of those is its own outage;
423
562
  // without this the second one finds the budget spent and closes for good.
424
563
  this.reAcquisitionUsed = false;
564
+ this.settleHub("commit");
425
565
  this.transition("connected");
426
566
  this.startKeepalive();
427
567
  void this.voiceImpl.attach();
@@ -432,8 +572,7 @@ class MultiplayerConnectionImpl {
432
572
  this.acceptRoster(frame);
433
573
  break;
434
574
  case "m":
435
- for (const callback of this.messages)
436
- callListener(callback, frame.d);
575
+ this.deliver(frame.d);
437
576
  break;
438
577
  case "worldCharacterResult":
439
578
  this.acceptWorldCharacterResult(frame);
@@ -458,8 +597,7 @@ class MultiplayerConnectionImpl {
458
597
  if (bytes.byteLength < 1 || bytes[0] !== BINARY_MESSAGE_TAG)
459
598
  return;
460
599
  const payload = new Uint8Array(buffer, 1);
461
- for (const callback of this.messages)
462
- callListener(callback, payload);
600
+ this.deliver(payload);
463
601
  }
464
602
  acceptRoster(frame) {
465
603
  const players = frame.players.slice();
@@ -492,6 +630,7 @@ class MultiplayerConnectionImpl {
492
630
  async recover() {
493
631
  const startedAt = Date.now();
494
632
  let attempt = 0;
633
+ let sessionEndedCloses = 0;
495
634
  while (Date.now() - startedAt < RECONNECT_BUDGET_MS) {
496
635
  const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
497
636
  attempt += 1;
@@ -502,7 +641,15 @@ class MultiplayerConnectionImpl {
502
641
  return;
503
642
  }
504
643
  catch (error) {
505
- if (error instanceof SocketClosedError && error.code === 1008)
644
+ const code = error instanceof SocketClosedError ? error.code : null;
645
+ if (code === 1008 || code === PROXY_CREDENTIAL_REJECTED_CLOSE_CODE)
646
+ break;
647
+ // Retrying a session that is gone only spends the budget; ask for a fresh join.
648
+ // Any other failure in between means the two refusals were not in a row.
649
+ sessionEndedCloses = code === PROXY_SESSION_ENDED_CLOSE_CODE
650
+ ? sessionEndedCloses + 1
651
+ : 0;
652
+ if (sessionEndedCloses >= SESSION_ENDED_CONFIRMATIONS)
506
653
  break;
507
654
  }
508
655
  }
@@ -569,6 +716,7 @@ class MultiplayerConnectionImpl {
569
716
  this.socketAttempt += 1;
570
717
  closeSocket(socket, 1000, "session closed");
571
718
  this.currentState = "closed";
719
+ this.early = null;
572
720
  this.voiceImpl.dispose();
573
721
  for (const callback of this.states)
574
722
  callStateListener(callback, "closed", metadata);
@@ -685,6 +833,11 @@ function cancelPendingDestinationJoin() {
685
833
  pending?.connection?.leave();
686
834
  }
687
835
  async function requestAcquisition(request) {
836
+ if (request.command === "hub") {
837
+ // The nonce was single-use and the trip is the host's; a hub session that
838
+ // cannot be reconnected to is left by going back through the door.
839
+ throw multiplayerError("MULTIPLAYER_CONNECTION_LOST", "A hub session cannot be acquired again. Go back through the door.");
840
+ }
688
841
  if (request.command === "multiplayer.getSession") {
689
842
  return hostRequest(request.command, request.params);
690
843
  }
@@ -778,6 +931,42 @@ function normalizeWorldCharacterCommandRequest(request) {
778
931
  type: "record_event",
779
932
  eventTypeId: boundedCharacterText(command.eventTypeId, 128, "command.eventTypeId", WORLD_CHARACTER_ID_MAX_CHARACTERS),
780
933
  ...(command.data === undefined ? {} : { data: normalizeJson(command.data, "command.data") }),
934
+ ...(command.witnesses === undefined ? {} : { witnesses: normalizeWitnesses(command.witnesses) }),
935
+ },
936
+ };
937
+ }
938
+ if (command.type === "evaluate_beats") {
939
+ const opportunity = command.opportunity;
940
+ if (!opportunity || typeof opportunity !== "object") {
941
+ throw characterError("INVALID_ARGUMENT", "command.opportunity must be an opportunity object.");
942
+ }
943
+ if (opportunity.type !== "approach" &&
944
+ opportunity.type !== "room_entry" &&
945
+ opportunity.type !== "world_action") {
946
+ throw characterError("INVALID_ARGUMENT", "command.opportunity.type is invalid.");
947
+ }
948
+ return {
949
+ requestId,
950
+ operationId,
951
+ command: {
952
+ type: "evaluate_beats",
953
+ opportunityId: boundedCharacterSafeKey(command.opportunityId, "command.opportunityId"),
954
+ opportunity: {
955
+ type: opportunity.type,
956
+ id: boundedCharacterSafeKey(opportunity.id, "command.opportunity.id"),
957
+ },
958
+ presentCharacterIds: boundedCharacterSafeKeys(command.presentCharacterIds, 16, "command.presentCharacterIds"),
959
+ },
960
+ };
961
+ }
962
+ if (command.type === "complete_scheduled_beat") {
963
+ return {
964
+ requestId,
965
+ operationId,
966
+ command: {
967
+ type: "complete_scheduled_beat",
968
+ deliveryId: boundedCharacterSafeKey(command.deliveryId, "command.deliveryId"),
969
+ outcomeId: boundedCharacterSafeKey(command.outcomeId, "command.outcomeId"),
781
970
  },
782
971
  };
783
972
  }
@@ -808,8 +997,20 @@ function normalizeWorldCharacterReadRequest(request) {
808
997
  },
809
998
  };
810
999
  }
1000
+ if (query.type === "actions") {
1001
+ if (query.after !== undefined && (typeof query.after !== "string" || !/^\d{1,18}$/.test(query.after))) {
1002
+ throw characterError("INVALID_ARGUMENT", "Character actions cursor must be a sequence string.");
1003
+ }
1004
+ return { requestId, query: { type: "actions", ...(query.after === undefined ? {} : { after: query.after }) } };
1005
+ }
811
1006
  throw characterError("INVALID_ARGUMENT", "Unknown shared character query type.");
812
1007
  }
1008
+ function normalizeWitnesses(value) {
1009
+ if (!Array.isArray(value) || value.length > 4) {
1010
+ throw characterError("INVALID_ARGUMENT", "command.witnesses must be at most 4 character ids.");
1011
+ }
1012
+ return value.map((item, index) => boundedCharacterText(item, 128, `command.witnesses[${index}]`, WORLD_CHARACTER_ID_MAX_CHARACTERS));
1013
+ }
813
1014
  function normalizeJson(value, field) {
814
1015
  try {
815
1016
  const text = JSON.stringify(value);
@@ -831,6 +1032,22 @@ function boundedCharacterIds(value, maxItems, field, maxCharacters = 128) {
831
1032
  }
832
1033
  return ids;
833
1034
  }
1035
+ function boundedCharacterSafeKeys(value, maxItems, field) {
1036
+ if (!Array.isArray(value) || value.length > maxItems) {
1037
+ throw characterError("INVALID_ARGUMENT", `${field} may contain at most ${maxItems} ids.`);
1038
+ }
1039
+ const ids = value.map((item) => boundedCharacterSafeKey(item, field));
1040
+ if (new Set(ids).size !== ids.length) {
1041
+ throw characterError("INVALID_ARGUMENT", `${field} may not contain duplicate ids.`);
1042
+ }
1043
+ return ids;
1044
+ }
1045
+ function boundedCharacterSafeKey(value, field) {
1046
+ if (typeof value !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(value)) {
1047
+ throw characterError("INVALID_ARGUMENT", `${field} must be a safe key of at most 64 characters.`);
1048
+ }
1049
+ return value;
1050
+ }
834
1051
  function boundedCharacterText(value, maxBytes, field, maxCharacters = maxBytes) {
835
1052
  if (typeof value !== "string" ||
836
1053
  value.length === 0 ||