castle-web-sdk 0.4.33 → 0.4.35
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 +131 -0
- package/dist/agent.d.ts +6 -0
- package/dist/agent.js +70 -0
- package/dist/castle.d.ts +4 -0
- package/dist/castle.js +2 -0
- package/dist/commands.d.ts +106 -0
- package/dist/commands.js +21 -0
- package/dist/decks.d.ts +7 -0
- package/dist/decks.js +31 -0
- package/dist/runtime.d.ts +4 -2
- package/dist/runtime.js +51 -40
- package/dist/server/storage.d.ts +84 -0
- package/dist/server/storage.js +109 -0
- package/dist/transport.d.ts +5 -1
- package/dist/transport.js +106 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,7 +24,9 @@ import { setup, initCard, Store, Leaderboard } from "castle-web-sdk";
|
|
|
24
24
|
- [Pass](#pass)
|
|
25
25
|
- [Snapshot](#snapshot)
|
|
26
26
|
- [Portal](#portal)
|
|
27
|
+
- [Decks](#decks)
|
|
27
28
|
- [Haptics](#haptics)
|
|
29
|
+
- [Agent](#agent)
|
|
28
30
|
- [Lifecycle](#lifecycle)
|
|
29
31
|
- [Multiplayer](#multiplayer)
|
|
30
32
|
- [Setup](#setup)
|
|
@@ -616,6 +618,38 @@ if (info.status === "ok") {
|
|
|
616
618
|
|
|
617
619
|
Answers are kept per deck for the session, so asking again is cheap.
|
|
618
620
|
|
|
621
|
+
## Decks
|
|
622
|
+
|
|
623
|
+
`Decks` lists decks the player can point a portal at: their own published
|
|
624
|
+
decks, and the decks they saved. A deck can offer these as a picker rather
|
|
625
|
+
than asking for a pasted link.
|
|
626
|
+
Decks is limited to Castle admins for now; for every other player both calls
|
|
627
|
+
reject with a `PERMISSION_DENIED` error.
|
|
628
|
+
|
|
629
|
+
### `Decks.mine(options?): Promise<DecksListResult>`
|
|
630
|
+
|
|
631
|
+
The player's own decks, newest first, leaving out private ones.
|
|
632
|
+
|
|
633
|
+
### `Decks.saved(options?): Promise<DecksListResult>`
|
|
634
|
+
|
|
635
|
+
The decks the player bookmarked, then the decks in their playlists, each
|
|
636
|
+
once.
|
|
637
|
+
|
|
638
|
+
Both take `{ limit }`, from 1 through 50 (default 20), and both leave out
|
|
639
|
+
decks whose creator turned portals off. The result is
|
|
640
|
+
`{ status: 'ok', decks }`, where each deck is
|
|
641
|
+
`{ deckId, title, creator: { userId, username } | null, coverUrl }` — the
|
|
642
|
+
same shape as `Portal.info`. `coverUrl` is a `data:` url, since a deck may
|
|
643
|
+
not load remote images; it is null when the deck has no cover. A player
|
|
644
|
+
who is not signed in gets an empty list.
|
|
645
|
+
|
|
646
|
+
```js
|
|
647
|
+
const { decks } = await Decks.mine({ limit: 12 });
|
|
648
|
+
for (const deck of decks) {
|
|
649
|
+
addPickerTile(deck.title, deck.coverUrl, () => placePortal(deck.deckId));
|
|
650
|
+
}
|
|
651
|
+
```
|
|
652
|
+
|
|
619
653
|
## Haptics
|
|
620
654
|
|
|
621
655
|
`Haptics` plays a short device vibration ("buzz") for tactile feedback —
|
|
@@ -651,6 +685,103 @@ Haptics respect the player's settings — if a player has muted haptics,
|
|
|
651
685
|
`play` does nothing and resolves `'unavailable'`. Rapid repeated calls may
|
|
652
686
|
be coalesced, so it's safe to call on frequent events.
|
|
653
687
|
|
|
688
|
+
## Agent
|
|
689
|
+
|
|
690
|
+
`Agent` asks a language model for one chat completion. The host sends it
|
|
691
|
+
through Castle's oracle on the player's own Castle account, so the deck
|
|
692
|
+
holds no key. Agent.chat is limited to Castle admins for now; every other
|
|
693
|
+
player gets `'refused'`.
|
|
694
|
+
|
|
695
|
+
### `Agent.chat(params): Promise<AgentChatResult>`
|
|
696
|
+
|
|
697
|
+
`params`:
|
|
698
|
+
|
|
699
|
+
- `messages` — the conversation, in the OpenAI chat format
|
|
700
|
+
(`{ role, content }`, plus `tool_calls` on an assistant message and
|
|
701
|
+
`tool_call_id` on a `'tool'` message).
|
|
702
|
+
- `tools` — optional function tools, in the OpenAI function-tool schema.
|
|
703
|
+
- `toolChoice` — optional: `'auto'` (the default), `'none'`, `'required'`,
|
|
704
|
+
or `{ type: 'function', function: { name } }` to force one tool.
|
|
705
|
+
- `model` — optional OpenRouter model id. Defaults to
|
|
706
|
+
`'meta/muse-spark-1.3'`.
|
|
707
|
+
- `maxTokens` — optional cap on the reply's length.
|
|
708
|
+
- `reasoning` — optional `{ effort: 'minimal' | 'low' | 'medium' | 'high' }`,
|
|
709
|
+
how hard the model thinks before it answers. Lower effort answers sooner.
|
|
710
|
+
Left out, the model uses its own default. Muse can't turn reasoning off;
|
|
711
|
+
`'minimal'` is its least.
|
|
712
|
+
- `onDelta` — optional callback for the reply as it streams in. It gets
|
|
713
|
+
`{ type: 'text', text }` and `{ type: 'reasoning', text }` pieces, and
|
|
714
|
+
`{ type: 'toolCall', toolCall }` once for each tool call, when its
|
|
715
|
+
arguments are complete. Pieces come in the order the model wrote them.
|
|
716
|
+
- `signal` — optional `AbortSignal`. Aborting it stops the model and
|
|
717
|
+
resolves the call with `'cancelled'` at once.
|
|
718
|
+
|
|
719
|
+
The promise resolves with the whole message when the model finishes. A deck
|
|
720
|
+
runs any tool calls itself, appends the message and one `'tool'` message per
|
|
721
|
+
call, and calls Agent.chat again, until a reply has no tool calls:
|
|
722
|
+
|
|
723
|
+
```js
|
|
724
|
+
import { Agent } from "castle-web-sdk";
|
|
725
|
+
|
|
726
|
+
const tools = [
|
|
727
|
+
{
|
|
728
|
+
type: "function",
|
|
729
|
+
function: {
|
|
730
|
+
name: "roll_dice",
|
|
731
|
+
description: "Roll six-sided dice.",
|
|
732
|
+
parameters: {
|
|
733
|
+
type: "object",
|
|
734
|
+
properties: { count: { type: "integer" } },
|
|
735
|
+
required: ["count"],
|
|
736
|
+
},
|
|
737
|
+
},
|
|
738
|
+
},
|
|
739
|
+
];
|
|
740
|
+
const messages = [{ role: "user", content: "Roll two dice and add them." }];
|
|
741
|
+
const stop = new AbortController();
|
|
742
|
+
|
|
743
|
+
for (let round = 0; round < 8; round++) {
|
|
744
|
+
const result = await Agent.chat({
|
|
745
|
+
messages,
|
|
746
|
+
tools,
|
|
747
|
+
signal: stop.signal,
|
|
748
|
+
onDelta: (delta) => {
|
|
749
|
+
if (delta.type === "text") {
|
|
750
|
+
showText(delta.text);
|
|
751
|
+
}
|
|
752
|
+
},
|
|
753
|
+
});
|
|
754
|
+
if (result.status !== "ok") {
|
|
755
|
+
break;
|
|
756
|
+
}
|
|
757
|
+
messages.push(result.message);
|
|
758
|
+
const calls = result.message.tool_calls ?? [];
|
|
759
|
+
if (calls.length === 0) {
|
|
760
|
+
break;
|
|
761
|
+
}
|
|
762
|
+
for (const call of calls) {
|
|
763
|
+
const { count } = JSON.parse(call.function.arguments);
|
|
764
|
+
const faces = Array.from({ length: count }, () => 1 + Math.floor(Math.random() * 6));
|
|
765
|
+
messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(faces) });
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
`AgentChatResult` has a `status`:
|
|
771
|
+
|
|
772
|
+
- `'ok'` — `message` is the assistant's reply: `{ role, content,
|
|
773
|
+
tool_calls? }`, with `content` null when the reply is only tool calls.
|
|
774
|
+
`reasoning` is the model's reasoning summary when it gave one, for
|
|
775
|
+
display; it is not part of `message`. `finishReason` is `'stop'`,
|
|
776
|
+
`'tool_calls'` or `'length'`. `usage` carries `prompt_tokens`,
|
|
777
|
+
`completion_tokens` and `total_tokens`.
|
|
778
|
+
- `'refused'` — the player is not a Castle admin. The model was not called.
|
|
779
|
+
- `'unavailable'` — this host has no route to the oracle.
|
|
780
|
+
- `'cancelled'` — the call's `signal` was aborted.
|
|
781
|
+
- `'error'` — the oracle or the model failed; `reason` says how.
|
|
782
|
+
|
|
783
|
+
Every status but `'ok'` carries a `reason`.
|
|
784
|
+
|
|
654
785
|
## Lifecycle
|
|
655
786
|
|
|
656
787
|
`Lifecycle` tells the host when the deck has painted its first frame, so
|
package/dist/agent.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { AgentChatParams, AgentChatResult } from "./commands";
|
|
2
|
+
export type { AgentChatDelta, AgentChatParams, AgentChatResult, AgentMessage, AgentReasoning, AgentTool, AgentToolCall, AgentToolChoice, AgentUsage, } from "./commands";
|
|
3
|
+
export interface CastleAgentApi {
|
|
4
|
+
chat(params: AgentChatParams): Promise<AgentChatResult>;
|
|
5
|
+
}
|
|
6
|
+
export declare const Agent: CastleAgentApi;
|
package/dist/agent.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Agent — one chat completion from a language model, run by the host through
|
|
2
|
+
// Castle's oracle with the player's own Castle account. Messages and tools
|
|
3
|
+
// follow the OpenAI chat completions format. The reply streams: `onDelta` gets
|
|
4
|
+
// text and reasoning as they arrive and each tool call once its arguments are
|
|
5
|
+
// complete, and the promise resolves with the whole message. A deck runs any
|
|
6
|
+
// tool calls itself and calls Agent.chat again with the results.
|
|
7
|
+
//
|
|
8
|
+
// Agent.chat is limited to Castle admins for now: every other player gets
|
|
9
|
+
// `refused`. A host with no route to the oracle answers `unavailable`.
|
|
10
|
+
import { CastleError } from "./errors";
|
|
11
|
+
import { hostRequest } from "./transport";
|
|
12
|
+
async function chat(params) {
|
|
13
|
+
validate(params);
|
|
14
|
+
const onDelta = params.onDelta;
|
|
15
|
+
try {
|
|
16
|
+
return await hostRequest("agent.chat", {
|
|
17
|
+
messages: params.messages,
|
|
18
|
+
tools: params.tools,
|
|
19
|
+
toolChoice: params.toolChoice,
|
|
20
|
+
model: params.model,
|
|
21
|
+
maxTokens: params.maxTokens,
|
|
22
|
+
reasoning: params.reasoning,
|
|
23
|
+
}, {
|
|
24
|
+
onStream: onDelta ? (event) => deliverDelta(onDelta, event) : undefined,
|
|
25
|
+
signal: params.signal,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error instanceof CastleError && error.code === "CANCELLED") {
|
|
30
|
+
return { status: "cancelled", reason: "Agent.chat was cancelled." };
|
|
31
|
+
}
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const REASONING_EFFORTS = ["minimal", "low", "medium", "high"];
|
|
36
|
+
function validate(params) {
|
|
37
|
+
if (!params || typeof params !== "object") {
|
|
38
|
+
invalid("Agent.chat requires { messages }.");
|
|
39
|
+
}
|
|
40
|
+
if (!Array.isArray(params.messages) || params.messages.length === 0) {
|
|
41
|
+
invalid("Agent.chat messages must be a non-empty array.");
|
|
42
|
+
}
|
|
43
|
+
if (params.tools !== undefined && !Array.isArray(params.tools)) {
|
|
44
|
+
invalid("Agent.chat tools must be an array.");
|
|
45
|
+
}
|
|
46
|
+
if (params.model !== undefined && (typeof params.model !== "string" || params.model === "")) {
|
|
47
|
+
invalid("Agent.chat model must be a model id.");
|
|
48
|
+
}
|
|
49
|
+
if (params.maxTokens !== undefined &&
|
|
50
|
+
(!Number.isSafeInteger(params.maxTokens) || params.maxTokens < 1)) {
|
|
51
|
+
invalid("Agent.chat maxTokens must be a positive integer.");
|
|
52
|
+
}
|
|
53
|
+
if (params.reasoning !== undefined && !REASONING_EFFORTS.includes(params.reasoning?.effort)) {
|
|
54
|
+
invalid("Agent.chat reasoning must be { effort: 'minimal' | 'low' | 'medium' | 'high' }.");
|
|
55
|
+
}
|
|
56
|
+
if (params.onDelta !== undefined && typeof params.onDelta !== "function") {
|
|
57
|
+
invalid("Agent.chat onDelta must be a function.");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Keepalives only restart the wait in transport.ts; the deck never sees them.
|
|
61
|
+
function deliverDelta(onDelta, event) {
|
|
62
|
+
const type = event?.type;
|
|
63
|
+
if (type === "text" || type === "reasoning" || type === "toolCall") {
|
|
64
|
+
onDelta(event);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function invalid(message) {
|
|
68
|
+
throw new CastleError({ code: "INVALID_ARGUMENT", message, operation: "Agent.chat" });
|
|
69
|
+
}
|
|
70
|
+
export const Agent = Object.freeze({ chat });
|
package/dist/castle.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export { isEdit } from "./context";
|
|
2
|
+
export { Agent } from "./agent";
|
|
3
|
+
export type { AgentChatDelta, AgentChatParams, AgentChatResult, AgentMessage, AgentReasoning, AgentTool, AgentToolCall, AgentToolChoice, AgentUsage, CastleAgentApi, } from "./agent";
|
|
2
4
|
export { Characters } from "./characters";
|
|
3
5
|
export type { CastleCharactersApi, CharacterHandle, CharacterHistoryOptions, CharacterSendInput, } from "./characters";
|
|
6
|
+
export { Decks } from "./decks";
|
|
7
|
+
export type { CastleDecksApi, DeckSummary, DecksListParams, DecksListResult } from "./decks";
|
|
4
8
|
export { CastleError } from "./errors";
|
|
5
9
|
export { deckPrefixOf, IMPORTS_DIR, IMPORTS_PREFIX, isImportedFile, resolveDeckFile, } from "./files";
|
|
6
10
|
export { Haptics } from "./haptics";
|
package/dist/castle.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Castle Web SDK
|
|
2
2
|
export { isEdit } from "./context";
|
|
3
|
+
export { Agent } from "./agent";
|
|
3
4
|
export { Characters } from "./characters";
|
|
5
|
+
export { Decks } from "./decks";
|
|
4
6
|
export { CastleError } from "./errors";
|
|
5
7
|
export { deckPrefixOf, IMPORTS_DIR, IMPORTS_PREFIX, isImportedFile, resolveDeckFile, } from "./files";
|
|
6
8
|
export { Haptics } from "./haptics";
|
package/dist/commands.d.ts
CHANGED
|
@@ -142,7 +142,97 @@ export interface CharacterHistory {
|
|
|
142
142
|
operations: CharacterOperation[];
|
|
143
143
|
cursor: string | null;
|
|
144
144
|
}
|
|
145
|
+
export interface AgentToolCall {
|
|
146
|
+
id: string;
|
|
147
|
+
type: "function";
|
|
148
|
+
function: {
|
|
149
|
+
name: string;
|
|
150
|
+
arguments: string;
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
export interface AgentMessage {
|
|
154
|
+
role: "system" | "user" | "assistant" | "tool";
|
|
155
|
+
content: string | Json[] | null;
|
|
156
|
+
tool_calls?: AgentToolCall[];
|
|
157
|
+
tool_call_id?: string;
|
|
158
|
+
name?: string;
|
|
159
|
+
}
|
|
160
|
+
export interface AgentTool {
|
|
161
|
+
type: "function";
|
|
162
|
+
function: {
|
|
163
|
+
name: string;
|
|
164
|
+
description?: string;
|
|
165
|
+
parameters?: Json;
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
export type AgentToolChoice = "auto" | "none" | "required" | {
|
|
169
|
+
type: "function";
|
|
170
|
+
function: {
|
|
171
|
+
name: string;
|
|
172
|
+
};
|
|
173
|
+
};
|
|
174
|
+
export type AgentChatDelta = {
|
|
175
|
+
type: "text";
|
|
176
|
+
text: string;
|
|
177
|
+
} | {
|
|
178
|
+
type: "reasoning";
|
|
179
|
+
text: string;
|
|
180
|
+
} | {
|
|
181
|
+
type: "toolCall";
|
|
182
|
+
toolCall: AgentToolCall;
|
|
183
|
+
};
|
|
184
|
+
export type AgentChatStreamEvent = AgentChatDelta | {
|
|
185
|
+
type: "keepalive";
|
|
186
|
+
};
|
|
187
|
+
export interface AgentReasoning {
|
|
188
|
+
effort: "minimal" | "low" | "medium" | "high";
|
|
189
|
+
}
|
|
190
|
+
export interface AgentChatParams {
|
|
191
|
+
messages: AgentMessage[];
|
|
192
|
+
tools?: AgentTool[];
|
|
193
|
+
toolChoice?: AgentToolChoice;
|
|
194
|
+
model?: string;
|
|
195
|
+
maxTokens?: number;
|
|
196
|
+
reasoning?: AgentReasoning;
|
|
197
|
+
onDelta?: (delta: AgentChatDelta) => void;
|
|
198
|
+
signal?: AbortSignal;
|
|
199
|
+
}
|
|
200
|
+
export type AgentChatRequest = Omit<AgentChatParams, "onDelta" | "signal">;
|
|
201
|
+
export interface AgentUsage {
|
|
202
|
+
prompt_tokens: number;
|
|
203
|
+
completion_tokens: number;
|
|
204
|
+
total_tokens: number;
|
|
205
|
+
}
|
|
206
|
+
export type AgentChatResult = {
|
|
207
|
+
status: "ok";
|
|
208
|
+
message: AgentMessage;
|
|
209
|
+
reasoning?: string;
|
|
210
|
+
finishReason: string | null;
|
|
211
|
+
usage?: AgentUsage;
|
|
212
|
+
} | {
|
|
213
|
+
status: "unavailable" | "refused" | "error" | "cancelled";
|
|
214
|
+
reason: string;
|
|
215
|
+
};
|
|
216
|
+
export interface DeckSummary {
|
|
217
|
+
deckId: string;
|
|
218
|
+
title: string;
|
|
219
|
+
creator: {
|
|
220
|
+
userId: string;
|
|
221
|
+
username: string;
|
|
222
|
+
} | null;
|
|
223
|
+
coverUrl: string | null;
|
|
224
|
+
}
|
|
225
|
+
export interface DecksListParams {
|
|
226
|
+
limit?: number | null;
|
|
227
|
+
}
|
|
228
|
+
export interface DecksListResult {
|
|
229
|
+
status: "ok";
|
|
230
|
+
decks: DeckSummary[];
|
|
231
|
+
}
|
|
145
232
|
export interface CommandParams {
|
|
233
|
+
"agent.chat": AgentChatRequest;
|
|
234
|
+
"decks.mine": DecksListParams;
|
|
235
|
+
"decks.saved": DecksListParams;
|
|
146
236
|
"deckStorage.load": Record<string, never>;
|
|
147
237
|
"deckStorage.update": {
|
|
148
238
|
updates: StorageUpdate[];
|
|
@@ -279,6 +369,9 @@ export interface CommandParams {
|
|
|
279
369
|
"lifecycle.restoreState": Record<string, never>;
|
|
280
370
|
}
|
|
281
371
|
export interface CommandResult {
|
|
372
|
+
"agent.chat": AgentChatResult;
|
|
373
|
+
"decks.mine": DecksListResult;
|
|
374
|
+
"decks.saved": DecksListResult;
|
|
282
375
|
"deckStorage.load": {
|
|
283
376
|
blob: StorageBlob;
|
|
284
377
|
};
|
|
@@ -365,6 +458,8 @@ export interface UserPayload {
|
|
|
365
458
|
color: string | null;
|
|
366
459
|
}
|
|
367
460
|
export type CommandName = keyof CommandParams;
|
|
461
|
+
export declare const COMMAND_TIMEOUTS_MS: Partial<Record<CommandName, number>>;
|
|
462
|
+
export declare const STREAM_IDLE_TIMEOUT_MS = 60000;
|
|
368
463
|
export interface SerializedCommandError {
|
|
369
464
|
code: string;
|
|
370
465
|
message: string;
|
|
@@ -385,6 +480,17 @@ export interface CommandResponseEnvelope {
|
|
|
385
480
|
error?: SerializedCommandError;
|
|
386
481
|
}
|
|
387
482
|
export declare function isResponseEnvelope(value: unknown): value is CommandResponseEnvelope;
|
|
483
|
+
export interface CommandStreamEnvelope {
|
|
484
|
+
castleSdk: typeof CASTLE_SDK_PROTOCOL;
|
|
485
|
+
requestId: string;
|
|
486
|
+
stream: unknown;
|
|
487
|
+
}
|
|
488
|
+
export declare function isStreamEnvelope(value: unknown): value is CommandStreamEnvelope;
|
|
489
|
+
export interface CommandCancelEnvelope {
|
|
490
|
+
castleSdk: typeof CASTLE_SDK_PROTOCOL;
|
|
491
|
+
requestId: string;
|
|
492
|
+
cancel: true;
|
|
493
|
+
}
|
|
388
494
|
export type LifecycleEvent = "ready" | "unloadInterest";
|
|
389
495
|
export interface LifecycleEnvelope {
|
|
390
496
|
castleSdk: typeof CASTLE_SDK_PROTOCOL;
|
package/dist/commands.js
CHANGED
|
@@ -6,6 +6,18 @@
|
|
|
6
6
|
// Marker + protocol version. Doubles as a discriminator so host messages can't
|
|
7
7
|
// be confused with embed.js `castlexyz:` strings or RN console messages.
|
|
8
8
|
export const CASTLE_SDK_PROTOCOL = 1;
|
|
9
|
+
// How long the deck waits for a reply to these commands, in place of the
|
|
10
|
+
// data-command timeout. A deck list fetches every cover on the list. A host
|
|
11
|
+
// from before streaming reads a whole model reply before answering agent.chat.
|
|
12
|
+
export const COMMAND_TIMEOUTS_MS = {
|
|
13
|
+
"agent.chat": 180_000,
|
|
14
|
+
"decks.mine": 30_000,
|
|
15
|
+
"decks.saved": 30_000,
|
|
16
|
+
};
|
|
17
|
+
// Once a host streams a command, each stream envelope restarts the wait at this
|
|
18
|
+
// length instead. The oracle sends a heartbeat every 15 seconds, and the host
|
|
19
|
+
// passes each one on.
|
|
20
|
+
export const STREAM_IDLE_TIMEOUT_MS = 60_000;
|
|
9
21
|
export function isResponseEnvelope(value) {
|
|
10
22
|
if (typeof value !== "object" || value === null)
|
|
11
23
|
return false;
|
|
@@ -14,6 +26,15 @@ export function isResponseEnvelope(value) {
|
|
|
14
26
|
typeof record.requestId === "string" &&
|
|
15
27
|
typeof record.ok === "boolean");
|
|
16
28
|
}
|
|
29
|
+
export function isStreamEnvelope(value) {
|
|
30
|
+
if (typeof value !== "object" || value === null) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
const record = value;
|
|
34
|
+
return (record.castleSdk === CASTLE_SDK_PROTOCOL &&
|
|
35
|
+
typeof record.requestId === "string" &&
|
|
36
|
+
"stream" in record);
|
|
37
|
+
}
|
|
17
38
|
export function unloadRequestId(value) {
|
|
18
39
|
if (typeof value !== "object" || value === null)
|
|
19
40
|
return null;
|
package/dist/decks.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { DecksListParams, DecksListResult } from "./commands";
|
|
2
|
+
export type { DeckSummary, DecksListParams, DecksListResult } from "./commands";
|
|
3
|
+
export interface CastleDecksApi {
|
|
4
|
+
mine(options?: DecksListParams): Promise<DecksListResult>;
|
|
5
|
+
saved(options?: DecksListParams): Promise<DecksListResult>;
|
|
6
|
+
}
|
|
7
|
+
export declare const Decks: CastleDecksApi;
|
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 });
|
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<
|
|
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
|
|
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
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
378
|
-
const
|
|
379
|
-
if (
|
|
380
|
-
|
|
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
|
-
|
|
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).
|
package/dist/server/storage.d.ts
CHANGED
|
@@ -102,17 +102,101 @@ export interface CompleteCharacterBeatInput {
|
|
|
102
102
|
eventOccurrenceId: string;
|
|
103
103
|
outcomeId: string;
|
|
104
104
|
}
|
|
105
|
+
export interface EvaluateCharacterInformationInput {
|
|
106
|
+
operationId: string;
|
|
107
|
+
maxDeliveries?: number;
|
|
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
|
+
};
|
|
151
|
+
export interface CharacterInformationEvaluationResult {
|
|
152
|
+
generation: number;
|
|
153
|
+
evaluatedAt: string;
|
|
154
|
+
snapshotCandidateCount: number;
|
|
155
|
+
deliveries: Array<{
|
|
156
|
+
ruleId: string;
|
|
157
|
+
rootId: string;
|
|
158
|
+
recipientCharacterId: string;
|
|
159
|
+
status: "delivered" | "already_known";
|
|
160
|
+
}>;
|
|
161
|
+
cancelled: Array<{
|
|
162
|
+
ruleId: string;
|
|
163
|
+
rootId: string;
|
|
164
|
+
}>;
|
|
165
|
+
blocked: Array<{
|
|
166
|
+
ruleId: string;
|
|
167
|
+
rootId: string;
|
|
168
|
+
recipientCharacterId: string;
|
|
169
|
+
code: "testimony_backlog_full" | "information_limit" | "storage_capacity";
|
|
170
|
+
}>;
|
|
171
|
+
remainingCount: number;
|
|
172
|
+
}
|
|
173
|
+
export type CharacterInformationEvaluationOperation = Omit<CharacterOperation, "result"> & {
|
|
174
|
+
result: CharacterInformationEvaluationResult | null;
|
|
175
|
+
};
|
|
176
|
+
export interface SendCharacterInput {
|
|
177
|
+
operationId: string;
|
|
178
|
+
characterId: string;
|
|
179
|
+
input: string;
|
|
180
|
+
audience: {
|
|
181
|
+
listenerCharacterIds: string[];
|
|
182
|
+
};
|
|
183
|
+
}
|
|
105
184
|
/**
|
|
106
185
|
* Narrative commands do not run a model. They record creator-validated events,
|
|
107
186
|
* knowledge delivery and beat completion inside this immutable playthrough.
|
|
108
187
|
*/
|
|
109
188
|
export interface ServerPlaythroughCharacters {
|
|
189
|
+
/** Queues a funded turn whose admitted listeners are fixed at enqueue time. */
|
|
190
|
+
send(input: SendCharacterInput): Promise<CharacterOperation>;
|
|
110
191
|
recordEvent(input: RecordCharacterEventInput): Promise<CharacterOperation>;
|
|
111
192
|
setGameState(input: SetCharacterGameStateInput): Promise<CharacterOperation>;
|
|
112
193
|
shareClaim(input: ShareCharacterClaimInput): Promise<CharacterOperation>;
|
|
113
194
|
inspect(): Promise<Json>;
|
|
114
195
|
deliverKnowledge(input: DeliverCharacterKnowledgeInput): Promise<CharacterOperation>;
|
|
115
196
|
completeBeat(input: CompleteCharacterBeatInput): Promise<CharacterOperation>;
|
|
197
|
+
evaluateInformation(input: EvaluateCharacterInformationInput): Promise<CharacterInformationEvaluationOperation>;
|
|
198
|
+
evaluateBeats(input: EvaluateCharacterBeatsInput): Promise<CharacterBeatEvaluationOperation>;
|
|
199
|
+
completeScheduledBeat(input: CompleteScheduledCharacterBeatInput): Promise<CharacterScheduledBeatCompletionOperation>;
|
|
116
200
|
status(operationId: string): Promise<CharacterOperation>;
|
|
117
201
|
history(options?: ServerCharacterHistoryOptions): Promise<CharacterHistory>;
|
|
118
202
|
}
|
package/dist/server/storage.js
CHANGED
|
@@ -84,6 +84,17 @@ export function createServerStorage(platformHandle) {
|
|
|
84
84
|
}
|
|
85
85
|
function createPlaythrough(call, subject, generation) {
|
|
86
86
|
const characters = Object.freeze({
|
|
87
|
+
async send(input) {
|
|
88
|
+
const characterId = assertNarrativeId(input?.characterId, "characterId");
|
|
89
|
+
return call("characterSendWithAudience", {
|
|
90
|
+
subject,
|
|
91
|
+
generation,
|
|
92
|
+
operationId: assertOperationId(input?.operationId),
|
|
93
|
+
characterId,
|
|
94
|
+
input: assertCharacterInput(input?.input),
|
|
95
|
+
audience: assertCharacterAudience(input?.audience, characterId),
|
|
96
|
+
});
|
|
97
|
+
},
|
|
87
98
|
async recordEvent(input) {
|
|
88
99
|
return call("characterRecordEvent", {
|
|
89
100
|
subject,
|
|
@@ -139,6 +150,34 @@ function createPlaythrough(call, subject, generation) {
|
|
|
139
150
|
outcomeId: assertNarrativeId(input?.outcomeId, "outcomeId"),
|
|
140
151
|
});
|
|
141
152
|
},
|
|
153
|
+
async evaluateInformation(input) {
|
|
154
|
+
const maxDeliveries = assertInformationMaxDeliveries(input?.maxDeliveries);
|
|
155
|
+
return call("characterEvaluateInformation", {
|
|
156
|
+
subject,
|
|
157
|
+
generation,
|
|
158
|
+
operationId: assertOperationId(input?.operationId),
|
|
159
|
+
...(maxDeliveries === undefined ? {} : { maxDeliveries }),
|
|
160
|
+
});
|
|
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
|
+
},
|
|
142
181
|
async status(operationId) {
|
|
143
182
|
return call("characterStatus", {
|
|
144
183
|
subject,
|
|
@@ -323,6 +362,41 @@ function assertOperationId(value) {
|
|
|
323
362
|
}
|
|
324
363
|
return value;
|
|
325
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
|
+
}
|
|
326
400
|
function assertNarrativeId(value, field) {
|
|
327
401
|
if (typeof value !== "string" ||
|
|
328
402
|
value.length === 0 ||
|
|
@@ -351,6 +425,33 @@ function assertNarrativeIds(value, field) {
|
|
|
351
425
|
}
|
|
352
426
|
return value.map((item) => assertNarrativeId(item, field));
|
|
353
427
|
}
|
|
428
|
+
function assertCharacterInput(value) {
|
|
429
|
+
if (typeof value !== "string" ||
|
|
430
|
+
value.length === 0 ||
|
|
431
|
+
value.length > 2000 ||
|
|
432
|
+
value.includes("\0") ||
|
|
433
|
+
hasUnpairedSurrogate(value)) {
|
|
434
|
+
throw characterArgumentError("input must be valid non-empty text of at most 2000 characters.");
|
|
435
|
+
}
|
|
436
|
+
return value;
|
|
437
|
+
}
|
|
438
|
+
function assertCharacterAudience(value, targetCharacterId) {
|
|
439
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
440
|
+
throw characterArgumentError("audience must contain listenerCharacterIds.");
|
|
441
|
+
}
|
|
442
|
+
const audience = value;
|
|
443
|
+
if (Object.keys(audience).some((key) => key !== "listenerCharacterIds") ||
|
|
444
|
+
!Array.isArray(audience.listenerCharacterIds) ||
|
|
445
|
+
audience.listenerCharacterIds.length > 3) {
|
|
446
|
+
throw characterArgumentError("audience must contain at most three listener character ids.");
|
|
447
|
+
}
|
|
448
|
+
const listenerCharacterIds = audience.listenerCharacterIds.map((id) => assertNarrativeId(id, "audience.listenerCharacterIds"));
|
|
449
|
+
if (new Set(listenerCharacterIds).size !== listenerCharacterIds.length ||
|
|
450
|
+
listenerCharacterIds.includes(targetCharacterId)) {
|
|
451
|
+
throw characterArgumentError("audience listeners must be unique and must not include the target character.");
|
|
452
|
+
}
|
|
453
|
+
return { listenerCharacterIds };
|
|
454
|
+
}
|
|
354
455
|
function assertKnowledgeSource(value) {
|
|
355
456
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
356
457
|
throw storageError("CAULDRON_CHARACTER_INVALID_ARGUMENT", "source must name creator_event or a character.", "session.storage.playthrough.characters.deliverKnowledge");
|
|
@@ -402,6 +503,14 @@ function assertStateRevision(value) {
|
|
|
402
503
|
}
|
|
403
504
|
return value;
|
|
404
505
|
}
|
|
506
|
+
function assertInformationMaxDeliveries(value) {
|
|
507
|
+
if (value === undefined)
|
|
508
|
+
return undefined;
|
|
509
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 32) {
|
|
510
|
+
throw characterArgumentError("maxDeliveries must be an integer from 1 through 32.");
|
|
511
|
+
}
|
|
512
|
+
return value;
|
|
513
|
+
}
|
|
405
514
|
function assertStateChanges(value, hasObjectChanges = false) {
|
|
406
515
|
if (!Array.isArray(value) || value.length > 32 || (!hasObjectChanges && value.length < 1)) {
|
|
407
516
|
throw characterArgumentError(hasObjectChanges
|
package/dist/transport.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { type CommandName, type CommandParams, type CommandResult, type LifecycleEvent } 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,7 +15,7 @@ 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;
|
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, } 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
|
|
@@ -20,16 +24,16 @@ const INTERACTIVE_COMMANDS = new Set([
|
|
|
20
24
|
"snapshot.offer",
|
|
21
25
|
"portal.open",
|
|
22
26
|
]);
|
|
27
|
+
// Request ids carry a per-page prefix so that a host serving several decks at
|
|
28
|
+
// once can key a cancel by requestId alone.
|
|
29
|
+
const requestIdPrefix = `csdk_${Math.random().toString(36).slice(2, 10)}_`;
|
|
23
30
|
let nextRequestId = 1;
|
|
24
31
|
const pending = new Map();
|
|
25
32
|
let listenersInstalled = false;
|
|
26
33
|
let unloadHandler = null;
|
|
27
|
-
export async function hostRequest(command, params) {
|
|
34
|
+
export async function hostRequest(command, params, stream = {}) {
|
|
28
35
|
try {
|
|
29
|
-
const
|
|
30
|
-
const env = channel === "local"
|
|
31
|
-
? await sendLocalCommand(command, params)
|
|
32
|
-
: await postCommand(channel, command, params);
|
|
36
|
+
const env = await sendCommand(resolveChannel(), command, params, stream);
|
|
33
37
|
return interpretResponse(command, env);
|
|
34
38
|
}
|
|
35
39
|
catch (error) {
|
|
@@ -103,20 +107,84 @@ function resolveChannel() {
|
|
|
103
107
|
// local dev server (top-level page served by `castle-web serve`).
|
|
104
108
|
return window.parent && window.parent !== window ? "web" : "local";
|
|
105
109
|
}
|
|
106
|
-
function
|
|
107
|
-
|
|
108
|
-
const requestId = `csdk_${nextRequestId++}`;
|
|
110
|
+
function sendCommand(channel, command, params, stream) {
|
|
111
|
+
const requestId = `${requestIdPrefix}${nextRequestId++}`;
|
|
109
112
|
return new Promise((resolve, reject) => {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
if (stream.signal?.aborted) {
|
|
114
|
+
reject(cancelledError(command));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const entry = { command, channel, resolve, reject, onStream: stream.onStream };
|
|
118
|
+
pending.set(requestId, entry);
|
|
119
|
+
restartTimeout(requestId, entry, COMMAND_TIMEOUTS_MS[command] ?? REQUEST_TIMEOUT_MS);
|
|
120
|
+
watchAbort(requestId, entry, stream.signal);
|
|
121
|
+
if (channel === "local") {
|
|
122
|
+
sendLocalCommand(requestId, command, params, receive).catch((error) => {
|
|
123
|
+
forget(requestId);
|
|
124
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
125
|
+
});
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
installHostListener();
|
|
117
129
|
sendEnvelope(channel, { castleSdk: CASTLE_SDK_PROTOCOL, requestId, command, params });
|
|
118
130
|
});
|
|
119
131
|
}
|
|
132
|
+
// Interactive commands have no timeout; they resolve only when the host replies.
|
|
133
|
+
function restartTimeout(requestId, entry, ms) {
|
|
134
|
+
if (entry.timeout) {
|
|
135
|
+
clearTimeout(entry.timeout);
|
|
136
|
+
}
|
|
137
|
+
if (INTERACTIVE_COMMANDS.has(entry.command)) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
entry.timeout = setTimeout(() => {
|
|
141
|
+
forget(requestId);
|
|
142
|
+
entry.reject(new Error(`Castle host did not respond to ${entry.command}.`));
|
|
143
|
+
}, ms);
|
|
144
|
+
}
|
|
145
|
+
function watchAbort(requestId, entry, signal) {
|
|
146
|
+
if (!signal) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const onAbort = () => {
|
|
150
|
+
forget(requestId);
|
|
151
|
+
if (entry.channel === "local") {
|
|
152
|
+
cancelLocalCommand(requestId);
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
const envelope = {
|
|
156
|
+
castleSdk: CASTLE_SDK_PROTOCOL,
|
|
157
|
+
requestId,
|
|
158
|
+
cancel: true,
|
|
159
|
+
};
|
|
160
|
+
sendEnvelope(entry.channel, envelope);
|
|
161
|
+
}
|
|
162
|
+
entry.reject(cancelledError(entry.command));
|
|
163
|
+
};
|
|
164
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
165
|
+
entry.stopWatchingAbort = () => signal.removeEventListener("abort", onAbort);
|
|
166
|
+
}
|
|
167
|
+
function forget(requestId) {
|
|
168
|
+
const entry = pending.get(requestId);
|
|
169
|
+
if (!entry) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
pending.delete(requestId);
|
|
173
|
+
if (entry.timeout) {
|
|
174
|
+
clearTimeout(entry.timeout);
|
|
175
|
+
}
|
|
176
|
+
entry.stopWatchingAbort?.();
|
|
177
|
+
if (entry.channel === "local") {
|
|
178
|
+
releaseLocalCommand(requestId);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function cancelledError(command) {
|
|
182
|
+
return new CastleError({
|
|
183
|
+
code: "CANCELLED",
|
|
184
|
+
message: `${command} was cancelled.`,
|
|
185
|
+
operation: command,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
120
188
|
function sendEnvelope(channel, envelope) {
|
|
121
189
|
const json = JSON.stringify(envelope);
|
|
122
190
|
if (channel === "mobile") {
|
|
@@ -146,21 +214,36 @@ function receive(message) {
|
|
|
146
214
|
settle(message);
|
|
147
215
|
return;
|
|
148
216
|
}
|
|
217
|
+
if (isStreamEnvelope(message)) {
|
|
218
|
+
deliverStream(message.requestId, message.stream);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
149
221
|
const unloadId = unloadRequestId(message);
|
|
150
222
|
if (unloadId !== null)
|
|
151
223
|
unloadHandler?.(unloadId);
|
|
152
224
|
}
|
|
153
225
|
function settle(message) {
|
|
154
|
-
if (!isResponseEnvelope(message))
|
|
155
|
-
return;
|
|
156
226
|
const entry = pending.get(message.requestId);
|
|
157
|
-
if (!entry)
|
|
227
|
+
if (!entry) {
|
|
158
228
|
return;
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
pending.delete(message.requestId);
|
|
229
|
+
}
|
|
230
|
+
forget(message.requestId);
|
|
162
231
|
entry.resolve(message);
|
|
163
232
|
}
|
|
233
|
+
// A throwing onStream is logged, and the stream goes on.
|
|
234
|
+
function deliverStream(requestId, event) {
|
|
235
|
+
const entry = pending.get(requestId);
|
|
236
|
+
if (!entry) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
restartTimeout(requestId, entry, STREAM_IDLE_TIMEOUT_MS);
|
|
240
|
+
try {
|
|
241
|
+
entry.onStream?.(event);
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
console.error(`Castle ${entry.command} stream handler threw:`, error);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
164
247
|
function interpretResponse(command, env) {
|
|
165
248
|
if (env.ok)
|
|
166
249
|
return env.data;
|