castle-web-sdk 0.4.36 → 0.4.38

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.
@@ -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;
@@ -24,6 +34,9 @@ export interface PlatformHandle {
24
34
  on(event: "binaryMessage", callback: (connId: string, bytes: Uint8Array) => unknown): void;
25
35
  on(event: "replaced", callback: () => unknown): void;
26
36
  on(event: "shutdown", callback: (reason?: string) => unknown): void;
37
+ on(event: "worldCharacterTurn", callback: (request: PlatformWorldCharacterTurn) => unknown): void;
38
+ on(event: "worldCharacterCommand", callback: (request: PlatformWorldCharacterCommand) => unknown): void;
39
+ on(event: "worldCharacterRead", callback: (request: PlatformWorldCharacterRead) => unknown): void;
27
40
  /**
28
41
  * Deck storage. Strings in and strings out, like the rest of this handle, so a
29
42
  * runtime other than the Node shim can implement it without knowing what storage is.
@@ -34,5 +47,46 @@ export interface PlatformHandle {
34
47
  storage?(op: string, argsJson: string): Promise<string>;
35
48
  /** False when the host cannot broker storage, so a call can fail at once. */
36
49
  readonly storageEnabled?: boolean;
50
+ /** Persistent-world checkpoint broker. The host stamps the hidden world binding. */
51
+ worldCheckpointRead?(): Promise<string>;
52
+ worldCheckpointCommit?(argsJson: string): Promise<string>;
53
+ worldCharacterSend?(grantId: string): Promise<string>;
54
+ worldCharacterCommand?(grantId: string): Promise<string>;
55
+ worldCharacterRead?(grantId: string): Promise<string>;
56
+ worldCharacterReject?(grantId: string, code?: string): void;
37
57
  readonly config: PlatformSessionConfig;
38
58
  }
59
+ export interface PlatformWorldCharacterCommand {
60
+ grantId: string;
61
+ requestId: string;
62
+ operationId: string;
63
+ speaker: PlayerIdentity & {
64
+ playerId: string;
65
+ };
66
+ command: unknown;
67
+ }
68
+ export interface PlatformWorldCharacterRead {
69
+ grantId: string;
70
+ requestId: string;
71
+ speaker: PlayerIdentity & {
72
+ playerId: string;
73
+ };
74
+ query: unknown;
75
+ }
76
+ export interface PlatformWorldCharacterTurn {
77
+ grantId: string;
78
+ requestId: string;
79
+ operationId: string;
80
+ speaker: PlayerIdentity & {
81
+ playerId: string;
82
+ };
83
+ characterId: string;
84
+ input: string;
85
+ audience: {
86
+ disclosure: "participants" | "world";
87
+ players: Array<PlayerIdentity & {
88
+ playerId: string;
89
+ }>;
90
+ characterIds: string[];
91
+ };
92
+ }
@@ -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,86 @@ 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
+ readonly characters: MultiplayerServerWorldCharacters;
56
+ }
57
+ export interface MultiplayerServerWorldCharacters {
58
+ publicInspect<T = unknown>(): Promise<T>;
59
+ publicRelay(input: {
60
+ operationId: string;
61
+ sourceId: string;
62
+ transmittingCharacterId: string;
63
+ recipientCharacterIds: string[];
64
+ }): Promise<unknown>;
65
+ }
66
+ export interface MultiplayerWorldCharacterCommandRequest {
67
+ readonly requestId: string;
68
+ readonly operationId: string;
69
+ readonly speaker: MultiplayerServerPlayer;
70
+ readonly command: Readonly<Record<string, unknown>>;
71
+ apply(): Promise<unknown>;
72
+ reject(code?: string): void;
73
+ }
74
+ export interface MultiplayerWorldCharacterReadRequest {
75
+ readonly requestId: string;
76
+ readonly speaker: MultiplayerServerPlayer;
77
+ readonly query: Readonly<Record<string, unknown>>;
78
+ read<T = unknown>(): Promise<T>;
79
+ reject(code?: string): void;
80
+ }
81
+ export interface MultiplayerWorldCharacterAdmission {
82
+ operationId: string;
83
+ status: "queued" | "completed" | "interrupted";
84
+ replayed: boolean;
85
+ }
86
+ export interface MultiplayerWorldCharacterTurn {
87
+ readonly requestId: string;
88
+ readonly operationId: string;
89
+ readonly speaker: MultiplayerServerPlayer;
90
+ readonly characterId: string;
91
+ readonly input: string;
92
+ readonly audience: {
93
+ readonly disclosure: "participants" | "world";
94
+ readonly players: readonly MultiplayerServerPlayer[];
95
+ readonly characterIds: readonly string[];
96
+ };
97
+ /** Durably admits the exact host-authorized request. No arguments can broaden it. */
98
+ send(): Promise<MultiplayerWorldCharacterAdmission>;
99
+ /** Explicitly rejects the request before admission. */
100
+ reject(code?: string): void;
101
+ }
27
102
  type CallbackResult = unknown;
28
103
  export interface MultiplayerServerCallbacks {
29
104
  onStart?: (session: MultiplayerServerSession) => CallbackResult;
@@ -31,6 +106,9 @@ export interface MultiplayerServerCallbacks {
31
106
  onPlayerLeave?: (session: MultiplayerServerSession, player: MultiplayerServerPlayer) => CallbackResult;
32
107
  /** Receives binary messages as Uint8Array values and JSON messages otherwise. */
33
108
  onMessage?: (session: MultiplayerServerSession, player: MultiplayerServerPlayer, data: unknown) => CallbackResult;
109
+ onWorldCharacterTurn?: (session: MultiplayerServerSession, turn: MultiplayerWorldCharacterTurn) => CallbackResult;
110
+ onWorldCharacterCommand?: (session: MultiplayerServerSession, request: MultiplayerWorldCharacterCommandRequest) => CallbackResult;
111
+ onWorldCharacterRead?: (session: MultiplayerServerSession, request: MultiplayerWorldCharacterReadRequest) => CallbackResult;
34
112
  onTick?: (session: MultiplayerServerSession) => CallbackResult;
35
113
  onReplaced?: (session: MultiplayerServerSession) => CallbackResult;
36
114
  /**