castle-web-sdk 0.4.34 → 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/dist/decks.js ADDED
@@ -0,0 +1,31 @@
1
+ // Decks — the player's own published decks and the decks they saved, for
2
+ // picking where a portal leads. Each entry has the same shape as Portal.info:
3
+ // deckId, title, creator and a cover as a data: url.
4
+ //
5
+ // Both lists leave out decks a portal cannot reach: a private deck, or one
6
+ // whose creator turned portals off.
7
+ //
8
+ // Decks is limited to Castle admins for now; other players get a
9
+ // PERMISSION_DENIED error.
10
+ import { CastleError } from "./errors";
11
+ import { hostRequest } from "./transport";
12
+ function mine(options = {}) {
13
+ return hostRequest("decks.mine", { limit: listLimit(options.limit, "Decks.mine") });
14
+ }
15
+ function saved(options = {}) {
16
+ return hostRequest("decks.saved", { limit: listLimit(options.limit, "Decks.saved") });
17
+ }
18
+ function listLimit(value, operation) {
19
+ if (value === undefined || value === null) {
20
+ return null;
21
+ }
22
+ if (Number.isSafeInteger(value) && value >= 1 && value <= 50) {
23
+ return value;
24
+ }
25
+ throw new CastleError({
26
+ code: "INVALID_ARGUMENT",
27
+ message: `${operation} limit must be an integer from 1 through 50.`,
28
+ operation,
29
+ });
30
+ }
31
+ export const Decks = Object.freeze({ mine, saved });
@@ -1,6 +1,7 @@
1
1
  import type { MultiplayerSoloReason } from "./commands";
2
2
  import { CastleError } from "./errors";
3
3
  import { type MultiplayerPlayer } from "./multiplayerProtocol";
4
+ import { type MultiplayerVoice } from "./multiplayerVoice";
4
5
  export type MultiplayerJoinOptions = {
5
6
  mode: "named";
6
7
  key: string;
@@ -43,6 +44,8 @@ export interface MultiplayerConnection {
43
44
  onMessage(callback: (data: unknown) => void): () => void;
44
45
  onRoster(callback: (roster: MultiplayerRoster) => void): () => void;
45
46
  onStateChange(callback: (state: MultiplayerConnectionState, metadata?: MultiplayerStateMetadata) => void): () => void;
47
+ /** Voice chat for this session, run by the host. See MultiplayerVoice. */
48
+ readonly voice: MultiplayerVoice;
46
49
  leave(): void;
47
50
  }
48
51
  export interface CastleMultiplayerApi {
@@ -1,6 +1,7 @@
1
1
  import { CastleError } from "./errors";
2
2
  import { BINARY_MESSAGE_TAG, isBinaryPayload, MAX_CLIENT_FRAME_BYTES, toUint8Array, } from "./multiplayerProtocol";
3
3
  import { hostRequest } from "./transport";
4
+ import { MultiplayerVoiceImpl } from "./multiplayerVoice";
4
5
  const KEEPALIVE_INTERVAL_MS = 25_000;
5
6
  const HELLO_TIMEOUT_MS = 10_000;
6
7
  const RECONNECT_BUDGET_MS = 30_000;
@@ -39,6 +40,11 @@ class MultiplayerConnectionImpl {
39
40
  messages = new Set();
40
41
  rosters = new Set();
41
42
  states = new Set();
43
+ voiceImpl = new MultiplayerVoiceImpl({
44
+ sessionId: () => this.currentSessionId,
45
+ players: () => this.roster?.players ?? [],
46
+ isSolo: () => this.solo,
47
+ });
42
48
  constructor(params, onClosed) {
43
49
  this.params = params;
44
50
  this.onClosed = onClosed;
@@ -58,6 +64,9 @@ class MultiplayerConnectionImpl {
58
64
  get soloReason() {
59
65
  return this.currentSoloReason;
60
66
  }
67
+ get voice() {
68
+ return this.voiceImpl;
69
+ }
61
70
  async start() {
62
71
  try {
63
72
  await this.acquire();
@@ -171,6 +180,7 @@ class MultiplayerConnectionImpl {
171
180
  };
172
181
  this.setRoster({ players: [you], you });
173
182
  this.transition("connected");
183
+ void this.voiceImpl.attach();
174
184
  }
175
185
  openSocket(credential, value) {
176
186
  const attempt = ++this.socketAttempt;
@@ -253,6 +263,7 @@ class MultiplayerConnectionImpl {
253
263
  this.reAcquisitionUsed = false;
254
264
  this.transition("connected");
255
265
  this.startKeepalive();
266
+ void this.voiceImpl.attach();
256
267
  }
257
268
  handleFrame(frame) {
258
269
  switch (frame.t) {
@@ -293,6 +304,7 @@ class MultiplayerConnectionImpl {
293
304
  }
294
305
  setRoster(roster) {
295
306
  this.roster = roster;
307
+ this.voiceImpl.rosterChanged();
296
308
  for (const callback of this.rosters)
297
309
  callListener(callback, roster);
298
310
  }
@@ -391,6 +403,7 @@ class MultiplayerConnectionImpl {
391
403
  this.socketAttempt += 1;
392
404
  closeSocket(socket, 1000, "session closed");
393
405
  this.currentState = "closed";
406
+ this.voiceImpl.dispose();
394
407
  for (const callback of this.states)
395
408
  callStateListener(callback, "closed", metadata);
396
409
  this.onClosed();
@@ -0,0 +1,67 @@
1
+ import type { VoiceEvent, VoiceJoinStatus, VoiceMixEntry, VoiceState } from "./commands";
2
+ import type { MultiplayerPlayer } from "./multiplayerProtocol";
3
+ export declare const VOICE_MIX_INTERVAL_MS = 100;
4
+ export type { VoiceJoinStatus, VoiceMixEntry, VoiceState };
5
+ export interface MultiplayerVoice {
6
+ /** "unavailable" when this host, deck, session or player can't have voice. */
7
+ readonly state: VoiceState;
8
+ /**
9
+ * Asks the player to join voice. The host shows its own prompt; nothing happens
10
+ * without the player's say-so. Resolves once they have chosen.
11
+ */
12
+ requestJoin(): Promise<VoiceJoinStatus>;
13
+ /** Leaves voice, or withdraws a pending request. The game connection is unaffected. */
14
+ leave(): void;
15
+ /**
16
+ * Suggested volume (0-1) and stereo position (-1 left to 1 right) per player,
17
+ * keyed by playerId. Players and fields not named keep their last value (initially
18
+ * full volume, centered). Sent at most ten times a second; the latest values win.
19
+ */
20
+ setMix(mix: Record<string, VoiceMixEntry>): void;
21
+ onState(callback: (state: VoiceState) => void): () => void;
22
+ /** The playerIds speaking right now; called whenever that set changes. */
23
+ onSpeaking(callback: (playerIds: string[]) => void): () => void;
24
+ }
25
+ export interface VoiceSessionAccess {
26
+ sessionId(): string;
27
+ players(): MultiplayerPlayer[];
28
+ isSolo(): boolean;
29
+ }
30
+ export declare class MultiplayerVoiceImpl implements MultiplayerVoice {
31
+ private readonly access;
32
+ private currentState;
33
+ private voiceSessionId;
34
+ private attaching;
35
+ private hostStateVersion;
36
+ private pendingJoin;
37
+ private joinRequest;
38
+ private speakingUserIds;
39
+ private speaking;
40
+ private pendingMix;
41
+ private sentMix;
42
+ private mixTimer;
43
+ private lastMixSentAt;
44
+ private disposed;
45
+ private readonly states;
46
+ private readonly speakers;
47
+ constructor(access: VoiceSessionAccess);
48
+ get state(): VoiceState;
49
+ attach(): Promise<void>;
50
+ requestJoin(): Promise<VoiceJoinStatus>;
51
+ private askToJoin;
52
+ leave(): void;
53
+ setMix(mix: Record<string, VoiceMixEntry>): void;
54
+ onState(callback: (state: VoiceState) => void): () => void;
55
+ onSpeaking(callback: (playerIds: string[]) => void): () => void;
56
+ receive(event: VoiceEvent): void;
57
+ rosterChanged(): void;
58
+ dispose(): void;
59
+ private isCurrent;
60
+ private withdraw;
61
+ private endSession;
62
+ private sendLeave;
63
+ private setState;
64
+ private emitSpeaking;
65
+ private scheduleMix;
66
+ private flushMix;
67
+ }
@@ -0,0 +1,309 @@
1
+ import { hostRequest, setVoiceEventHandler } from "./transport";
2
+ // Voice chat in a multiplayer session. The host (castle.xyz or the Castle app) owns
3
+ // the microphone, the call and playback, and shows its own prompt and live-mic badge; a
4
+ // deck never touches audio. What a deck gets: whether voice is on, a way to ask the
5
+ // player to join, who is speaking, and a suggested volume and stereo position per
6
+ // player (proximity chat). Push-to-talk and muting are the player's, in the host's UI.
7
+ // A deck driving proximity every frame sends far more than the host needs.
8
+ export const VOICE_MIX_INTERVAL_MS = 100;
9
+ const DEFAULT_MIX = { gain: 1, pan: 0 };
10
+ let activeVoice = null;
11
+ function dispatchVoiceEvent(event) {
12
+ activeVoice?.receive(event);
13
+ }
14
+ // One multiplayer connection is live at a time, so one voice receives host events.
15
+ function setActiveVoice(voice, current) {
16
+ if (current && activeVoice !== current)
17
+ return;
18
+ activeVoice = voice;
19
+ }
20
+ export class MultiplayerVoiceImpl {
21
+ access;
22
+ currentState = "unavailable";
23
+ // The session this voice state belongs to; "" before the first attach or in solo.
24
+ voiceSessionId = "";
25
+ attaching = null;
26
+ // Bumped on every host state event, so a join reply never overrides a newer one.
27
+ hostStateVersion = 0;
28
+ pendingJoin = null;
29
+ joinRequest = null;
30
+ speakingUserIds = [];
31
+ speaking = [];
32
+ pendingMix = {};
33
+ sentMix = {};
34
+ mixTimer = null;
35
+ lastMixSentAt = 0;
36
+ disposed = false;
37
+ states = new Set();
38
+ speakers = new Set();
39
+ constructor(access) {
40
+ this.access = access;
41
+ }
42
+ get state() {
43
+ return this.currentState;
44
+ }
45
+ // Called by the connection whenever it (re)gains a session. A reconnect keeps the
46
+ // session; a re-acquisition may land in a different one, or in solo, and voice for
47
+ // the old session ends with it. Asks the host where voice stands; a host without
48
+ // voice (an older app, local dev) answers with an error or "unavailable".
49
+ attach() {
50
+ if (this.disposed)
51
+ return Promise.resolve();
52
+ setActiveVoice(this);
53
+ setVoiceEventHandler(dispatchVoiceEvent);
54
+ const sessionId = this.access.isSolo() ? "" : this.access.sessionId();
55
+ if (this.voiceSessionId !== sessionId) {
56
+ this.endSession();
57
+ // The old session's state means nothing for the new one until the host answers.
58
+ this.setState("unavailable");
59
+ }
60
+ this.voiceSessionId = sessionId;
61
+ if (!sessionId) {
62
+ this.setState("unavailable");
63
+ this.attaching = null;
64
+ return Promise.resolve();
65
+ }
66
+ const attaching = hostRequest("voice.getState", { sessionId }).then((result) => {
67
+ if (this.isCurrent(sessionId))
68
+ this.setState(result.state);
69
+ }, () => {
70
+ if (this.isCurrent(sessionId))
71
+ this.setState("unavailable");
72
+ });
73
+ this.attaching = attaching;
74
+ return attaching;
75
+ }
76
+ // A second call while the player is still choosing shares the first one's answer.
77
+ requestJoin() {
78
+ if (!this.joinRequest) {
79
+ const request = this.askToJoin().finally(() => {
80
+ if (this.joinRequest === request)
81
+ this.joinRequest = null;
82
+ });
83
+ this.joinRequest = request;
84
+ }
85
+ return this.joinRequest;
86
+ }
87
+ async askToJoin() {
88
+ // Registered before any waiting, so leave() or a session change can withdraw a
89
+ // request that hasn't reached the host yet.
90
+ const pending = new AbortController();
91
+ this.pendingJoin = pending;
92
+ // `join()` resolves at hello, before the host has said whether voice is on. A
93
+ // reconnect can attach again meanwhile; wait for the latest answer.
94
+ let awaited = null;
95
+ while (this.attaching && this.attaching !== awaited) {
96
+ awaited = this.attaching;
97
+ await awaited;
98
+ }
99
+ if (pending.signal.aborted ||
100
+ this.disposed ||
101
+ !this.voiceSessionId ||
102
+ this.currentState === "unavailable" ||
103
+ this.currentState === "live") {
104
+ if (this.pendingJoin === pending)
105
+ this.pendingJoin = null;
106
+ if (pending.signal.aborted || this.currentState !== "live")
107
+ return "unavailable";
108
+ return "joined";
109
+ }
110
+ const sessionId = this.voiceSessionId;
111
+ const version = this.hostStateVersion;
112
+ this.setState("joining");
113
+ let status;
114
+ try {
115
+ ({ status } = await hostRequest("voice.requestJoin", { sessionId }, { signal: pending.signal }));
116
+ }
117
+ catch {
118
+ status = "unavailable";
119
+ }
120
+ finally {
121
+ if (this.pendingJoin === pending)
122
+ this.pendingJoin = null;
123
+ }
124
+ if (pending.signal.aborted || !this.isCurrent(sessionId)) {
125
+ // Withdrawn, or the game connection ended, while the player was choosing. The
126
+ // abort already cancelled the request at the host, whose executor undoes a
127
+ // "yes" that lands anyway.
128
+ return "unavailable";
129
+ }
130
+ if (version === this.hostStateVersion) {
131
+ this.setState(status === "joined" ? "live" : status === "unavailable" ? "unavailable" : "off");
132
+ }
133
+ return status;
134
+ }
135
+ leave() {
136
+ if (this.disposed)
137
+ return;
138
+ const inVoice = this.currentState === "live" || this.currentState === "joining";
139
+ if (!inVoice && this.pendingJoin === null)
140
+ return;
141
+ this.withdraw();
142
+ if (inVoice) {
143
+ this.setState("off");
144
+ this.sendLeave(this.voiceSessionId);
145
+ }
146
+ }
147
+ setMix(mix) {
148
+ if (this.disposed || !mix || typeof mix !== "object")
149
+ return;
150
+ const players = this.access.players();
151
+ for (const [playerId, entry] of Object.entries(mix)) {
152
+ const player = players.find((candidate) => candidate.playerId === playerId);
153
+ if (!player || !entry || typeof entry !== "object")
154
+ continue;
155
+ const next = { ...this.pendingMix[player.userId] };
156
+ if (typeof entry.gain === "number" && Number.isFinite(entry.gain)) {
157
+ next.gain = clamp(entry.gain, 0, 1);
158
+ }
159
+ if (typeof entry.pan === "number" && Number.isFinite(entry.pan)) {
160
+ next.pan = clamp(entry.pan, -1, 1);
161
+ }
162
+ this.pendingMix[player.userId] = next;
163
+ }
164
+ this.scheduleMix();
165
+ }
166
+ onState(callback) {
167
+ this.states.add(callback);
168
+ callListener(callback, this.currentState);
169
+ return () => this.states.delete(callback);
170
+ }
171
+ onSpeaking(callback) {
172
+ this.speakers.add(callback);
173
+ callListener(callback, this.speaking.slice());
174
+ return () => this.speakers.delete(callback);
175
+ }
176
+ receive(event) {
177
+ if (this.disposed || !this.voiceSessionId || event.sessionId !== this.voiceSessionId) {
178
+ return;
179
+ }
180
+ if (event.type === "state") {
181
+ this.hostStateVersion += 1;
182
+ this.setState(event.state);
183
+ return;
184
+ }
185
+ this.speakingUserIds = event.userIds.slice();
186
+ this.emitSpeaking();
187
+ }
188
+ // The roster changed: speakers are re-mapped, and mixes for players who left dropped.
189
+ rosterChanged() {
190
+ if (this.disposed)
191
+ return;
192
+ const userIds = new Set(this.access.players().map((player) => player.userId));
193
+ for (const userId of Object.keys(this.pendingMix)) {
194
+ if (!userIds.has(userId))
195
+ delete this.pendingMix[userId];
196
+ }
197
+ for (const userId of Object.keys(this.sentMix)) {
198
+ if (!userIds.has(userId))
199
+ delete this.sentMix[userId];
200
+ }
201
+ this.emitSpeaking();
202
+ }
203
+ // The game connection ended: voice for its session goes with it.
204
+ dispose() {
205
+ if (this.disposed)
206
+ return;
207
+ this.endSession();
208
+ this.setState("unavailable");
209
+ this.disposed = true;
210
+ if (this.mixTimer !== null)
211
+ clearTimeout(this.mixTimer);
212
+ this.mixTimer = null;
213
+ setActiveVoice(null, this);
214
+ }
215
+ isCurrent(sessionId) {
216
+ return !this.disposed && sessionId !== "" && sessionId === this.voiceSessionId;
217
+ }
218
+ // Cancels a pending request, so a retry starts a fresh prompt.
219
+ withdraw() {
220
+ this.pendingJoin?.abort();
221
+ this.pendingJoin = null;
222
+ this.joinRequest = null;
223
+ }
224
+ // Leaves the current voice session, if the player is in it or being asked.
225
+ endSession() {
226
+ const inVoice = this.currentState === "live" || this.currentState === "joining" || this.pendingJoin !== null;
227
+ this.withdraw();
228
+ if (inVoice && this.voiceSessionId)
229
+ this.sendLeave(this.voiceSessionId);
230
+ this.speakingUserIds = [];
231
+ this.pendingMix = {};
232
+ this.sentMix = {};
233
+ this.emitSpeaking();
234
+ }
235
+ sendLeave(sessionId) {
236
+ void hostRequest("voice.leave", { sessionId }).catch(() => { });
237
+ }
238
+ setState(state) {
239
+ if (this.currentState === state)
240
+ return;
241
+ this.currentState = state;
242
+ if (state !== "live")
243
+ this.speakingUserIds = [];
244
+ this.emitSpeaking();
245
+ for (const callback of this.states)
246
+ callListener(callback, state);
247
+ if (state === "live") {
248
+ // A rejoined call starts from scratch at the host: send the deck's mix again.
249
+ for (const userId of Object.keys(this.sentMix)) {
250
+ this.pendingMix[userId] ??= {};
251
+ }
252
+ this.scheduleMix();
253
+ }
254
+ }
255
+ emitSpeaking() {
256
+ const userIds = new Set(this.speakingUserIds);
257
+ const playerIds = this.access
258
+ .players()
259
+ .filter((player) => userIds.has(player.userId))
260
+ .map((player) => player.playerId)
261
+ .sort();
262
+ if (playerIds.join("\n") === this.speaking.join("\n"))
263
+ return;
264
+ this.speaking = playerIds;
265
+ for (const callback of this.speakers)
266
+ callListener(callback, playerIds.slice());
267
+ }
268
+ scheduleMix() {
269
+ if (this.mixTimer !== null || Object.keys(this.pendingMix).length === 0)
270
+ return;
271
+ const wait = Math.max(0, this.lastMixSentAt + VOICE_MIX_INTERVAL_MS - Date.now());
272
+ this.mixTimer = setTimeout(() => {
273
+ this.mixTimer = null;
274
+ this.flushMix();
275
+ }, wait);
276
+ }
277
+ // Held until the player is in voice, then sent: the latest mix is what matters. Each
278
+ // entry goes out whole, so a field the deck didn't name keeps its last value.
279
+ flushMix() {
280
+ if (this.disposed || !this.voiceSessionId || this.currentState !== "live")
281
+ return;
282
+ const userIds = new Set(this.access.players().map((player) => player.userId));
283
+ const mix = {};
284
+ for (const [userId, entry] of Object.entries(this.pendingMix)) {
285
+ if (!userIds.has(userId))
286
+ continue;
287
+ const previous = this.sentMix[userId] ?? DEFAULT_MIX;
288
+ const full = { gain: entry.gain ?? previous.gain, pan: entry.pan ?? previous.pan };
289
+ this.sentMix[userId] = full;
290
+ mix[userId] = full;
291
+ }
292
+ this.pendingMix = {};
293
+ if (Object.keys(mix).length === 0)
294
+ return;
295
+ this.lastMixSentAt = Date.now();
296
+ void hostRequest("voice.setMix", { sessionId: this.voiceSessionId, mix }).catch(() => { });
297
+ }
298
+ }
299
+ function clamp(value, min, max) {
300
+ return Math.min(max, Math.max(min, value));
301
+ }
302
+ function callListener(callback, value) {
303
+ try {
304
+ callback(value);
305
+ }
306
+ catch {
307
+ // One deck listener must not break voice or other listeners.
308
+ }
309
+ }
package/dist/runtime.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type CommandName, type CommandParams, type CommandResponseEnvelope } from "./commands";
1
+ import { type CommandName, type CommandParams, type CommandResponseEnvelope, type CommandStreamEnvelope } from "./commands";
2
2
  import { type TunedParam } from "./paramsRegistry";
3
3
  export declare const CARD_RATIO: number;
4
4
  interface LocalResponse {
@@ -43,7 +43,9 @@ export declare function renameFile(from: string, to: string): Promise<void>;
43
43
  export declare function deleteFile(path: string): Promise<void>;
44
44
  export declare function openFile(path: string): void;
45
45
  export declare function initCard(): HTMLDivElement;
46
- export declare function sendLocalCommand<C extends CommandName>(command: C, params: CommandParams[C]): Promise<CommandResponseEnvelope>;
46
+ export declare function sendLocalCommand<C extends CommandName>(requestId: string, command: C, params: CommandParams[C], listener: (message: CommandResponseEnvelope | CommandStreamEnvelope) => void): Promise<void>;
47
+ export declare function cancelLocalCommand(requestId: string): void;
48
+ export declare function releaseLocalCommand(requestId: string): void;
47
49
  /**
48
50
  * Notified `true` on every fresh dev-server socket and `false` when one is lost.
49
51
  * What lets a queued write know the moment retrying is worth it again.
package/dist/runtime.js CHANGED
@@ -13,10 +13,9 @@ const pendingRequests = new Map();
13
13
  // Local-dev command channel: the `castle-web serve` dev server is the host, so
14
14
  // SDK commands ride the same websocket runtime.ts already uses for
15
15
  // logs/screenshots/restart. Correlated by requestId, separate from the
16
- // screenshot/write_file request map above.
17
- const COMMAND_TIMEOUT_MS = 15000;
16
+ // screenshot/write_file request map above. transport.ts owns the timeouts.
18
17
  const SOCKET_WAIT_TIMEOUT_MS = 10000;
19
- const pendingCommands = new Map();
18
+ const localCommandListeners = new Map();
20
19
  const origLog = console.log;
21
20
  const origWarn = console.warn;
22
21
  const origError = console.error;
@@ -324,38 +323,42 @@ function sendLocalRequest(msg) {
324
323
  ws.send(JSON.stringify(request));
325
324
  });
326
325
  }
327
- // Send an SDK command to the dev server and resolve with the raw response
328
- // envelope (ok/data/error). transport.ts interprets it — error reconstruction
329
- // stays uniform across all three channels there. Waits for the socket to open
330
- // so a command issued during startup isn't dropped.
331
- export function sendLocalCommand(command, params) {
332
- const requestId = `cmd_${nextRequestId++}`;
333
- return new Promise((resolve, reject) => {
334
- const timeout = setTimeout(() => {
335
- pendingCommands.delete(requestId);
336
- reject(new Error(`Timed out waiting for command ${command}.`));
337
- }, COMMAND_TIMEOUT_MS);
338
- pendingCommands.set(requestId, (env) => {
339
- clearTimeout(timeout);
340
- pendingCommands.delete(requestId);
341
- resolve(env);
342
- });
343
- waitForSocket()
344
- .then((socket) => {
345
- socket.send(JSON.stringify({
346
- type: "castle_command",
347
- castleSdk: CASTLE_SDK_PROTOCOL,
348
- requestId,
349
- command,
350
- params,
351
- }));
352
- })
353
- .catch((error) => {
354
- clearTimeout(timeout);
355
- pendingCommands.delete(requestId);
356
- reject(error instanceof Error ? error : new Error(String(error)));
357
- });
358
- });
326
+ // Send an SDK command to the dev server. Its stream envelopes and its response
327
+ // go to `listener` as they arrive, and the listener is dropped after the
328
+ // response. transport.ts interprets them, so error reconstruction stays uniform
329
+ // across all three channels there. Waits for the socket to open so a command
330
+ // issued during startup isn't dropped; rejects if it never opens.
331
+ export async function sendLocalCommand(requestId, command, params, listener) {
332
+ localCommandListeners.set(requestId, listener);
333
+ try {
334
+ const socket = await waitForSocket();
335
+ // Cancelled or timed out while the socket was opening.
336
+ if (!localCommandListeners.has(requestId)) {
337
+ return;
338
+ }
339
+ socket.send(JSON.stringify({
340
+ type: "castle_command",
341
+ castleSdk: CASTLE_SDK_PROTOCOL,
342
+ requestId,
343
+ command,
344
+ params,
345
+ }));
346
+ }
347
+ catch (error) {
348
+ localCommandListeners.delete(requestId);
349
+ throw error;
350
+ }
351
+ }
352
+ // Ask the dev server to stop a streaming command, and stop listening for it.
353
+ export function cancelLocalCommand(requestId) {
354
+ localCommandListeners.delete(requestId);
355
+ if (ws && ws.readyState === WebSocket.OPEN) {
356
+ ws.send(JSON.stringify({ type: "castle_command_cancel", castleSdk: CASTLE_SDK_PROTOCOL, requestId }));
357
+ }
358
+ }
359
+ // Stop listening for a command the deck has given up on.
360
+ export function releaseLocalCommand(requestId) {
361
+ localCommandListeners.delete(requestId);
359
362
  }
360
363
  function waitForSocket() {
361
364
  if (ws && ws.readyState === WebSocket.OPEN)
@@ -374,10 +377,15 @@ function waitForSocket() {
374
377
  }, 100);
375
378
  });
376
379
  }
377
- function resolveLocalCommand(msg) {
378
- const pending = pendingCommands.get(msg.requestId);
379
- if (pending)
380
- pending(msg);
380
+ function deliverLocalCommand(msg) {
381
+ const listener = localCommandListeners.get(msg.requestId);
382
+ if (!listener) {
383
+ return;
384
+ }
385
+ if (!("stream" in msg)) {
386
+ localCommandListeners.delete(msg.requestId);
387
+ }
388
+ listener(msg);
381
389
  }
382
390
  function resolveLocalRequest(msg) {
383
391
  if (!msg.requestId)
@@ -919,7 +927,10 @@ function handleLocalMessage(msg) {
919
927
  resolveLocalRequest(msg);
920
928
  }
921
929
  else if (msg.type === "castle_command_response") {
922
- resolveLocalCommand(msg);
930
+ deliverLocalCommand(msg);
931
+ }
932
+ else if (msg.type === "castle_command_stream") {
933
+ deliverLocalCommand(msg);
923
934
  }
924
935
  }
925
936
  // Files-changed listeners, registered by consumers (the kit engine, editors).
@@ -106,6 +106,48 @@ export interface EvaluateCharacterInformationInput {
106
106
  operationId: string;
107
107
  maxDeliveries?: number;
108
108
  }
109
+ export type CharacterStoryOpportunity = {
110
+ type: "approach" | "room_entry" | "world_action";
111
+ id: string;
112
+ };
113
+ export interface EvaluateCharacterBeatsInput {
114
+ operationId: string;
115
+ opportunityId: string;
116
+ opportunity: CharacterStoryOpportunity;
117
+ presentCharacterIds: string[];
118
+ }
119
+ export interface CharacterBeatSelection {
120
+ deliveryId: string;
121
+ beatId: string;
122
+ characterId: string;
123
+ openingText: string;
124
+ }
125
+ export interface CharacterBeatEvaluationResult {
126
+ operationId: string;
127
+ opportunityId: string;
128
+ opportunityCount: number;
129
+ gameTime: number | null;
130
+ selection: CharacterBeatSelection | null;
131
+ }
132
+ export type CharacterBeatEvaluationOperation = Omit<CharacterOperation, "result"> & {
133
+ result: CharacterBeatEvaluationResult | null;
134
+ };
135
+ export interface CompleteScheduledCharacterBeatInput {
136
+ operationId: string;
137
+ deliveryId: string;
138
+ outcomeId: string;
139
+ }
140
+ export interface CharacterScheduledBeatCompletionResult {
141
+ completionId: string;
142
+ deliveryId: string;
143
+ beatId: string;
144
+ characterId: string;
145
+ outcomeId: string;
146
+ completedAt: string;
147
+ }
148
+ export type CharacterScheduledBeatCompletionOperation = Omit<CharacterOperation, "result"> & {
149
+ result: CharacterScheduledBeatCompletionResult | null;
150
+ };
109
151
  export interface CharacterInformationEvaluationResult {
110
152
  generation: number;
111
153
  evaluatedAt: string;
@@ -153,6 +195,8 @@ export interface ServerPlaythroughCharacters {
153
195
  deliverKnowledge(input: DeliverCharacterKnowledgeInput): Promise<CharacterOperation>;
154
196
  completeBeat(input: CompleteCharacterBeatInput): Promise<CharacterOperation>;
155
197
  evaluateInformation(input: EvaluateCharacterInformationInput): Promise<CharacterInformationEvaluationOperation>;
198
+ evaluateBeats(input: EvaluateCharacterBeatsInput): Promise<CharacterBeatEvaluationOperation>;
199
+ completeScheduledBeat(input: CompleteScheduledCharacterBeatInput): Promise<CharacterScheduledBeatCompletionOperation>;
156
200
  status(operationId: string): Promise<CharacterOperation>;
157
201
  history(options?: ServerCharacterHistoryOptions): Promise<CharacterHistory>;
158
202
  }