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 +70 -7
- package/dist/castle.d.ts +3 -2
- package/dist/commands.d.ts +93 -0
- package/dist/commands.js +25 -0
- package/dist/multiplayer.d.ts +16 -1
- package/dist/multiplayer.js +166 -11
- package/dist/multiplayerVoice.d.ts +91 -0
- package/dist/multiplayerVoice.js +424 -0
- package/dist/server/platformHandle.d.ts +13 -0
- package/dist/server/wrapper.d.ts +30 -1
- package/dist/server/wrapper.js +92 -0
- package/dist/transport.d.ts +2 -1
- package/dist/transport.js +16 -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
|
@@ -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 {
|
|
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";
|
package/dist/commands.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/multiplayer.d.ts
CHANGED
|
@@ -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;
|
package/dist/multiplayer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
126
|
-
?
|
|
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
|
}
|