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/README.md +201 -7
- package/dist/agent.d.ts +6 -0
- package/dist/agent.js +70 -0
- package/dist/castle.d.ts +5 -0
- package/dist/castle.js +2 -0
- package/dist/commands.d.ts +154 -0
- package/dist/commands.js +46 -0
- package/dist/decks.d.ts +7 -0
- package/dist/decks.js +31 -0
- package/dist/multiplayer.d.ts +3 -0
- package/dist/multiplayer.js +13 -0
- package/dist/multiplayerVoice.d.ts +67 -0
- package/dist/multiplayerVoice.js +309 -0
- package/dist/runtime.d.ts +4 -2
- package/dist/runtime.js +51 -40
- package/dist/server/storage.d.ts +44 -0
- package/dist/server/storage.js +54 -0
- package/dist/transport.d.ts +7 -2
- package/dist/transport.js +120 -23
- package/package.json +1 -1
package/dist/server/storage.js
CHANGED
|
@@ -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 ||
|
package/dist/transport.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
36
|
+
let voiceEventHandler = null;
|
|
37
|
+
export async function hostRequest(command, params, stream = {}) {
|
|
28
38
|
try {
|
|
29
|
-
const
|
|
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
|
|
107
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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;
|