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 +70 -7
- package/dist/castle.d.ts +1 -0
- package/dist/commands.d.ts +48 -0
- package/dist/commands.js +25 -0
- package/dist/multiplayer.d.ts +3 -0
- package/dist/multiplayer.js +13 -0
- package/dist/multiplayerVoice.d.ts +67 -0
- package/dist/multiplayerVoice.js +309 -0
- package/dist/transport.d.ts +2 -1
- package/dist/transport.js +15 -1
- package/package.json +1 -1
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
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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";
|
package/dist/commands.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/multiplayer.d.ts
CHANGED
|
@@ -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 {
|
package/dist/multiplayer.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/transport.d.ts
CHANGED
|
@@ -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);
|