castle-web-sdk 0.4.35 → 0.4.36

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
@@ -15,6 +15,7 @@ export { Lifecycle } from "./lifecycle";
15
15
  export type { CastleLifecycleApi } from "./lifecycle";
16
16
  export { Multiplayer } from "./multiplayer";
17
17
  export type { CastleMultiplayerApi, MultiplayerConnection, MultiplayerConnectionState, MultiplayerJoinOptions, MultiplayerRoster, MultiplayerStateMetadata, } from "./multiplayer";
18
+ export type { MultiplayerVoice, VoiceJoinStatus, VoiceMixEntry, VoiceState, } from "./multiplayerVoice";
18
19
  export type { 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";
@@ -103,6 +103,14 @@ 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 type VoiceState = "unavailable" | "off" | "joining" | "live";
107
+ export type VoiceJoinStatus = "joined" | "declined" | "unavailable";
108
+ export interface VoiceMixEntry {
109
+ /** 0 (silent) to 1 (full). */
110
+ gain?: number;
111
+ /** -1 (left) to 1 (right). */
112
+ pan?: number;
113
+ }
106
114
  export interface CharacterSummary {
107
115
  id: string;
108
116
  version: string;
@@ -295,6 +303,19 @@ export interface CommandParams {
295
303
  style: HapticStyle;
296
304
  };
297
305
  "multiplayer.getSession": MultiplayerGetSessionParams;
306
+ "voice.getState": {
307
+ sessionId: string;
308
+ };
309
+ "voice.requestJoin": {
310
+ sessionId: string;
311
+ };
312
+ "voice.leave": {
313
+ sessionId: string;
314
+ };
315
+ "voice.setMix": {
316
+ sessionId: string;
317
+ mix: Record<string, VoiceMixEntry>;
318
+ };
298
319
  "characters.open": {
299
320
  characterId: string;
300
321
  };
@@ -417,6 +438,18 @@ export interface CommandResult {
417
438
  "portal.info": PortalInfoResult;
418
439
  "haptics.play": HapticsResult;
419
440
  "multiplayer.getSession": MultiplayerGetSessionResult;
441
+ "voice.getState": {
442
+ state: VoiceState;
443
+ };
444
+ "voice.requestJoin": {
445
+ status: VoiceJoinStatus;
446
+ };
447
+ "voice.leave": {
448
+ ok: true;
449
+ };
450
+ "voice.setMix": {
451
+ ok: true;
452
+ };
420
453
  "characters.open": CharacterAdmission;
421
454
  "characters.send": CharacterOperation;
422
455
  "characters.status": CharacterOperation;
@@ -510,3 +543,18 @@ export interface UnloadDoneEnvelope {
510
543
  state?: string;
511
544
  };
512
545
  }
546
+ export type VoiceEvent = {
547
+ type: "state";
548
+ sessionId: string;
549
+ state: VoiceState;
550
+ } | {
551
+ type: "speaking";
552
+ sessionId: string;
553
+ userIds: string[];
554
+ };
555
+ export interface VoiceEventEnvelope {
556
+ castleSdk: typeof CASTLE_SDK_PROTOCOL;
557
+ voice: VoiceEvent;
558
+ }
559
+ export declare function isVoiceState(value: unknown): value is VoiceState;
560
+ 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
1
  import type { 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;
@@ -43,6 +44,8 @@ export interface MultiplayerConnection {
43
44
  onMessage(callback: (data: unknown) => void): () => void;
44
45
  onRoster(callback: (roster: MultiplayerRoster) => void): () => void;
45
46
  onStateChange(callback: (state: MultiplayerConnectionState, metadata?: MultiplayerStateMetadata) => void): () => void;
47
+ /** Voice chat for this session, run by the host. See MultiplayerVoice. */
48
+ readonly voice: MultiplayerVoice;
46
49
  leave(): void;
47
50
  }
48
51
  export interface CastleMultiplayerApi {
@@ -1,6 +1,7 @@
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;
@@ -39,6 +40,11 @@ class MultiplayerConnectionImpl {
39
40
  messages = new Set();
40
41
  rosters = new Set();
41
42
  states = new Set();
43
+ voiceImpl = new MultiplayerVoiceImpl({
44
+ sessionId: () => this.currentSessionId,
45
+ players: () => this.roster?.players ?? [],
46
+ isSolo: () => this.solo,
47
+ });
42
48
  constructor(params, onClosed) {
43
49
  this.params = params;
44
50
  this.onClosed = onClosed;
@@ -58,6 +64,9 @@ class MultiplayerConnectionImpl {
58
64
  get soloReason() {
59
65
  return this.currentSoloReason;
60
66
  }
67
+ get voice() {
68
+ return this.voiceImpl;
69
+ }
61
70
  async start() {
62
71
  try {
63
72
  await this.acquire();
@@ -171,6 +180,7 @@ class MultiplayerConnectionImpl {
171
180
  };
172
181
  this.setRoster({ players: [you], you });
173
182
  this.transition("connected");
183
+ void this.voiceImpl.attach();
174
184
  }
175
185
  openSocket(credential, value) {
176
186
  const attempt = ++this.socketAttempt;
@@ -253,6 +263,7 @@ class MultiplayerConnectionImpl {
253
263
  this.reAcquisitionUsed = false;
254
264
  this.transition("connected");
255
265
  this.startKeepalive();
266
+ void this.voiceImpl.attach();
256
267
  }
257
268
  handleFrame(frame) {
258
269
  switch (frame.t) {
@@ -293,6 +304,7 @@ class MultiplayerConnectionImpl {
293
304
  }
294
305
  setRoster(roster) {
295
306
  this.roster = roster;
307
+ this.voiceImpl.rosterChanged();
296
308
  for (const callback of this.rosters)
297
309
  callListener(callback, roster);
298
310
  }
@@ -391,6 +403,7 @@ class MultiplayerConnectionImpl {
391
403
  this.socketAttempt += 1;
392
404
  closeSocket(socket, 1000, "session closed");
393
405
  this.currentState = "closed";
406
+ this.voiceImpl.dispose();
394
407
  for (const callback of this.states)
395
408
  callStateListener(callback, "closed", metadata);
396
409
  this.onClosed();
@@ -0,0 +1,67 @@
1
+ import type { VoiceEvent, VoiceJoinStatus, VoiceMixEntry, VoiceState } from "./commands";
2
+ import type { MultiplayerPlayer } from "./multiplayerProtocol";
3
+ export declare const VOICE_MIX_INTERVAL_MS = 100;
4
+ export type { VoiceJoinStatus, VoiceMixEntry, VoiceState };
5
+ export interface MultiplayerVoice {
6
+ /** "unavailable" when this host, deck, session or player can't have voice. */
7
+ readonly state: VoiceState;
8
+ /**
9
+ * Asks the player to join voice. The host shows its own prompt; nothing happens
10
+ * without the player's say-so. Resolves once they have chosen.
11
+ */
12
+ requestJoin(): Promise<VoiceJoinStatus>;
13
+ /** Leaves voice, or withdraws a pending request. The game connection is unaffected. */
14
+ leave(): void;
15
+ /**
16
+ * Suggested volume (0-1) and stereo position (-1 left to 1 right) per player,
17
+ * keyed by playerId. Players and fields not named keep their last value (initially
18
+ * full volume, centered). Sent at most ten times a second; the latest values win.
19
+ */
20
+ setMix(mix: Record<string, VoiceMixEntry>): void;
21
+ onState(callback: (state: VoiceState) => void): () => void;
22
+ /** The playerIds speaking right now; called whenever that set changes. */
23
+ onSpeaking(callback: (playerIds: string[]) => void): () => void;
24
+ }
25
+ export interface VoiceSessionAccess {
26
+ sessionId(): string;
27
+ players(): MultiplayerPlayer[];
28
+ isSolo(): boolean;
29
+ }
30
+ export declare class MultiplayerVoiceImpl implements MultiplayerVoice {
31
+ private readonly access;
32
+ private currentState;
33
+ private voiceSessionId;
34
+ private attaching;
35
+ private hostStateVersion;
36
+ private pendingJoin;
37
+ private joinRequest;
38
+ private speakingUserIds;
39
+ private speaking;
40
+ private pendingMix;
41
+ private sentMix;
42
+ private mixTimer;
43
+ private lastMixSentAt;
44
+ private disposed;
45
+ private readonly states;
46
+ private readonly speakers;
47
+ constructor(access: VoiceSessionAccess);
48
+ get state(): VoiceState;
49
+ attach(): Promise<void>;
50
+ requestJoin(): Promise<VoiceJoinStatus>;
51
+ private askToJoin;
52
+ leave(): void;
53
+ setMix(mix: Record<string, VoiceMixEntry>): void;
54
+ onState(callback: (state: VoiceState) => void): () => void;
55
+ onSpeaking(callback: (playerIds: string[]) => void): () => void;
56
+ receive(event: VoiceEvent): void;
57
+ rosterChanged(): void;
58
+ dispose(): void;
59
+ private isCurrent;
60
+ private withdraw;
61
+ private endSession;
62
+ private sendLeave;
63
+ private setState;
64
+ private emitSpeaking;
65
+ private scheduleMix;
66
+ private flushMix;
67
+ }
@@ -0,0 +1,309 @@
1
+ import { hostRequest, setVoiceEventHandler } from "./transport";
2
+ // Voice chat in a multiplayer session. The host (castle.xyz or the Castle app) owns
3
+ // the microphone, the call and playback, and shows its own prompt and live-mic badge; a
4
+ // deck never touches audio. What a deck gets: whether voice is on, a way to ask the
5
+ // player to join, who is speaking, and a suggested volume and stereo position per
6
+ // player (proximity chat). Push-to-talk and muting are the player's, in the host's UI.
7
+ // A deck driving proximity every frame sends far more than the host needs.
8
+ export const VOICE_MIX_INTERVAL_MS = 100;
9
+ const DEFAULT_MIX = { gain: 1, pan: 0 };
10
+ let activeVoice = null;
11
+ function dispatchVoiceEvent(event) {
12
+ activeVoice?.receive(event);
13
+ }
14
+ // One multiplayer connection is live at a time, so one voice receives host events.
15
+ function setActiveVoice(voice, current) {
16
+ if (current && activeVoice !== current)
17
+ return;
18
+ activeVoice = voice;
19
+ }
20
+ export class MultiplayerVoiceImpl {
21
+ access;
22
+ currentState = "unavailable";
23
+ // The session this voice state belongs to; "" before the first attach or in solo.
24
+ voiceSessionId = "";
25
+ attaching = null;
26
+ // Bumped on every host state event, so a join reply never overrides a newer one.
27
+ hostStateVersion = 0;
28
+ pendingJoin = null;
29
+ joinRequest = null;
30
+ speakingUserIds = [];
31
+ speaking = [];
32
+ pendingMix = {};
33
+ sentMix = {};
34
+ mixTimer = null;
35
+ lastMixSentAt = 0;
36
+ disposed = false;
37
+ states = new Set();
38
+ speakers = new Set();
39
+ constructor(access) {
40
+ this.access = access;
41
+ }
42
+ get state() {
43
+ return this.currentState;
44
+ }
45
+ // Called by the connection whenever it (re)gains a session. A reconnect keeps the
46
+ // session; a re-acquisition may land in a different one, or in solo, and voice for
47
+ // the old session ends with it. Asks the host where voice stands; a host without
48
+ // voice (an older app, local dev) answers with an error or "unavailable".
49
+ attach() {
50
+ if (this.disposed)
51
+ return Promise.resolve();
52
+ setActiveVoice(this);
53
+ setVoiceEventHandler(dispatchVoiceEvent);
54
+ const sessionId = this.access.isSolo() ? "" : this.access.sessionId();
55
+ if (this.voiceSessionId !== sessionId) {
56
+ this.endSession();
57
+ // The old session's state means nothing for the new one until the host answers.
58
+ this.setState("unavailable");
59
+ }
60
+ this.voiceSessionId = sessionId;
61
+ if (!sessionId) {
62
+ this.setState("unavailable");
63
+ this.attaching = null;
64
+ return Promise.resolve();
65
+ }
66
+ const attaching = hostRequest("voice.getState", { sessionId }).then((result) => {
67
+ if (this.isCurrent(sessionId))
68
+ this.setState(result.state);
69
+ }, () => {
70
+ if (this.isCurrent(sessionId))
71
+ this.setState("unavailable");
72
+ });
73
+ this.attaching = attaching;
74
+ return attaching;
75
+ }
76
+ // A second call while the player is still choosing shares the first one's answer.
77
+ requestJoin() {
78
+ if (!this.joinRequest) {
79
+ const request = this.askToJoin().finally(() => {
80
+ if (this.joinRequest === request)
81
+ this.joinRequest = null;
82
+ });
83
+ this.joinRequest = request;
84
+ }
85
+ return this.joinRequest;
86
+ }
87
+ async askToJoin() {
88
+ // Registered before any waiting, so leave() or a session change can withdraw a
89
+ // request that hasn't reached the host yet.
90
+ const pending = new AbortController();
91
+ this.pendingJoin = pending;
92
+ // `join()` resolves at hello, before the host has said whether voice is on. A
93
+ // reconnect can attach again meanwhile; wait for the latest answer.
94
+ let awaited = null;
95
+ while (this.attaching && this.attaching !== awaited) {
96
+ awaited = this.attaching;
97
+ await awaited;
98
+ }
99
+ if (pending.signal.aborted ||
100
+ this.disposed ||
101
+ !this.voiceSessionId ||
102
+ this.currentState === "unavailable" ||
103
+ this.currentState === "live") {
104
+ if (this.pendingJoin === pending)
105
+ this.pendingJoin = null;
106
+ if (pending.signal.aborted || this.currentState !== "live")
107
+ return "unavailable";
108
+ return "joined";
109
+ }
110
+ const sessionId = this.voiceSessionId;
111
+ const version = this.hostStateVersion;
112
+ this.setState("joining");
113
+ let status;
114
+ try {
115
+ ({ status } = await hostRequest("voice.requestJoin", { sessionId }, { signal: pending.signal }));
116
+ }
117
+ catch {
118
+ status = "unavailable";
119
+ }
120
+ finally {
121
+ if (this.pendingJoin === pending)
122
+ this.pendingJoin = null;
123
+ }
124
+ if (pending.signal.aborted || !this.isCurrent(sessionId)) {
125
+ // Withdrawn, or the game connection ended, while the player was choosing. The
126
+ // abort already cancelled the request at the host, whose executor undoes a
127
+ // "yes" that lands anyway.
128
+ return "unavailable";
129
+ }
130
+ if (version === this.hostStateVersion) {
131
+ this.setState(status === "joined" ? "live" : status === "unavailable" ? "unavailable" : "off");
132
+ }
133
+ return status;
134
+ }
135
+ leave() {
136
+ if (this.disposed)
137
+ return;
138
+ const inVoice = this.currentState === "live" || this.currentState === "joining";
139
+ if (!inVoice && this.pendingJoin === null)
140
+ return;
141
+ this.withdraw();
142
+ if (inVoice) {
143
+ this.setState("off");
144
+ this.sendLeave(this.voiceSessionId);
145
+ }
146
+ }
147
+ setMix(mix) {
148
+ if (this.disposed || !mix || typeof mix !== "object")
149
+ return;
150
+ const players = this.access.players();
151
+ for (const [playerId, entry] of Object.entries(mix)) {
152
+ const player = players.find((candidate) => candidate.playerId === playerId);
153
+ if (!player || !entry || typeof entry !== "object")
154
+ continue;
155
+ const next = { ...this.pendingMix[player.userId] };
156
+ if (typeof entry.gain === "number" && Number.isFinite(entry.gain)) {
157
+ next.gain = clamp(entry.gain, 0, 1);
158
+ }
159
+ if (typeof entry.pan === "number" && Number.isFinite(entry.pan)) {
160
+ next.pan = clamp(entry.pan, -1, 1);
161
+ }
162
+ this.pendingMix[player.userId] = next;
163
+ }
164
+ this.scheduleMix();
165
+ }
166
+ onState(callback) {
167
+ this.states.add(callback);
168
+ callListener(callback, this.currentState);
169
+ return () => this.states.delete(callback);
170
+ }
171
+ onSpeaking(callback) {
172
+ this.speakers.add(callback);
173
+ callListener(callback, this.speaking.slice());
174
+ return () => this.speakers.delete(callback);
175
+ }
176
+ receive(event) {
177
+ if (this.disposed || !this.voiceSessionId || event.sessionId !== this.voiceSessionId) {
178
+ return;
179
+ }
180
+ if (event.type === "state") {
181
+ this.hostStateVersion += 1;
182
+ this.setState(event.state);
183
+ return;
184
+ }
185
+ this.speakingUserIds = event.userIds.slice();
186
+ this.emitSpeaking();
187
+ }
188
+ // The roster changed: speakers are re-mapped, and mixes for players who left dropped.
189
+ rosterChanged() {
190
+ if (this.disposed)
191
+ return;
192
+ const userIds = new Set(this.access.players().map((player) => player.userId));
193
+ for (const userId of Object.keys(this.pendingMix)) {
194
+ if (!userIds.has(userId))
195
+ delete this.pendingMix[userId];
196
+ }
197
+ for (const userId of Object.keys(this.sentMix)) {
198
+ if (!userIds.has(userId))
199
+ delete this.sentMix[userId];
200
+ }
201
+ this.emitSpeaking();
202
+ }
203
+ // The game connection ended: voice for its session goes with it.
204
+ dispose() {
205
+ if (this.disposed)
206
+ return;
207
+ this.endSession();
208
+ this.setState("unavailable");
209
+ this.disposed = true;
210
+ if (this.mixTimer !== null)
211
+ clearTimeout(this.mixTimer);
212
+ this.mixTimer = null;
213
+ setActiveVoice(null, this);
214
+ }
215
+ isCurrent(sessionId) {
216
+ return !this.disposed && sessionId !== "" && sessionId === this.voiceSessionId;
217
+ }
218
+ // Cancels a pending request, so a retry starts a fresh prompt.
219
+ withdraw() {
220
+ this.pendingJoin?.abort();
221
+ this.pendingJoin = null;
222
+ this.joinRequest = null;
223
+ }
224
+ // Leaves the current voice session, if the player is in it or being asked.
225
+ endSession() {
226
+ const inVoice = this.currentState === "live" || this.currentState === "joining" || this.pendingJoin !== null;
227
+ this.withdraw();
228
+ if (inVoice && this.voiceSessionId)
229
+ this.sendLeave(this.voiceSessionId);
230
+ this.speakingUserIds = [];
231
+ this.pendingMix = {};
232
+ this.sentMix = {};
233
+ this.emitSpeaking();
234
+ }
235
+ sendLeave(sessionId) {
236
+ void hostRequest("voice.leave", { sessionId }).catch(() => { });
237
+ }
238
+ setState(state) {
239
+ if (this.currentState === state)
240
+ return;
241
+ this.currentState = state;
242
+ if (state !== "live")
243
+ this.speakingUserIds = [];
244
+ this.emitSpeaking();
245
+ for (const callback of this.states)
246
+ callListener(callback, state);
247
+ if (state === "live") {
248
+ // A rejoined call starts from scratch at the host: send the deck's mix again.
249
+ for (const userId of Object.keys(this.sentMix)) {
250
+ this.pendingMix[userId] ??= {};
251
+ }
252
+ this.scheduleMix();
253
+ }
254
+ }
255
+ emitSpeaking() {
256
+ const userIds = new Set(this.speakingUserIds);
257
+ const playerIds = this.access
258
+ .players()
259
+ .filter((player) => userIds.has(player.userId))
260
+ .map((player) => player.playerId)
261
+ .sort();
262
+ if (playerIds.join("\n") === this.speaking.join("\n"))
263
+ return;
264
+ this.speaking = playerIds;
265
+ for (const callback of this.speakers)
266
+ callListener(callback, playerIds.slice());
267
+ }
268
+ scheduleMix() {
269
+ if (this.mixTimer !== null || Object.keys(this.pendingMix).length === 0)
270
+ return;
271
+ const wait = Math.max(0, this.lastMixSentAt + VOICE_MIX_INTERVAL_MS - Date.now());
272
+ this.mixTimer = setTimeout(() => {
273
+ this.mixTimer = null;
274
+ this.flushMix();
275
+ }, wait);
276
+ }
277
+ // Held until the player is in voice, then sent: the latest mix is what matters. Each
278
+ // entry goes out whole, so a field the deck didn't name keeps its last value.
279
+ flushMix() {
280
+ if (this.disposed || !this.voiceSessionId || this.currentState !== "live")
281
+ return;
282
+ const userIds = new Set(this.access.players().map((player) => player.userId));
283
+ const mix = {};
284
+ for (const [userId, entry] of Object.entries(this.pendingMix)) {
285
+ if (!userIds.has(userId))
286
+ continue;
287
+ const previous = this.sentMix[userId] ?? DEFAULT_MIX;
288
+ const full = { gain: entry.gain ?? previous.gain, pan: entry.pan ?? previous.pan };
289
+ this.sentMix[userId] = full;
290
+ mix[userId] = full;
291
+ }
292
+ this.pendingMix = {};
293
+ if (Object.keys(mix).length === 0)
294
+ return;
295
+ this.lastMixSentAt = Date.now();
296
+ void hostRequest("voice.setMix", { sessionId: this.voiceSessionId, mix }).catch(() => { });
297
+ }
298
+ }
299
+ function clamp(value, min, max) {
300
+ return Math.min(max, Math.max(min, value));
301
+ }
302
+ function callListener(callback, value) {
303
+ try {
304
+ callback(value);
305
+ }
306
+ catch {
307
+ // One deck listener must not break voice or other listeners.
308
+ }
309
+ }
@@ -1,4 +1,4 @@
1
- import { type CommandName, type CommandParams, type CommandResult, type LifecycleEvent } from "./commands";
1
+ import { type CommandName, type CommandParams, type CommandResult, type LifecycleEvent, type VoiceEvent } from "./commands";
2
2
  type PostChannel = "mobile" | "web";
3
3
  export interface HostStreamOptions {
4
4
  onStream?: (event: unknown) => void;
@@ -20,5 +20,6 @@ export declare function getCommandChannel(): PostChannel | "local";
20
20
  export declare function hostNotify(event: LifecycleEvent): void;
21
21
  export declare function postUnloadDone(unloadId: string, state?: string): void;
22
22
  export declare function setUnloadHandler(handler: (unloadId: string) => void): void;
23
+ export declare function setVoiceEventHandler(handler: ((event: VoiceEvent) => void) | null): void;
23
24
  export declare function installHostListener(): void;
24
25
  export {};
package/dist/transport.js CHANGED
@@ -11,7 +11,7 @@
11
11
  // A streaming command (agent.chat) gets stream envelopes on its requestId before
12
12
  // its response. Each one goes to the caller's onStream and restarts the wait,
13
13
  // and the caller's AbortSignal sends the host a cancel envelope.
14
- import { CASTLE_SDK_PROTOCOL, COMMAND_TIMEOUTS_MS, STREAM_IDLE_TIMEOUT_MS, isResponseEnvelope, isStreamEnvelope, unloadRequestId, } from "./commands";
14
+ import { CASTLE_SDK_PROTOCOL, COMMAND_TIMEOUTS_MS, STREAM_IDLE_TIMEOUT_MS, isResponseEnvelope, isStreamEnvelope, unloadRequestId, voiceEventFrom, } from "./commands";
15
15
  import { getCastleEmbed } from "./context";
16
16
  import { CastleError } from "./errors";
17
17
  import { cancelLocalCommand, releaseLocalCommand, sendLocalCommand } from "./runtime";
@@ -23,6 +23,8 @@ const INTERACTIVE_COMMANDS = new Set([
23
23
  "pass.offer",
24
24
  "snapshot.offer",
25
25
  "portal.open",
26
+ // The host's voice prompt and the browser's microphone permission.
27
+ "voice.requestJoin",
26
28
  ]);
27
29
  // Request ids carry a per-page prefix so that a host serving several decks at
28
30
  // once can key a cancel by requestId alone.
@@ -31,6 +33,7 @@ let nextRequestId = 1;
31
33
  const pending = new Map();
32
34
  let listenersInstalled = false;
33
35
  let unloadHandler = null;
36
+ let voiceEventHandler = null;
34
37
  export async function hostRequest(command, params, stream = {}) {
35
38
  try {
36
39
  const env = await sendCommand(resolveChannel(), command, params, stream);
@@ -93,6 +96,12 @@ export function postUnloadDone(unloadId, state) {
93
96
  export function setUnloadHandler(handler) {
94
97
  unloadHandler = handler;
95
98
  }
99
+ // Registered by multiplayerVoice.ts, the same way, for host voice events.
100
+ export function setVoiceEventHandler(handler) {
101
+ voiceEventHandler = handler;
102
+ if (handler)
103
+ installHostListener();
104
+ }
96
105
  function resolveChannel() {
97
106
  if (typeof window === "undefined")
98
107
  return "local";
@@ -218,6 +227,11 @@ function receive(message) {
218
227
  deliverStream(message.requestId, message.stream);
219
228
  return;
220
229
  }
230
+ const voiceEvent = voiceEventFrom(message);
231
+ if (voiceEvent) {
232
+ voiceEventHandler?.(voiceEvent);
233
+ return;
234
+ }
221
235
  const unloadId = unloadRequestId(message);
222
236
  if (unloadId !== null)
223
237
  unloadHandler?.(unloadId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-sdk",
3
- "version": "0.4.35",
3
+ "version": "0.4.36",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/castle-xyz/castle-experimental-web.git"