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.
@@ -159,6 +159,25 @@ function createPlaythrough(call, subject, generation) {
159
159
  ...(maxDeliveries === undefined ? {} : { maxDeliveries }),
160
160
  });
161
161
  },
162
+ async evaluateBeats(input) {
163
+ return call("characterEvaluateBeats", {
164
+ subject,
165
+ generation,
166
+ operationId: assertOperationId(input?.operationId),
167
+ opportunityId: assertOpportunityId(input?.opportunityId),
168
+ opportunity: assertStoryOpportunity(input?.opportunity),
169
+ presentCharacterIds: assertPresentCharacterIds(input?.presentCharacterIds),
170
+ });
171
+ },
172
+ async completeScheduledBeat(input) {
173
+ return call("characterCompleteScheduledBeat", {
174
+ subject,
175
+ generation,
176
+ operationId: assertOperationId(input?.operationId),
177
+ deliveryId: assertOpportunityId(input?.deliveryId, "deliveryId"),
178
+ outcomeId: assertNarrativeId(input?.outcomeId, "outcomeId"),
179
+ });
180
+ },
162
181
  async status(operationId) {
163
182
  return call("characterStatus", {
164
183
  subject,
@@ -343,6 +362,41 @@ function assertOperationId(value) {
343
362
  }
344
363
  return value;
345
364
  }
365
+ function assertOpportunityId(value, field = "opportunityId") {
366
+ if (typeof value !== "string" ||
367
+ value.length === 0 ||
368
+ value.length > MAX_OPERATION_ID_CHARACTERS ||
369
+ value.includes("\0") ||
370
+ hasUnpairedSurrogate(value) ||
371
+ utf8Length(value) > MAX_OPERATION_ID_BYTES) {
372
+ throw characterArgumentError(`${field} must be valid text of at most ${MAX_OPERATION_ID_CHARACTERS} characters and ${MAX_OPERATION_ID_BYTES} bytes.`);
373
+ }
374
+ return value;
375
+ }
376
+ function assertStoryOpportunity(value) {
377
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
378
+ throw characterArgumentError("opportunity must contain a type and id.");
379
+ }
380
+ const opportunity = value;
381
+ if (Object.keys(opportunity).some((key) => key !== "type" && key !== "id") ||
382
+ !["approach", "room_entry", "world_action"].includes(String(opportunity.type))) {
383
+ throw characterArgumentError("opportunity type is invalid.");
384
+ }
385
+ return {
386
+ type: opportunity.type,
387
+ id: assertNarrativeId(opportunity.id, "opportunity.id"),
388
+ };
389
+ }
390
+ function assertPresentCharacterIds(value) {
391
+ if (!Array.isArray(value) || value.length > 16) {
392
+ throw characterArgumentError("presentCharacterIds must contain at most 16 character ids.");
393
+ }
394
+ const ids = value.map((item) => assertNarrativeId(item, "presentCharacterIds"));
395
+ if (new Set(ids).size !== ids.length) {
396
+ throw characterArgumentError("presentCharacterIds must not contain duplicates.");
397
+ }
398
+ return ids.sort();
399
+ }
346
400
  function assertNarrativeId(value, field) {
347
401
  if (typeof value !== "string" ||
348
402
  value.length === 0 ||
@@ -1,5 +1,9 @@
1
- import { type CommandName, type CommandParams, type CommandResult, type LifecycleEvent } from "./commands";
1
+ import { type CommandName, type CommandParams, type CommandResult, type LifecycleEvent, type VoiceEvent } from "./commands";
2
2
  type PostChannel = "mobile" | "web";
3
+ export interface HostStreamOptions {
4
+ onStream?: (event: unknown) => void;
5
+ signal?: AbortSignal;
6
+ }
3
7
  interface ReactNativeWebViewBridge {
4
8
  postMessage: (message: string) => void;
5
9
  }
@@ -11,10 +15,11 @@ declare global {
11
15
  };
12
16
  }
13
17
  }
14
- export declare function hostRequest<C extends CommandName>(command: C, params: CommandParams[C]): Promise<CommandResult[C]>;
18
+ export declare function hostRequest<C extends CommandName>(command: C, params: CommandParams[C], stream?: HostStreamOptions): Promise<CommandResult[C]>;
15
19
  export declare function getCommandChannel(): PostChannel | "local";
16
20
  export declare function hostNotify(event: LifecycleEvent): void;
17
21
  export declare function postUnloadDone(unloadId: string, state?: string): void;
18
22
  export declare function setUnloadHandler(handler: (unloadId: string) => void): void;
23
+ export declare function setVoiceEventHandler(handler: ((event: VoiceEvent) => void) | null): void;
19
24
  export declare function installHostListener(): void;
20
25
  export {};
package/dist/transport.js CHANGED
@@ -7,10 +7,14 @@
7
7
  // - web → window.parent.postMessage; responses via 'message' events
8
8
  // - local → the castle-web serve dev server, over runtime.ts's websocket
9
9
  // Error reconstruction is uniform here so callers always get a CastleError.
10
- import { CASTLE_SDK_PROTOCOL, isResponseEnvelope, unloadRequestId, } from "./commands";
10
+ //
11
+ // A streaming command (agent.chat) gets stream envelopes on its requestId before
12
+ // its response. Each one goes to the caller's onStream and restarts the wait,
13
+ // and the caller's AbortSignal sends the host a cancel envelope.
14
+ import { CASTLE_SDK_PROTOCOL, COMMAND_TIMEOUTS_MS, STREAM_IDLE_TIMEOUT_MS, isResponseEnvelope, isStreamEnvelope, unloadRequestId, voiceEventFrom, } from "./commands";
11
15
  import { getCastleEmbed } from "./context";
12
16
  import { CastleError } from "./errors";
13
- import { sendLocalCommand } from "./runtime";
17
+ import { cancelLocalCommand, releaseLocalCommand, sendLocalCommand } from "./runtime";
14
18
  const REQUEST_TIMEOUT_MS = 15000;
15
19
  // Interactive platform commands hold the screen while the player interacts with
16
20
  // a host-native sheet (e.g. a pass purchase), so the flat data-command timeout
@@ -19,17 +23,20 @@ const INTERACTIVE_COMMANDS = new Set([
19
23
  "pass.offer",
20
24
  "snapshot.offer",
21
25
  "portal.open",
26
+ // The host's voice prompt and the browser's microphone permission.
27
+ "voice.requestJoin",
22
28
  ]);
29
+ // Request ids carry a per-page prefix so that a host serving several decks at
30
+ // once can key a cancel by requestId alone.
31
+ const requestIdPrefix = `csdk_${Math.random().toString(36).slice(2, 10)}_`;
23
32
  let nextRequestId = 1;
24
33
  const pending = new Map();
25
34
  let listenersInstalled = false;
26
35
  let unloadHandler = null;
27
- export async function hostRequest(command, params) {
36
+ let voiceEventHandler = null;
37
+ export async function hostRequest(command, params, stream = {}) {
28
38
  try {
29
- const channel = resolveChannel();
30
- const env = channel === "local"
31
- ? await sendLocalCommand(command, params)
32
- : await postCommand(channel, command, params);
39
+ const env = await sendCommand(resolveChannel(), command, params, stream);
33
40
  return interpretResponse(command, env);
34
41
  }
35
42
  catch (error) {
@@ -89,6 +96,12 @@ export function postUnloadDone(unloadId, state) {
89
96
  export function setUnloadHandler(handler) {
90
97
  unloadHandler = handler;
91
98
  }
99
+ // Registered by multiplayerVoice.ts, the same way, for host voice events.
100
+ export function setVoiceEventHandler(handler) {
101
+ voiceEventHandler = handler;
102
+ if (handler)
103
+ installHostListener();
104
+ }
92
105
  function resolveChannel() {
93
106
  if (typeof window === "undefined")
94
107
  return "local";
@@ -103,20 +116,84 @@ function resolveChannel() {
103
116
  // local dev server (top-level page served by `castle-web serve`).
104
117
  return window.parent && window.parent !== window ? "web" : "local";
105
118
  }
106
- function postCommand(channel, command, params) {
107
- installHostListener();
108
- const requestId = `csdk_${nextRequestId++}`;
119
+ function sendCommand(channel, command, params, stream) {
120
+ const requestId = `${requestIdPrefix}${nextRequestId++}`;
109
121
  return new Promise((resolve, reject) => {
110
- const timeout = INTERACTIVE_COMMANDS.has(command)
111
- ? undefined
112
- : setTimeout(() => {
113
- pending.delete(requestId);
114
- reject(new Error(`Castle host did not respond to ${command}.`));
115
- }, REQUEST_TIMEOUT_MS);
116
- pending.set(requestId, { resolve, reject, timeout });
122
+ if (stream.signal?.aborted) {
123
+ reject(cancelledError(command));
124
+ return;
125
+ }
126
+ const entry = { command, channel, resolve, reject, onStream: stream.onStream };
127
+ pending.set(requestId, entry);
128
+ restartTimeout(requestId, entry, COMMAND_TIMEOUTS_MS[command] ?? REQUEST_TIMEOUT_MS);
129
+ watchAbort(requestId, entry, stream.signal);
130
+ if (channel === "local") {
131
+ sendLocalCommand(requestId, command, params, receive).catch((error) => {
132
+ forget(requestId);
133
+ reject(error instanceof Error ? error : new Error(String(error)));
134
+ });
135
+ return;
136
+ }
137
+ installHostListener();
117
138
  sendEnvelope(channel, { castleSdk: CASTLE_SDK_PROTOCOL, requestId, command, params });
118
139
  });
119
140
  }
141
+ // Interactive commands have no timeout; they resolve only when the host replies.
142
+ function restartTimeout(requestId, entry, ms) {
143
+ if (entry.timeout) {
144
+ clearTimeout(entry.timeout);
145
+ }
146
+ if (INTERACTIVE_COMMANDS.has(entry.command)) {
147
+ return;
148
+ }
149
+ entry.timeout = setTimeout(() => {
150
+ forget(requestId);
151
+ entry.reject(new Error(`Castle host did not respond to ${entry.command}.`));
152
+ }, ms);
153
+ }
154
+ function watchAbort(requestId, entry, signal) {
155
+ if (!signal) {
156
+ return;
157
+ }
158
+ const onAbort = () => {
159
+ forget(requestId);
160
+ if (entry.channel === "local") {
161
+ cancelLocalCommand(requestId);
162
+ }
163
+ else {
164
+ const envelope = {
165
+ castleSdk: CASTLE_SDK_PROTOCOL,
166
+ requestId,
167
+ cancel: true,
168
+ };
169
+ sendEnvelope(entry.channel, envelope);
170
+ }
171
+ entry.reject(cancelledError(entry.command));
172
+ };
173
+ signal.addEventListener("abort", onAbort, { once: true });
174
+ entry.stopWatchingAbort = () => signal.removeEventListener("abort", onAbort);
175
+ }
176
+ function forget(requestId) {
177
+ const entry = pending.get(requestId);
178
+ if (!entry) {
179
+ return;
180
+ }
181
+ pending.delete(requestId);
182
+ if (entry.timeout) {
183
+ clearTimeout(entry.timeout);
184
+ }
185
+ entry.stopWatchingAbort?.();
186
+ if (entry.channel === "local") {
187
+ releaseLocalCommand(requestId);
188
+ }
189
+ }
190
+ function cancelledError(command) {
191
+ return new CastleError({
192
+ code: "CANCELLED",
193
+ message: `${command} was cancelled.`,
194
+ operation: command,
195
+ });
196
+ }
120
197
  function sendEnvelope(channel, envelope) {
121
198
  const json = JSON.stringify(envelope);
122
199
  if (channel === "mobile") {
@@ -146,21 +223,41 @@ function receive(message) {
146
223
  settle(message);
147
224
  return;
148
225
  }
226
+ if (isStreamEnvelope(message)) {
227
+ deliverStream(message.requestId, message.stream);
228
+ return;
229
+ }
230
+ const voiceEvent = voiceEventFrom(message);
231
+ if (voiceEvent) {
232
+ voiceEventHandler?.(voiceEvent);
233
+ return;
234
+ }
149
235
  const unloadId = unloadRequestId(message);
150
236
  if (unloadId !== null)
151
237
  unloadHandler?.(unloadId);
152
238
  }
153
239
  function settle(message) {
154
- if (!isResponseEnvelope(message))
155
- return;
156
240
  const entry = pending.get(message.requestId);
157
- if (!entry)
241
+ if (!entry) {
158
242
  return;
159
- if (entry.timeout)
160
- clearTimeout(entry.timeout);
161
- pending.delete(message.requestId);
243
+ }
244
+ forget(message.requestId);
162
245
  entry.resolve(message);
163
246
  }
247
+ // A throwing onStream is logged, and the stream goes on.
248
+ function deliverStream(requestId, event) {
249
+ const entry = pending.get(requestId);
250
+ if (!entry) {
251
+ return;
252
+ }
253
+ restartTimeout(requestId, entry, STREAM_IDLE_TIMEOUT_MS);
254
+ try {
255
+ entry.onStream?.(event);
256
+ }
257
+ catch (error) {
258
+ console.error(`Castle ${entry.command} stream handler threw:`, error);
259
+ }
260
+ }
164
261
  function interpretResponse(command, env) {
165
262
  if (env.ok)
166
263
  return env.data;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-sdk",
3
- "version": "0.4.34",
3
+ "version": "0.4.36",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/castle-xyz/castle-experimental-web.git"