castle-web-sdk 0.4.36 → 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/dist/castle.d.ts CHANGED
@@ -14,9 +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 { MultiplayerVoice, VoiceJoinStatus, VoiceMixEntry, VoiceState, } from "./multiplayerVoice";
19
- export type { MultiplayerSoloReason } from "./commands";
17
+ export type { CastleMultiplayerApi, MultiplayerConnection, MultiplayerConnectionState, MultiplayerDestinationJoinResult, MultiplayerDestinationOptions, MultiplayerJoinOptions, MultiplayerRoster, MultiplayerStateMetadata, } from "./multiplayer";
18
+ export type { MultiplayerVoice, VoiceJoinStatus, VoiceListener, VoiceMixEntry, VoiceSpatialOptions, VoiceState, VoiceVector, } from "./multiplayerVoice";
19
+ export type { MultiplayerDestinationAudience, MultiplayerSoloReason } from "./commands";
20
20
  export { MAX_CLIENT_FRAME_BYTES, MAX_PLAYERS_CAP } from "./multiplayerProtocol";
21
21
  export type { ClientByeFrame, ClientHelloFrame, ClientMessageFrame, ClientPingFrame, ClientPongFrame, ClientReplacedFrame, ClientRosterFrame, ClientToPlatformFrame, ClientToPlatformMessageFrame, MultiplayerPlayer, PlayerIdentity, PlatformToClientFrame, SessionPlayer, SessionType, } from "./multiplayerProtocol";
22
22
  export { registerParams } from "./paramsRegistry";
@@ -103,14 +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
+ }
106
119
  export type VoiceState = "unavailable" | "off" | "joining" | "live";
107
120
  export type VoiceJoinStatus = "joined" | "declined" | "unavailable";
108
121
  export interface VoiceMixEntry {
109
122
  /** 0 (silent) to 1 (full). */
110
123
  gain?: number;
111
- /** -1 (left) to 1 (right). */
124
+ /** -1 (left) to 1 (right). Ignored for a player with a 3D position. */
112
125
  pan?: number;
113
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
+ }
114
151
  export interface CharacterSummary {
115
152
  id: string;
116
153
  version: string;
@@ -303,6 +340,7 @@ export interface CommandParams {
303
340
  style: HapticStyle;
304
341
  };
305
342
  "multiplayer.getSession": MultiplayerGetSessionParams;
343
+ "multiplayer.joinDestination": MultiplayerJoinDestinationParams;
306
344
  "voice.getState": {
307
345
  sessionId: string;
308
346
  };
@@ -316,6 +354,9 @@ export interface CommandParams {
316
354
  sessionId: string;
317
355
  mix: Record<string, VoiceMixEntry>;
318
356
  };
357
+ "voice.setSpatial": {
358
+ sessionId: string;
359
+ } & VoiceSpatialUpdate;
319
360
  "characters.open": {
320
361
  characterId: string;
321
362
  };
@@ -438,6 +479,7 @@ export interface CommandResult {
438
479
  "portal.info": PortalInfoResult;
439
480
  "haptics.play": HapticsResult;
440
481
  "multiplayer.getSession": MultiplayerGetSessionResult;
482
+ "multiplayer.joinDestination": MultiplayerJoinDestinationResult;
441
483
  "voice.getState": {
442
484
  state: VoiceState;
443
485
  };
@@ -450,6 +492,9 @@ export interface CommandResult {
450
492
  "voice.setMix": {
451
493
  ok: true;
452
494
  };
495
+ "voice.setSpatial": {
496
+ ok: true;
497
+ };
453
498
  "characters.open": CharacterAdmission;
454
499
  "characters.send": CharacterOperation;
455
500
  "characters.status": CharacterOperation;
@@ -1,4 +1,4 @@
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
4
  import { type MultiplayerVoice } from "./multiplayerVoice";
@@ -10,6 +10,17 @@ export type MultiplayerJoinOptions = {
10
10
  } | {
11
11
  mode: "public";
12
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
+ };
13
24
  export type MultiplayerConnectionState = "acquiring" | "connecting" | "connected" | "reconnecting" | "closed";
14
25
  export type MultiplayerStateMetadata = {
15
26
  type: "connectionLost";
@@ -50,5 +61,6 @@ export interface MultiplayerConnection {
50
61
  }
51
62
  export interface CastleMultiplayerApi {
52
63
  join(options: MultiplayerJoinOptions): Promise<MultiplayerConnection>;
64
+ joinDestination(options: MultiplayerDestinationOptions): Promise<MultiplayerDestinationJoinResult>;
53
65
  }
54
66
  export declare const Multiplayer: CastleMultiplayerApi;
@@ -8,11 +8,15 @@ const RECONNECT_BUDGET_MS = 30_000;
8
8
  const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];
9
9
  const INVALID_DEV_RECONNECT_CLOSE_CODE = 4003;
10
10
  let activeConnection = null;
11
- export const Multiplayer = { join };
11
+ let latestJoinAttempt = 0;
12
+ let pendingDestinationJoin = null;
13
+ export const Multiplayer = { join, joinDestination };
12
14
  async function join(options) {
13
15
  const params = joinParams(options);
16
+ latestJoinAttempt += 1;
17
+ cancelPendingDestinationJoin();
14
18
  activeConnection?.leave();
15
- const connection = new MultiplayerConnectionImpl(params, () => {
19
+ const connection = new MultiplayerConnectionImpl({ command: "multiplayer.getSession", params }, () => {
16
20
  if (activeConnection === connection)
17
21
  activeConnection = null;
18
22
  });
@@ -20,9 +24,80 @@ async function join(options) {
20
24
  await connection.start();
21
25
  return connection;
22
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
+ }
23
97
  class MultiplayerConnectionImpl {
24
- params;
98
+ request;
25
99
  onClosed;
100
+ destinationAudience;
26
101
  currentState = "acquiring";
27
102
  currentPlayerId = "";
28
103
  currentSessionId = "";
@@ -45,9 +120,10 @@ class MultiplayerConnectionImpl {
45
120
  players: () => this.roster?.players ?? [],
46
121
  isSolo: () => this.solo,
47
122
  });
48
- constructor(params, onClosed) {
49
- this.params = params;
123
+ constructor(request, onClosed, destinationAudience = null) {
124
+ this.request = request;
50
125
  this.onClosed = onClosed;
126
+ this.destinationAudience = destinationAudience;
51
127
  }
52
128
  get state() {
53
129
  return this.currentState;
@@ -67,9 +143,9 @@ class MultiplayerConnectionImpl {
67
143
  get voice() {
68
144
  return this.voiceImpl;
69
145
  }
70
- async start() {
146
+ async start(initial) {
71
147
  try {
72
- await this.acquire();
148
+ await this.acquire(initial);
73
149
  }
74
150
  catch (error) {
75
151
  const castleError = asCastleError(error);
@@ -117,9 +193,16 @@ class MultiplayerConnectionImpl {
117
193
  return;
118
194
  this.finish({ type: "left" });
119
195
  }
120
- async acquire() {
196
+ matchesDestination(params) {
197
+ return (this.currentState !== "closed" &&
198
+ this.request.command === "multiplayer.joinDestination" &&
199
+ this.request.params.routeId === params.routeId &&
200
+ this.request.params.slot === params.slot &&
201
+ this.destinationAudience !== null);
202
+ }
203
+ async acquire(initial) {
121
204
  this.transition("acquiring");
122
- const result = await hostRequest("multiplayer.getSession", this.params);
205
+ const result = initial ?? await requestAcquisition(this.request);
123
206
  this.assertOpen();
124
207
  if (result.status === "solo") {
125
208
  this.connectSolo(result);
@@ -131,8 +214,11 @@ class MultiplayerConnectionImpl {
131
214
  this.solo = false;
132
215
  this.currentSoloReason = null;
133
216
  this.baseUrl = result.url;
134
- this.devReconnectStorageKey = result.devReconnectScope
135
- ? `castle-dev-mp-reconnect:${result.url}:${result.sessionId}:${result.devReconnectScope}`
217
+ const devReconnectScope = "devReconnectScope" in result
218
+ ? result.devReconnectScope
219
+ : undefined;
220
+ this.devReconnectStorageKey = devReconnectScope
221
+ ? `castle-dev-mp-reconnect:${result.url}:${result.sessionId}:${devReconnectScope}`
136
222
  : null;
137
223
  this.transition("connecting");
138
224
  const savedToken = this.devReconnectStorageKey
@@ -436,6 +522,62 @@ function joinParams(options) {
436
522
  }
437
523
  throw invalidJoinOptions();
438
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
+ }
439
581
  function invalidJoinOptions() {
440
582
  return multiplayerError("INVALID_ARGUMENT", "Multiplayer.join requires named, party, or public options.");
441
583
  }
@@ -1,7 +1,7 @@
1
- import type { VoiceEvent, VoiceJoinStatus, VoiceMixEntry, VoiceState } from "./commands";
1
+ import type { VoiceEvent, VoiceJoinStatus, VoiceListener, VoiceMixEntry, VoiceSpatialOptions, VoiceState, VoiceVector } from "./commands";
2
2
  import type { MultiplayerPlayer } from "./multiplayerProtocol";
3
3
  export declare const VOICE_MIX_INTERVAL_MS = 100;
4
- export type { VoiceJoinStatus, VoiceMixEntry, VoiceState };
4
+ export type { VoiceJoinStatus, VoiceListener, VoiceMixEntry, VoiceSpatialOptions, VoiceState, VoiceVector, };
5
5
  export interface MultiplayerVoice {
6
6
  /** "unavailable" when this host, deck, session or player can't have voice. */
7
7
  readonly state: VoiceState;
@@ -18,6 +18,19 @@ export interface MultiplayerVoice {
18
18
  * full volume, centered). Sent at most ten times a second; the latest values win.
19
19
  */
20
20
  setMix(mix: Record<string, VoiceMixEntry>): void;
21
+ /**
22
+ * 3D voice: where the listener (usually the local player's camera) is and faces, in
23
+ * the deck's world units, y up. With positions set, voices come from their place in
24
+ * the world (distance, left/right, front/behind, above/below; best on headphones).
25
+ */
26
+ setListener(listener: VoiceListener): void;
27
+ /**
28
+ * Where each player's voice comes from, keyed by playerId. `null` returns a player
29
+ * to plain gain and pan. Their `setMix` gain still applies; their pan does not.
30
+ */
31
+ setPositions(positions: Record<string, VoiceVector | null>): void;
32
+ /** How voices fade with distance. Defaults: inverse, refDistance 1, maxDistance 100, rolloff 1. */
33
+ setSpatialOptions(options: VoiceSpatialOptions): void;
21
34
  onState(callback: (state: VoiceState) => void): () => void;
22
35
  /** The playerIds speaking right now; called whenever that set changes. */
23
36
  onSpeaking(callback: (playerIds: string[]) => void): () => void;
@@ -39,6 +52,12 @@ export declare class MultiplayerVoiceImpl implements MultiplayerVoice {
39
52
  private speaking;
40
53
  private pendingMix;
41
54
  private sentMix;
55
+ private pendingListener;
56
+ private pendingPositions;
57
+ private pendingOptions;
58
+ private sentListener;
59
+ private sentPositions;
60
+ private sentOptions;
42
61
  private mixTimer;
43
62
  private lastMixSentAt;
44
63
  private disposed;
@@ -51,6 +70,9 @@ export declare class MultiplayerVoiceImpl implements MultiplayerVoice {
51
70
  private askToJoin;
52
71
  leave(): void;
53
72
  setMix(mix: Record<string, VoiceMixEntry>): void;
73
+ setListener(listener: VoiceListener): void;
74
+ setPositions(positions: Record<string, VoiceVector | null>): void;
75
+ setSpatialOptions(options: VoiceSpatialOptions): void;
54
76
  onState(callback: (state: VoiceState) => void): () => void;
55
77
  onSpeaking(callback: (playerIds: string[]) => void): () => void;
56
78
  receive(event: VoiceEvent): void;
@@ -62,6 +84,8 @@ export declare class MultiplayerVoiceImpl implements MultiplayerVoice {
62
84
  private sendLeave;
63
85
  private setState;
64
86
  private emitSpeaking;
87
+ private hasPending;
65
88
  private scheduleMix;
66
89
  private flushMix;
90
+ private takeSpatial;
67
91
  }
@@ -31,6 +31,13 @@ export class MultiplayerVoiceImpl {
31
31
  speaking = [];
32
32
  pendingMix = {};
33
33
  sentMix = {};
34
+ // Spatial state: pending (not yet sent) and sent (re-sent when a call restarts).
35
+ pendingListener = null;
36
+ pendingPositions = {};
37
+ pendingOptions = null;
38
+ sentListener = null;
39
+ sentPositions = {};
40
+ sentOptions = null;
34
41
  mixTimer = null;
35
42
  lastMixSentAt = 0;
36
43
  disposed = false;
@@ -163,6 +170,45 @@ export class MultiplayerVoiceImpl {
163
170
  }
164
171
  this.scheduleMix();
165
172
  }
173
+ setListener(listener) {
174
+ if (this.disposed || !listener || typeof listener !== "object")
175
+ return;
176
+ const position = vector(listener.position);
177
+ const forward = vector(listener.forward);
178
+ const up = listener.up === undefined ? undefined : vector(listener.up);
179
+ // A zero direction faces nowhere (a player standing still, say): keep the last
180
+ // listener rather than send one the host would drop.
181
+ if (!position || !forward || up === null || isZero(forward) || (up && isZero(up)))
182
+ return;
183
+ this.pendingListener = up ? { position, forward, up } : { position, forward };
184
+ this.scheduleMix();
185
+ }
186
+ setPositions(positions) {
187
+ if (this.disposed || !positions || typeof positions !== "object")
188
+ return;
189
+ const players = this.access.players();
190
+ for (const [playerId, value] of Object.entries(positions)) {
191
+ const player = players.find((candidate) => candidate.playerId === playerId);
192
+ if (!player)
193
+ continue;
194
+ if (value === null) {
195
+ this.pendingPositions[player.userId] = null;
196
+ continue;
197
+ }
198
+ const position = vector(value);
199
+ if (position)
200
+ this.pendingPositions[player.userId] = position;
201
+ }
202
+ this.scheduleMix();
203
+ }
204
+ setSpatialOptions(options) {
205
+ if (this.disposed || !options || typeof options !== "object")
206
+ return;
207
+ // Cumulative: the host fills every missing field with its default, so each send
208
+ // carries everything the deck has set so far.
209
+ this.pendingOptions = { ...this.sentOptions, ...this.pendingOptions, ...options };
210
+ this.scheduleMix();
211
+ }
166
212
  onState(callback) {
167
213
  this.states.add(callback);
168
214
  callListener(callback, this.currentState);
@@ -198,6 +244,14 @@ export class MultiplayerVoiceImpl {
198
244
  if (!userIds.has(userId))
199
245
  delete this.sentMix[userId];
200
246
  }
247
+ for (const userId of Object.keys(this.pendingPositions)) {
248
+ if (!userIds.has(userId))
249
+ delete this.pendingPositions[userId];
250
+ }
251
+ for (const userId of Object.keys(this.sentPositions)) {
252
+ if (!userIds.has(userId))
253
+ delete this.sentPositions[userId];
254
+ }
201
255
  this.emitSpeaking();
202
256
  }
203
257
  // The game connection ended: voice for its session goes with it.
@@ -230,6 +284,12 @@ export class MultiplayerVoiceImpl {
230
284
  this.speakingUserIds = [];
231
285
  this.pendingMix = {};
232
286
  this.sentMix = {};
287
+ this.pendingListener = null;
288
+ this.pendingPositions = {};
289
+ this.pendingOptions = null;
290
+ this.sentListener = null;
291
+ this.sentPositions = {};
292
+ this.sentOptions = null;
233
293
  this.emitSpeaking();
234
294
  }
235
295
  sendLeave(sessionId) {
@@ -249,6 +309,12 @@ export class MultiplayerVoiceImpl {
249
309
  for (const userId of Object.keys(this.sentMix)) {
250
310
  this.pendingMix[userId] ??= {};
251
311
  }
312
+ this.pendingListener ??= this.sentListener;
313
+ this.pendingOptions ??= this.sentOptions;
314
+ for (const [userId, position] of Object.entries(this.sentPositions)) {
315
+ if (!(userId in this.pendingPositions))
316
+ this.pendingPositions[userId] = position;
317
+ }
252
318
  this.scheduleMix();
253
319
  }
254
320
  }
@@ -265,8 +331,14 @@ export class MultiplayerVoiceImpl {
265
331
  for (const callback of this.speakers)
266
332
  callListener(callback, playerIds.slice());
267
333
  }
334
+ hasPending() {
335
+ return (Object.keys(this.pendingMix).length > 0 ||
336
+ Object.keys(this.pendingPositions).length > 0 ||
337
+ this.pendingListener !== null ||
338
+ this.pendingOptions !== null);
339
+ }
268
340
  scheduleMix() {
269
- if (this.mixTimer !== null || Object.keys(this.pendingMix).length === 0)
341
+ if (this.mixTimer !== null || !this.hasPending())
270
342
  return;
271
343
  const wait = Math.max(0, this.lastMixSentAt + VOICE_MIX_INTERVAL_MS - Date.now());
272
344
  this.mixTimer = setTimeout(() => {
@@ -290,11 +362,54 @@ export class MultiplayerVoiceImpl {
290
362
  mix[userId] = full;
291
363
  }
292
364
  this.pendingMix = {};
293
- if (Object.keys(mix).length === 0)
365
+ const spatial = this.takeSpatial(userIds);
366
+ if (Object.keys(mix).length === 0 && !spatial)
294
367
  return;
295
368
  this.lastMixSentAt = Date.now();
296
- void hostRequest("voice.setMix", { sessionId: this.voiceSessionId, mix }).catch(() => { });
369
+ if (Object.keys(mix).length > 0) {
370
+ void hostRequest("voice.setMix", { sessionId: this.voiceSessionId, mix }).catch(() => { });
371
+ }
372
+ if (spatial) {
373
+ void hostRequest("voice.setSpatial", { sessionId: this.voiceSessionId, ...spatial }).catch(() => { });
374
+ }
297
375
  }
376
+ takeSpatial(userIds) {
377
+ const update = {};
378
+ if (this.pendingListener) {
379
+ update.listener = this.pendingListener;
380
+ this.sentListener = this.pendingListener;
381
+ }
382
+ if (this.pendingOptions) {
383
+ update.options = this.pendingOptions;
384
+ this.sentOptions = this.pendingOptions;
385
+ }
386
+ const positions = {};
387
+ for (const [userId, position] of Object.entries(this.pendingPositions)) {
388
+ if (!userIds.has(userId))
389
+ continue;
390
+ positions[userId] = position;
391
+ if (position)
392
+ this.sentPositions[userId] = position;
393
+ else
394
+ delete this.sentPositions[userId];
395
+ }
396
+ if (Object.keys(positions).length > 0)
397
+ update.positions = positions;
398
+ this.pendingListener = null;
399
+ this.pendingOptions = null;
400
+ this.pendingPositions = {};
401
+ return update.listener || update.options || update.positions ? update : null;
402
+ }
403
+ }
404
+ function vector(value) {
405
+ return Array.isArray(value) &&
406
+ value.length === 3 &&
407
+ value.every((component) => typeof component === "number" && Number.isFinite(component))
408
+ ? [value[0], value[1], value[2]]
409
+ : null;
410
+ }
411
+ function isZero(value) {
412
+ return value[0] === 0 && value[1] === 0 && value[2] === 0;
298
413
  }
299
414
  function clamp(value, min, max) {
300
415
  return Math.min(max, Math.max(min, value));
@@ -1,3 +1,4 @@
1
+ import type { Json } from "../types";
1
2
  export type MultiplayerSessionMode = "named" | "party" | "public";
2
3
  export interface PlayerIdentity {
3
4
  userId: string;
@@ -11,6 +12,15 @@ export interface PlatformSessionConfig {
11
12
  readonly mode: MultiplayerSessionMode;
12
13
  readonly tickRate: number;
13
14
  readonly maxPlayers: number;
15
+ readonly world?: PlatformWorldSnapshot;
16
+ }
17
+ export interface PlatformWorldCheckpoint {
18
+ version: number;
19
+ value: Json;
20
+ }
21
+ export interface PlatformWorldSnapshot {
22
+ revision: string;
23
+ checkpoint: PlatformWorldCheckpoint | null;
14
24
  }
15
25
  export interface PlatformHandle {
16
26
  send(connId: string, text: string): void;
@@ -34,5 +44,8 @@ export interface PlatformHandle {
34
44
  storage?(op: string, argsJson: string): Promise<string>;
35
45
  /** False when the host cannot broker storage, so a call can fail at once. */
36
46
  readonly storageEnabled?: boolean;
47
+ /** Persistent-world checkpoint broker. The host stamps the hidden world binding. */
48
+ worldCheckpointRead?(): Promise<string>;
49
+ worldCheckpointCommit?(argsJson: string): Promise<string>;
37
50
  readonly config: PlatformSessionConfig;
38
51
  }
@@ -1,6 +1,7 @@
1
1
  import type { MultiplayerSessionMode, PlatformHandle, PlayerIdentity } from "./platformHandle";
2
+ import type { Json } from "../types";
2
3
  import { type ServerStorage } from "./storage";
3
- export type { MultiplayerSessionMode, PlatformHandle, PlatformSessionConfig, PlayerIdentity, } from "./platformHandle";
4
+ export type { MultiplayerSessionMode, PlatformHandle, PlatformSessionConfig, PlatformWorldCheckpoint, PlatformWorldSnapshot, PlayerIdentity, } from "./platformHandle";
4
5
  export type { CharacterKnowledgeSource, CompleteCharacterBeatInput, DeliverCharacterKnowledgeInput, RecordCharacterEventInput, SetCharacterGameStateInput, ShareCharacterClaimInput, ServerStorage, ServerStorageBoard, ServerStorageBoardScore, ServerStorageListEntry, ServerStorageListOptions, ServerStorageListPage, ServerPlaythrough, ServerPlaythroughCharacters, ServerCharacterHistoryOptions, ServerStorageScope, } from "./storage";
5
6
  export interface MultiplayerServerPlayer extends PlayerIdentity {
6
7
  playerId: string;
@@ -18,12 +19,40 @@ export interface MultiplayerServerSession {
18
19
  * when its last player leaves and its memory goes with it.
19
20
  */
20
21
  readonly storage: ServerStorage;
22
+ /** A durable checkpoint for an opted-in persistent destination. */
23
+ readonly world: MultiplayerServerWorld | null;
21
24
  /** Sends bytes as a binary message and all other payloads as JSON. */
22
25
  send(playerId: string, data: unknown): void;
23
26
  /** Broadcasts bytes as a binary message and all other payloads as JSON. */
24
27
  broadcast(data: unknown, options?: MultiplayerBroadcastOptions): void;
25
28
  disconnect(playerId: string, reason?: string): void;
26
29
  }
30
+ export interface MultiplayerWorldCheckpoint {
31
+ version: number;
32
+ value: Json;
33
+ }
34
+ export interface MultiplayerWorldSnapshot {
35
+ revision: string;
36
+ checkpoint: MultiplayerWorldCheckpoint | null;
37
+ }
38
+ export interface MultiplayerWorldCommitInput {
39
+ expectedRevision: string;
40
+ operationId: string;
41
+ checkpoint: MultiplayerWorldCheckpoint;
42
+ }
43
+ export interface MultiplayerWorldCommitReceipt {
44
+ revision: string;
45
+ replayed: boolean;
46
+ operationId: string;
47
+ }
48
+ export interface MultiplayerServerWorld {
49
+ /** The checkpoint acquired before this runtime started. */
50
+ readonly snapshot: MultiplayerWorldSnapshot;
51
+ /** Reads the latest durable checkpoint, for example after a revision conflict. */
52
+ read(): Promise<MultiplayerWorldSnapshot>;
53
+ /** Commits exactly against expectedRevision; conflicts never partially write. */
54
+ commit(input: MultiplayerWorldCommitInput): Promise<MultiplayerWorldCommitReceipt>;
55
+ }
27
56
  type CallbackResult = unknown;
28
57
  export interface MultiplayerServerCallbacks {
29
58
  onStart?: (session: MultiplayerServerSession) => CallbackResult;
@@ -1,4 +1,5 @@
1
1
  import { isBinaryPayload, toUint8Array, } from "../multiplayerProtocol";
2
+ import { encodeStorageValue } from "../storageJson";
2
3
  import { createServerStorage } from "./storage";
3
4
  // The publish bundler binds the author's default callbacks export once and
4
5
  // exports the returned one-argument function as `__castleBootSession`.
@@ -68,6 +69,7 @@ function createAuthorSession(platformHandle, players) {
68
69
  deckId: platformHandle.config.deckId,
69
70
  mode: platformHandle.config.mode,
70
71
  storage: createServerStorage(platformHandle),
72
+ world: createServerWorld(platformHandle),
71
73
  send(playerId, data) {
72
74
  if (isBinaryPayload(data)) {
73
75
  requireSendBinary(platformHandle);
@@ -106,6 +108,96 @@ function createAuthorSession(platformHandle, players) {
106
108
  },
107
109
  };
108
110
  }
111
+ const WORLD_OPERATION_ID_MAX_BYTES = 128;
112
+ const WORLD_OPERATION_ID_MAX_CHARACTERS = 120;
113
+ const WORLD_CHECKPOINT_VERSION_MAX = 2_147_483_647;
114
+ function createServerWorld(platformHandle) {
115
+ if (!platformHandle.config.world)
116
+ return null;
117
+ if (typeof platformHandle.worldCheckpointRead !== "function" ||
118
+ typeof platformHandle.worldCheckpointCommit !== "function") {
119
+ throw worldError("The persistent-world checkpoint broker is unavailable.", "read");
120
+ }
121
+ const initial = parseWorldSnapshot(platformHandle.config.world, "read");
122
+ return Object.freeze({
123
+ snapshot: initial,
124
+ async read() {
125
+ const result = await platformHandle.worldCheckpointRead();
126
+ return parseWorldSnapshot(parsePlatformJson(result, "read"), "read");
127
+ },
128
+ async commit(input) {
129
+ const expectedRevision = assertWorldRevision(input?.expectedRevision);
130
+ const operationId = assertWorldOperationId(input?.operationId);
131
+ const checkpoint = assertWorldCheckpoint(input?.checkpoint);
132
+ const raw = await platformHandle.worldCheckpointCommit(JSON.stringify({
133
+ expectedRevision,
134
+ operationId,
135
+ checkpoint,
136
+ }));
137
+ const result = parsePlatformJson(raw, "commit");
138
+ if (typeof result.replayed !== "boolean") {
139
+ throw worldError("The platform returned an invalid commit receipt.", "commit");
140
+ }
141
+ return Object.freeze({
142
+ revision: assertWorldRevision(result.revision),
143
+ replayed: result.replayed,
144
+ operationId,
145
+ });
146
+ },
147
+ });
148
+ }
149
+ function parseWorldSnapshot(value, operation) {
150
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
151
+ throw worldError("The platform returned an invalid world checkpoint.", "read");
152
+ }
153
+ const raw = value;
154
+ return Object.freeze({
155
+ revision: assertWorldRevision(raw.revision),
156
+ checkpoint: raw.checkpoint === null
157
+ ? null
158
+ : assertWorldCheckpoint(raw.checkpoint, operation),
159
+ });
160
+ }
161
+ function assertWorldCheckpoint(value, operation = "commit") {
162
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
163
+ throw worldError("A world checkpoint requires version and value.", operation);
164
+ }
165
+ const raw = value;
166
+ if (!Number.isSafeInteger(raw.version) ||
167
+ raw.version < 0 ||
168
+ raw.version > WORLD_CHECKPOINT_VERSION_MAX) {
169
+ throw worldError(`A world checkpoint version must be an integer from 0 through ${WORLD_CHECKPOINT_VERSION_MAX}.`, operation);
170
+ }
171
+ const text = encodeStorageValue(raw.value, `session.world.${operation}`);
172
+ const normalized = JSON.parse(text);
173
+ return Object.freeze({ version: raw.version, value: normalized });
174
+ }
175
+ function assertWorldRevision(value) {
176
+ if (typeof value === "string" && /^(?:0|[1-9][0-9]*)$/.test(value))
177
+ return value;
178
+ throw worldError("A world checkpoint revision must be a canonical decimal string.", "commit");
179
+ }
180
+ function assertWorldOperationId(value) {
181
+ if (typeof value !== "string" ||
182
+ value.length === 0 ||
183
+ value.length > WORLD_OPERATION_ID_MAX_CHARACTERS ||
184
+ value.includes("\0") ||
185
+ new TextEncoder().encode(value).byteLength > WORLD_OPERATION_ID_MAX_BYTES) {
186
+ throw worldError(`A world checkpoint operation id must be at most ${WORLD_OPERATION_ID_MAX_CHARACTERS} characters and ${WORLD_OPERATION_ID_MAX_BYTES} bytes.`, "commit");
187
+ }
188
+ return value;
189
+ }
190
+ function parsePlatformJson(value, operation) {
191
+ try {
192
+ return JSON.parse(value);
193
+ }
194
+ catch {
195
+ throw worldError("The platform returned an invalid world checkpoint response.", operation);
196
+ }
197
+ }
198
+ function worldError(message, operation) {
199
+ return new TypeError(`session.world.${operation}: ${message}`);
200
+ }
109
201
  function requireSendBinary(platformHandle) {
110
202
  if (typeof platformHandle.sendBinary !== "function")
111
203
  unsupportedBinaryRuntime();
package/dist/transport.js CHANGED
@@ -23,6 +23,7 @@ const INTERACTIVE_COMMANDS = new Set([
23
23
  "pass.offer",
24
24
  "snapshot.offer",
25
25
  "portal.open",
26
+ "multiplayer.joinDestination",
26
27
  // The host's voice prompt and the browser's microphone permission.
27
28
  "voice.requestJoin",
28
29
  ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-sdk",
3
- "version": "0.4.36",
3
+ "version": "0.4.37",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/castle-xyz/castle-experimental-web.git"