castle-web-sdk 0.4.35 → 0.4.37

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/README.md CHANGED
@@ -25,6 +25,7 @@ import { setup, initCard, Store, Leaderboard } from "castle-web-sdk";
25
25
  - [Snapshot](#snapshot)
26
26
  - [Portal](#portal)
27
27
  - [Decks](#decks)
28
+ - [Clipboard](#clipboard)
28
29
  - [Haptics](#haptics)
29
30
  - [Agent](#agent)
30
31
  - [Lifecycle](#lifecycle)
@@ -251,11 +252,30 @@ guide.
251
252
 
252
253
  ### Limits
253
254
 
254
- The platform caps key length, value size, how many keys one call may
255
- touch, page size, and how much a deck may store in total, and it limits
256
- how fast a player may read and write. Going over any of them rejects
257
- with a `CastleError` whose `code` says which, so a deck can tell a
258
- player it is saving too fast apart from a deck that is out of space.
255
+ The platform caps key length, value size, how many keys one read may
256
+ name, and how much a deck may store in total, and it limits how fast a
257
+ player may read and write. Going over one of these rejects with a
258
+ `CastleError` whose `code` says which, so a deck can tell a player it is
259
+ saving too fast apart from a deck that is out of space:
260
+
261
+ | limit | value |
262
+ |---|---|
263
+ | key | 128 bytes |
264
+ | value | 256 KB |
265
+ | keys in one `get` | 32 |
266
+ | whole deck | 50 MB, 500,000 keys |
267
+ | one player's calls | 20 a second, 512 KB a second |
268
+ | every player of a deck together | 500 calls a second, 8 MB a second |
269
+
270
+ These do not reject. `set` and `remove` with more than 32 keys are split
271
+ into several calls by the SDK (each still counts against the rate above).
272
+ A `list` page holds at most 1,000 entries (a larger `limit` is lowered to
273
+ it) and ends early once it reaches 1 MB; take the next page with its
274
+ `cursor`.
275
+
276
+ A value is JSON, so binary data such as a picture or a video clip costs
277
+ about a third more once encoded, and a clip of more than a few seconds
278
+ does not fit in one. Ship media with the deck instead.
259
279
 
260
280
  ## Storage
261
281
 
@@ -267,6 +287,10 @@ player it is saving too fast apart from a deck that is out of space.
267
287
 
268
288
  `Storage` saves data for the current player. Nobody else can read it.
269
289
 
290
+ A player's whole `Storage` document is at most 2 MB. A write that takes
291
+ it past that never saves, and nothing tells the deck: `set` has already
292
+ returned.
293
+
270
294
  - **`Storage.get<T>(key): Promise<T | null>`** — the value at `key`, or
271
295
  `null` if it is not set.
272
296
  - **`Storage.set(key, value)`** — sets `key`. `value` must be something
@@ -295,7 +319,9 @@ await Store.private.set({ level: 7 });
295
319
  > and adds counters, listing, and leaderboards.
296
320
 
297
321
  `SharedStorage` saves data that other players can read. Values must be
298
- something that converts to JSON, same as `Storage`.
322
+ something that converts to JSON, same as `Storage`. Each bucket holds at
323
+ most 2 MB, and a write past that is dropped without an error, as with
324
+ `Storage`.
299
325
 
300
326
  Scopes: `'deck'` is one bucket for the whole deck, readable and writable
301
327
  by any player. `'user'` is a per-player bucket that anyone can read and
@@ -522,6 +548,9 @@ when the player collects or closes it. `options`:
522
548
 
523
549
  Collecting works only in the Castle mobile app; the website shows "collect in
524
550
  the app" and the dev server a short notice, and both resolve `unavailable`.
551
+ `offer` was added to the app on 2026-09-22; an app build from before then
552
+ rejects it with `UNKNOWN_COMMAND` (see [CastleError](#castleerror)), so catch
553
+ it where a deck has to keep going without the card.
525
554
  `SnapshotOfferResult` has a `status`:
526
555
 
527
556
  - `'collected'` — the player collected it.
@@ -536,6 +565,11 @@ const { status } = await Snapshot.offer({
536
565
  });
537
566
  ```
538
567
 
568
+ A snapshot is not the deck's cover, and no SDK call sets the cover. A cover
569
+ the creator chose by hand is kept; otherwise `save-deck` photographs the deck
570
+ as it opens, falling back to a `preview.png` the deck folder holds when that
571
+ capture does not produce one.
572
+
539
573
  ### `Snapshot.onTaken(handler): () => void`
540
574
 
541
575
  Registers a function called just before an offer's capture, the twin of the
@@ -606,7 +640,9 @@ label as the fallback. `PortalInfoResult` has a `status`:
606
640
  `null`), and `coverUrl`, a `data:` url of the deck's cover or `null`.
607
641
  - `'broken'` — the portal wouldn't work for this player: the deck doesn't
608
642
  exist, has portals turned off, or isn't visible to them.
609
- - `'unavailable'` — the host couldn't look it up.
643
+ - `'unavailable'` — the host couldn't look it up. This includes a Castle
644
+ app built before `portal.info` was added (2026-09-22): `info` never
645
+ throws `UNKNOWN_COMMAND`.
610
646
 
611
647
  ```js
612
648
  const info = await Portal.info(doorway.deckId);
@@ -650,6 +686,13 @@ for (const deck of decks) {
650
686
  }
651
687
  ```
652
688
 
689
+ ## Clipboard
690
+
691
+ The SDK has no clipboard command, and neither Castle host adds one: the
692
+ Castle app has no clipboard bridge, and castle.xyz's player does not grant
693
+ its frame `clipboard-write`. `navigator.clipboard` is best-effort — catch
694
+ its failure and show the text so the player can copy it by hand.
695
+
653
696
  ## Haptics
654
697
 
655
698
  `Haptics` plays a short device vibration ("buzz") for tactile feedback —
@@ -1030,5 +1073,25 @@ Common codes:
1030
1073
  - `CASTLE_HOST_UNAVAILABLE` — the Castle host (the app or website running
1031
1074
  the deck) didn't handle the request — e.g. it timed out or wasn't
1032
1075
  reachable. Usually transient; retry or surface a gentle error.
1076
+ - `UNKNOWN_COMMAND` ("Unknown Castle command.") — the Castle app running
1077
+ the deck is a build older than the command. Each app build knows only the
1078
+ commands it shipped with, and only an app update adds one, so keep the
1079
+ deck working without it. The app's last version bump, iOS v1.149, came
1080
+ before every command below, so v1.149 and earlier builds lack all of
1081
+ them. Each date is the commit that added the command to the app's code,
1082
+ not when a build carrying it reached the stores; an app built from code
1083
+ before that commit answers `UNKNOWN_COMMAND`:
1084
+ - `Multiplayer.join` — 2026-08-29
1085
+ - `Store` (every method) — 2026-09-03
1086
+ - `Lifecycle.restoreState` — 2026-09-08 (resolves `null` instead of
1087
+ throwing)
1088
+ - `User.get` — 2026-09-10
1089
+ - `Characters` — 2026-09-20
1090
+ - `Snapshot.offer` — 2026-09-22
1091
+ - `Portal.info` — 2026-09-22 (resolves `unavailable` instead of throwing)
1092
+ - `Agent.chat`, `Decks.mine`, `Decks.saved` — 2026-09-23
1093
+
1094
+ `Leaderboard.show` resolves `unavailable` rather than throwing on an app
1095
+ that lacks it.
1033
1096
  - `CAULDRON_STORAGE_STALE_GENERATION` — the handle belongs to an older reset
1034
1097
  generation; reopen explicitly rather than replaying work into the new one.
package/dist/castle.d.ts CHANGED
@@ -14,8 +14,9 @@ export type { LeaderboardData, LeaderboardEntry, LeaderboardOptions, Leaderboard
14
14
  export { Lifecycle } from "./lifecycle";
15
15
  export type { CastleLifecycleApi } from "./lifecycle";
16
16
  export { Multiplayer } from "./multiplayer";
17
- export type { CastleMultiplayerApi, MultiplayerConnection, MultiplayerConnectionState, MultiplayerJoinOptions, MultiplayerRoster, MultiplayerStateMetadata, } from "./multiplayer";
18
- export type { MultiplayerSoloReason } from "./commands";
17
+ export type { CastleMultiplayerApi, MultiplayerConnection, MultiplayerConnectionState, MultiplayerDestinationJoinResult, MultiplayerDestinationOptions, MultiplayerJoinOptions, MultiplayerRoster, MultiplayerStateMetadata, } from "./multiplayer";
18
+ export type { MultiplayerVoice, VoiceJoinStatus, VoiceListener, VoiceMixEntry, VoiceSpatialOptions, VoiceState, VoiceVector, } from "./multiplayerVoice";
19
+ export type { MultiplayerDestinationAudience, MultiplayerSoloReason } from "./commands";
19
20
  export { MAX_CLIENT_FRAME_BYTES, MAX_PLAYERS_CAP } from "./multiplayerProtocol";
20
21
  export type { ClientByeFrame, ClientHelloFrame, ClientMessageFrame, ClientPingFrame, ClientPongFrame, ClientReplacedFrame, ClientRosterFrame, ClientToPlatformFrame, ClientToPlatformMessageFrame, MultiplayerPlayer, PlayerIdentity, PlatformToClientFrame, SessionPlayer, SessionType, } from "./multiplayerProtocol";
21
22
  export { registerParams } from "./paramsRegistry";
@@ -103,6 +103,51 @@ export interface MultiplayerGetSessionResult {
103
103
  /** Internal local-serve scope for restoring this Play panel after a reload. */
104
104
  devReconnectScope?: string;
105
105
  }
106
+ export interface MultiplayerJoinDestinationParams {
107
+ routeId: string;
108
+ slot: string;
109
+ }
110
+ export type MultiplayerDestinationAudience = "player" | "party" | "public";
111
+ export type MultiplayerJoinDestinationStatus = "ok" | "cancelled" | "full" | "unavailable" | "not_found" | "forbidden" | "party_required";
112
+ export interface MultiplayerJoinDestinationResult {
113
+ status: MultiplayerJoinDestinationStatus;
114
+ url: string | null;
115
+ nonce: string | null;
116
+ sessionId: string | null;
117
+ audience: MultiplayerDestinationAudience | null;
118
+ }
119
+ export type VoiceState = "unavailable" | "off" | "joining" | "live";
120
+ export type VoiceJoinStatus = "joined" | "declined" | "unavailable";
121
+ export interface VoiceMixEntry {
122
+ /** 0 (silent) to 1 (full). */
123
+ gain?: number;
124
+ /** -1 (left) to 1 (right). Ignored for a player with a 3D position. */
125
+ pan?: number;
126
+ }
127
+ export type VoiceVector = [number, number, number];
128
+ export interface VoiceListener {
129
+ position: VoiceVector;
130
+ /** The direction the listener faces; need not be normalized. */
131
+ forward: VoiceVector;
132
+ /** Defaults to [0, 1, 0]. */
133
+ up?: VoiceVector;
134
+ }
135
+ export interface VoiceSpatialOptions {
136
+ /** How volume falls off with distance; default "inverse". */
137
+ distanceModel?: "linear" | "inverse" | "exponential";
138
+ /** Distance at which a voice is at full volume; default 1. */
139
+ refDistance?: number;
140
+ /** Beyond this, a voice gets no quieter ("linear": silent); default 100. */
141
+ maxDistance?: number;
142
+ /** How fast volume falls off; default 1. */
143
+ rolloff?: number;
144
+ }
145
+ export interface VoiceSpatialUpdate {
146
+ listener?: VoiceListener;
147
+ /** Keyed by userId. `null` returns a player to plain gain and pan. */
148
+ positions?: Record<string, VoiceVector | null>;
149
+ options?: VoiceSpatialOptions;
150
+ }
106
151
  export interface CharacterSummary {
107
152
  id: string;
108
153
  version: string;
@@ -295,6 +340,23 @@ export interface CommandParams {
295
340
  style: HapticStyle;
296
341
  };
297
342
  "multiplayer.getSession": MultiplayerGetSessionParams;
343
+ "multiplayer.joinDestination": MultiplayerJoinDestinationParams;
344
+ "voice.getState": {
345
+ sessionId: string;
346
+ };
347
+ "voice.requestJoin": {
348
+ sessionId: string;
349
+ };
350
+ "voice.leave": {
351
+ sessionId: string;
352
+ };
353
+ "voice.setMix": {
354
+ sessionId: string;
355
+ mix: Record<string, VoiceMixEntry>;
356
+ };
357
+ "voice.setSpatial": {
358
+ sessionId: string;
359
+ } & VoiceSpatialUpdate;
298
360
  "characters.open": {
299
361
  characterId: string;
300
362
  };
@@ -417,6 +479,22 @@ export interface CommandResult {
417
479
  "portal.info": PortalInfoResult;
418
480
  "haptics.play": HapticsResult;
419
481
  "multiplayer.getSession": MultiplayerGetSessionResult;
482
+ "multiplayer.joinDestination": MultiplayerJoinDestinationResult;
483
+ "voice.getState": {
484
+ state: VoiceState;
485
+ };
486
+ "voice.requestJoin": {
487
+ status: VoiceJoinStatus;
488
+ };
489
+ "voice.leave": {
490
+ ok: true;
491
+ };
492
+ "voice.setMix": {
493
+ ok: true;
494
+ };
495
+ "voice.setSpatial": {
496
+ ok: true;
497
+ };
420
498
  "characters.open": CharacterAdmission;
421
499
  "characters.send": CharacterOperation;
422
500
  "characters.status": CharacterOperation;
@@ -510,3 +588,18 @@ export interface UnloadDoneEnvelope {
510
588
  state?: string;
511
589
  };
512
590
  }
591
+ export type VoiceEvent = {
592
+ type: "state";
593
+ sessionId: string;
594
+ state: VoiceState;
595
+ } | {
596
+ type: "speaking";
597
+ sessionId: string;
598
+ userIds: string[];
599
+ };
600
+ export interface VoiceEventEnvelope {
601
+ castleSdk: typeof CASTLE_SDK_PROTOCOL;
602
+ voice: VoiceEvent;
603
+ }
604
+ export declare function isVoiceState(value: unknown): value is VoiceState;
605
+ export declare function voiceEventFrom(value: unknown): VoiceEvent | null;
package/dist/commands.js CHANGED
@@ -44,3 +44,28 @@ export function unloadRequestId(value) {
44
44
  const unload = record.unload;
45
45
  return typeof unload?.unloadId === "string" ? unload.unloadId : null;
46
46
  }
47
+ const VOICE_STATES = ["unavailable", "off", "joining", "live"];
48
+ export function isVoiceState(value) {
49
+ return typeof value === "string" && VOICE_STATES.includes(value);
50
+ }
51
+ export function voiceEventFrom(value) {
52
+ if (typeof value !== "object" || value === null)
53
+ return null;
54
+ const record = value;
55
+ if (record.castleSdk !== CASTLE_SDK_PROTOCOL)
56
+ return null;
57
+ const event = record.voice;
58
+ if (typeof event !== "object" || event === null)
59
+ return null;
60
+ if (typeof event.sessionId !== "string")
61
+ return null;
62
+ if (event.type === "state" && isVoiceState(event.state)) {
63
+ return { type: "state", sessionId: event.sessionId, state: event.state };
64
+ }
65
+ if (event.type === "speaking" &&
66
+ Array.isArray(event.userIds) &&
67
+ event.userIds.every((userId) => typeof userId === "string")) {
68
+ return { type: "speaking", sessionId: event.sessionId, userIds: event.userIds.slice() };
69
+ }
70
+ return null;
71
+ }
@@ -1,6 +1,7 @@
1
- import type { MultiplayerSoloReason } from "./commands";
1
+ import type { MultiplayerDestinationAudience, MultiplayerSoloReason } from "./commands";
2
2
  import { CastleError } from "./errors";
3
3
  import { type MultiplayerPlayer } from "./multiplayerProtocol";
4
+ import { type MultiplayerVoice } from "./multiplayerVoice";
4
5
  export type MultiplayerJoinOptions = {
5
6
  mode: "named";
6
7
  key: string;
@@ -9,6 +10,17 @@ export type MultiplayerJoinOptions = {
9
10
  } | {
10
11
  mode: "public";
11
12
  };
13
+ export interface MultiplayerDestinationOptions {
14
+ routeId: string;
15
+ slot: string;
16
+ }
17
+ export type MultiplayerDestinationJoinResult = {
18
+ status: "joined";
19
+ connection: MultiplayerConnection;
20
+ audience: MultiplayerDestinationAudience;
21
+ } | {
22
+ status: "cancelled" | "full" | "unavailable" | "not_found" | "forbidden" | "party_required";
23
+ };
12
24
  export type MultiplayerConnectionState = "acquiring" | "connecting" | "connected" | "reconnecting" | "closed";
13
25
  export type MultiplayerStateMetadata = {
14
26
  type: "connectionLost";
@@ -43,9 +55,12 @@ export interface MultiplayerConnection {
43
55
  onMessage(callback: (data: unknown) => void): () => void;
44
56
  onRoster(callback: (roster: MultiplayerRoster) => void): () => void;
45
57
  onStateChange(callback: (state: MultiplayerConnectionState, metadata?: MultiplayerStateMetadata) => void): () => void;
58
+ /** Voice chat for this session, run by the host. See MultiplayerVoice. */
59
+ readonly voice: MultiplayerVoice;
46
60
  leave(): void;
47
61
  }
48
62
  export interface CastleMultiplayerApi {
49
63
  join(options: MultiplayerJoinOptions): Promise<MultiplayerConnection>;
64
+ joinDestination(options: MultiplayerDestinationOptions): Promise<MultiplayerDestinationJoinResult>;
50
65
  }
51
66
  export declare const Multiplayer: CastleMultiplayerApi;
@@ -1,17 +1,22 @@
1
1
  import { CastleError } from "./errors";
2
2
  import { BINARY_MESSAGE_TAG, isBinaryPayload, MAX_CLIENT_FRAME_BYTES, toUint8Array, } from "./multiplayerProtocol";
3
3
  import { hostRequest } from "./transport";
4
+ import { MultiplayerVoiceImpl } from "./multiplayerVoice";
4
5
  const KEEPALIVE_INTERVAL_MS = 25_000;
5
6
  const HELLO_TIMEOUT_MS = 10_000;
6
7
  const RECONNECT_BUDGET_MS = 30_000;
7
8
  const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];
8
9
  const INVALID_DEV_RECONNECT_CLOSE_CODE = 4003;
9
10
  let activeConnection = null;
10
- export const Multiplayer = { join };
11
+ let latestJoinAttempt = 0;
12
+ let pendingDestinationJoin = null;
13
+ export const Multiplayer = { join, joinDestination };
11
14
  async function join(options) {
12
15
  const params = joinParams(options);
16
+ latestJoinAttempt += 1;
17
+ cancelPendingDestinationJoin();
13
18
  activeConnection?.leave();
14
- const connection = new MultiplayerConnectionImpl(params, () => {
19
+ const connection = new MultiplayerConnectionImpl({ command: "multiplayer.getSession", params }, () => {
15
20
  if (activeConnection === connection)
16
21
  activeConnection = null;
17
22
  });
@@ -19,9 +24,80 @@ async function join(options) {
19
24
  await connection.start();
20
25
  return connection;
21
26
  }
27
+ async function joinDestination(options) {
28
+ const params = destinationParams(options);
29
+ const attempt = ++latestJoinAttempt;
30
+ cancelPendingDestinationJoin();
31
+ if (activeConnection?.matchesDestination(params)) {
32
+ return {
33
+ status: "joined",
34
+ connection: activeConnection,
35
+ audience: activeConnection.destinationAudience,
36
+ };
37
+ }
38
+ const previousConnection = activeConnection;
39
+ const controller = new AbortController();
40
+ const pending = { controller, connection: null };
41
+ pendingDestinationJoin = pending;
42
+ let acquired;
43
+ try {
44
+ acquired = await hostRequest("multiplayer.joinDestination", params, {
45
+ signal: controller.signal,
46
+ });
47
+ }
48
+ catch (error) {
49
+ if (pendingDestinationJoin === pending)
50
+ pendingDestinationJoin = null;
51
+ if (attempt !== latestJoinAttempt || controller.signal.aborted) {
52
+ return { status: "cancelled" };
53
+ }
54
+ const status = destinationStatusFromError(error);
55
+ if (status)
56
+ return { status };
57
+ throw error;
58
+ }
59
+ if (attempt !== latestJoinAttempt)
60
+ return { status: "cancelled" };
61
+ if (acquired.status !== "ok") {
62
+ if (pendingDestinationJoin === pending)
63
+ pendingDestinationJoin = null;
64
+ return { status: acquired.status };
65
+ }
66
+ if (!acquired.url || !acquired.nonce || !acquired.audience) {
67
+ if (pendingDestinationJoin === pending)
68
+ pendingDestinationJoin = null;
69
+ return { status: "unavailable" };
70
+ }
71
+ const connection = new MultiplayerConnectionImpl({ command: "multiplayer.joinDestination", params }, () => {
72
+ if (activeConnection === connection)
73
+ activeConnection = null;
74
+ }, acquired.audience);
75
+ pending.connection = connection;
76
+ try {
77
+ await connection.start(acquired);
78
+ }
79
+ catch (error) {
80
+ if (pendingDestinationJoin === pending)
81
+ pendingDestinationJoin = null;
82
+ if (attempt !== latestJoinAttempt || controller.signal.aborted) {
83
+ return { status: "cancelled" };
84
+ }
85
+ throw error;
86
+ }
87
+ if (pendingDestinationJoin === pending)
88
+ pendingDestinationJoin = null;
89
+ if (attempt !== latestJoinAttempt) {
90
+ connection.leave();
91
+ return { status: "cancelled" };
92
+ }
93
+ previousConnection?.leave();
94
+ activeConnection = connection;
95
+ return { status: "joined", connection, audience: acquired.audience };
96
+ }
22
97
  class MultiplayerConnectionImpl {
23
- params;
98
+ request;
24
99
  onClosed;
100
+ destinationAudience;
25
101
  currentState = "acquiring";
26
102
  currentPlayerId = "";
27
103
  currentSessionId = "";
@@ -39,9 +115,15 @@ class MultiplayerConnectionImpl {
39
115
  messages = new Set();
40
116
  rosters = new Set();
41
117
  states = new Set();
42
- constructor(params, onClosed) {
43
- this.params = params;
118
+ voiceImpl = new MultiplayerVoiceImpl({
119
+ sessionId: () => this.currentSessionId,
120
+ players: () => this.roster?.players ?? [],
121
+ isSolo: () => this.solo,
122
+ });
123
+ constructor(request, onClosed, destinationAudience = null) {
124
+ this.request = request;
44
125
  this.onClosed = onClosed;
126
+ this.destinationAudience = destinationAudience;
45
127
  }
46
128
  get state() {
47
129
  return this.currentState;
@@ -58,9 +140,12 @@ class MultiplayerConnectionImpl {
58
140
  get soloReason() {
59
141
  return this.currentSoloReason;
60
142
  }
61
- async start() {
143
+ get voice() {
144
+ return this.voiceImpl;
145
+ }
146
+ async start(initial) {
62
147
  try {
63
- await this.acquire();
148
+ await this.acquire(initial);
64
149
  }
65
150
  catch (error) {
66
151
  const castleError = asCastleError(error);
@@ -108,9 +193,16 @@ class MultiplayerConnectionImpl {
108
193
  return;
109
194
  this.finish({ type: "left" });
110
195
  }
111
- async acquire() {
196
+ matchesDestination(params) {
197
+ return (this.currentState !== "closed" &&
198
+ this.request.command === "multiplayer.joinDestination" &&
199
+ this.request.params.routeId === params.routeId &&
200
+ this.request.params.slot === params.slot &&
201
+ this.destinationAudience !== null);
202
+ }
203
+ async acquire(initial) {
112
204
  this.transition("acquiring");
113
- const result = await hostRequest("multiplayer.getSession", this.params);
205
+ const result = initial ?? await requestAcquisition(this.request);
114
206
  this.assertOpen();
115
207
  if (result.status === "solo") {
116
208
  this.connectSolo(result);
@@ -122,8 +214,11 @@ class MultiplayerConnectionImpl {
122
214
  this.solo = false;
123
215
  this.currentSoloReason = null;
124
216
  this.baseUrl = result.url;
125
- this.devReconnectStorageKey = result.devReconnectScope
126
- ? `castle-dev-mp-reconnect:${result.url}:${result.sessionId}:${result.devReconnectScope}`
217
+ const devReconnectScope = "devReconnectScope" in result
218
+ ? result.devReconnectScope
219
+ : undefined;
220
+ this.devReconnectStorageKey = devReconnectScope
221
+ ? `castle-dev-mp-reconnect:${result.url}:${result.sessionId}:${devReconnectScope}`
127
222
  : null;
128
223
  this.transition("connecting");
129
224
  const savedToken = this.devReconnectStorageKey
@@ -171,6 +266,7 @@ class MultiplayerConnectionImpl {
171
266
  };
172
267
  this.setRoster({ players: [you], you });
173
268
  this.transition("connected");
269
+ void this.voiceImpl.attach();
174
270
  }
175
271
  openSocket(credential, value) {
176
272
  const attempt = ++this.socketAttempt;
@@ -253,6 +349,7 @@ class MultiplayerConnectionImpl {
253
349
  this.reAcquisitionUsed = false;
254
350
  this.transition("connected");
255
351
  this.startKeepalive();
352
+ void this.voiceImpl.attach();
256
353
  }
257
354
  handleFrame(frame) {
258
355
  switch (frame.t) {
@@ -293,6 +390,7 @@ class MultiplayerConnectionImpl {
293
390
  }
294
391
  setRoster(roster) {
295
392
  this.roster = roster;
393
+ this.voiceImpl.rosterChanged();
296
394
  for (const callback of this.rosters)
297
395
  callListener(callback, roster);
298
396
  }
@@ -391,6 +489,7 @@ class MultiplayerConnectionImpl {
391
489
  this.socketAttempt += 1;
392
490
  closeSocket(socket, 1000, "session closed");
393
491
  this.currentState = "closed";
492
+ this.voiceImpl.dispose();
394
493
  for (const callback of this.states)
395
494
  callStateListener(callback, "closed", metadata);
396
495
  this.onClosed();
@@ -423,6 +522,62 @@ function joinParams(options) {
423
522
  }
424
523
  throw invalidJoinOptions();
425
524
  }
525
+ function destinationParams(options) {
526
+ if (!options ||
527
+ typeof options !== "object" ||
528
+ typeof options.routeId !== "string" ||
529
+ !/^[A-Za-z0-9_-]{1,64}$/.test(options.routeId) ||
530
+ typeof options.slot !== "string" ||
531
+ options.slot.length === 0 ||
532
+ options.slot.includes("\0") ||
533
+ !isValidUnicode(options.slot) ||
534
+ new TextEncoder().encode(options.slot).byteLength > 128) {
535
+ throw multiplayerError("INVALID_ARGUMENT", "Multiplayer.joinDestination requires a valid routeId and a non-empty slot of at most 128 bytes.", "Multiplayer.joinDestination");
536
+ }
537
+ return { routeId: options.routeId, slot: options.slot };
538
+ }
539
+ function cancelPendingDestinationJoin() {
540
+ const pending = pendingDestinationJoin;
541
+ pendingDestinationJoin = null;
542
+ pending?.controller.abort();
543
+ pending?.connection?.leave();
544
+ }
545
+ async function requestAcquisition(request) {
546
+ if (request.command === "multiplayer.getSession") {
547
+ return hostRequest(request.command, request.params);
548
+ }
549
+ return hostRequest(request.command, request.params);
550
+ }
551
+ function destinationStatusFromError(error) {
552
+ if (!(error instanceof CastleError))
553
+ return null;
554
+ switch (error.code) {
555
+ case "CAULDRON_DESTINATION_NOT_FOUND":
556
+ return "not_found";
557
+ case "CAULDRON_DESTINATION_FORBIDDEN":
558
+ return "forbidden";
559
+ case "CAULDRON_DESTINATION_PARTY_REQUIRED":
560
+ return "party_required";
561
+ case "CAULDRON_DESTINATION_FULL":
562
+ return "full";
563
+ case "CAULDRON_DESTINATION_UNAVAILABLE":
564
+ case "CAULDRON_DESTINATION_CONFIG_CHANGED":
565
+ return "unavailable";
566
+ case "CANCELLED":
567
+ return "cancelled";
568
+ default:
569
+ return null;
570
+ }
571
+ }
572
+ function isValidUnicode(value) {
573
+ try {
574
+ encodeURIComponent(value);
575
+ return true;
576
+ }
577
+ catch {
578
+ return false;
579
+ }
580
+ }
426
581
  function invalidJoinOptions() {
427
582
  return multiplayerError("INVALID_ARGUMENT", "Multiplayer.join requires named, party, or public options.");
428
583
  }