castle-web-sdk 0.4.28 → 0.4.30
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 +21 -0
- package/dist/castle.d.ts +4 -0
- package/dist/castle.js +2 -0
- package/dist/characters.d.ts +26 -0
- package/dist/characters.js +108 -0
- package/dist/commands.d.ts +70 -0
- package/dist/multiplayer.js +68 -0
- package/dist/paramsRegistry.d.ts +32 -0
- package/dist/paramsRegistry.js +100 -0
- package/dist/runtime.d.ts +7 -0
- package/dist/runtime.js +34 -0
- package/dist/server/storage.d.ts +77 -0
- package/dist/server/storage.js +272 -18
- package/dist/server/wrapper.d.ts +1 -1
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -52,6 +52,8 @@ reconnects temporary socket drops automatically. `onStateChange` reports
|
|
|
52
52
|
`acquiring`, `connecting`, `connected`, `reconnecting`, and `closed`; its optional
|
|
53
53
|
metadata distinguishes a server `bye`, a replaced server version, an explicit
|
|
54
54
|
`leave`, and connection failures.
|
|
55
|
+
During local development, reloading a Play panel restores that panel's session
|
|
56
|
+
when its dev server is still running. A separate Play panel joins separately.
|
|
55
57
|
|
|
56
58
|
When Castle selects solo fallback, the returned connection has `isSolo === true`,
|
|
57
59
|
a one-player roster, and no server process. Calls to `send` are accepted but do
|
|
@@ -59,6 +61,23 @@ nothing. `soloReason` says why: `private_deck` or `no_server_bundle` are the
|
|
|
59
61
|
creator's to fix (change the deck's visibility, or publish a deck that has a
|
|
60
62
|
server entry); anything else reports `unavailable`. Client→server frames are limited to 16 KB on the wire.
|
|
61
63
|
|
|
64
|
+
### Limits
|
|
65
|
+
|
|
66
|
+
A client may send about 30 messages per second; going past that closes the
|
|
67
|
+
socket, so throttle pose or input sends rather than sending one per animation
|
|
68
|
+
frame.
|
|
69
|
+
|
|
70
|
+
A session may send about `40 + 40 × connected players` frames per second, with
|
|
71
|
+
roughly two seconds of burst and about 1 MiB/s of bytes. `session.send` costs
|
|
72
|
+
one frame per call, `session.broadcast(data)` costs one frame however many
|
|
73
|
+
players are connected, and `session.broadcast(data, { except })` is a loop of
|
|
74
|
+
sends, so it costs one frame per recipient. Frames over the budget are dropped
|
|
75
|
+
silently and never retried — the session keeps running — so broadcast one
|
|
76
|
+
snapshot per tick instead of sending a tailored message per player. Each client
|
|
77
|
+
reads every entry in that snapshot, its own included: the server's copy of its
|
|
78
|
+
hp, score and match state is authoritative, and only its own position stays
|
|
79
|
+
locally predicted.
|
|
80
|
+
|
|
62
81
|
### Binary messages
|
|
63
82
|
|
|
64
83
|
Pass any typed array, `DataView`, or `ArrayBuffer` to client `send`, server `send`,
|
|
@@ -807,3 +826,5 @@ Common codes:
|
|
|
807
826
|
- `CASTLE_HOST_UNAVAILABLE` — the Castle host (the app or website running
|
|
808
827
|
the deck) didn't handle the request — e.g. it timed out or wasn't
|
|
809
828
|
reachable. Usually transient; retry or surface a gentle error.
|
|
829
|
+
- `CAULDRON_STORAGE_STALE_GENERATION` — the handle belongs to an older reset
|
|
830
|
+
generation; reopen explicitly rather than replaying work into the new one.
|
package/dist/castle.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { isEdit } from "./context";
|
|
2
|
+
export { Characters } from "./characters";
|
|
3
|
+
export type { CastleCharactersApi, CharacterHandle, CharacterHistoryOptions, CharacterSendInput, } from "./characters";
|
|
2
4
|
export { CastleError } from "./errors";
|
|
3
5
|
export { deckPrefixOf, IMPORTS_DIR, IMPORTS_PREFIX, isImportedFile, resolveDeckFile, } from "./files";
|
|
4
6
|
export { Haptics } from "./haptics";
|
|
@@ -12,6 +14,8 @@ export type { CastleMultiplayerApi, MultiplayerConnection, MultiplayerConnection
|
|
|
12
14
|
export type { MultiplayerSoloReason } from "./commands";
|
|
13
15
|
export { MAX_CLIENT_FRAME_BYTES, MAX_PLAYERS_CAP } from "./multiplayerProtocol";
|
|
14
16
|
export type { ClientByeFrame, ClientHelloFrame, ClientMessageFrame, ClientPingFrame, ClientPongFrame, ClientReplacedFrame, ClientRosterFrame, ClientToPlatformFrame, ClientToPlatformMessageFrame, MultiplayerPlayer, PlayerIdentity, PlatformToClientFrame, SessionPlayer, SessionType, } from "./multiplayerProtocol";
|
|
17
|
+
export { registerParams } from "./paramsRegistry";
|
|
18
|
+
export type { TunedParam, TunedParamValue } from "./paramsRegistry";
|
|
15
19
|
export { Pass } from "./passes";
|
|
16
20
|
export type { CastlePassApi, PassOfferResult, PassOfferStatus, } from "./passes";
|
|
17
21
|
export { Portal } from "./portal";
|
package/dist/castle.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Castle Web SDK
|
|
2
2
|
export { isEdit } from "./context";
|
|
3
|
+
export { Characters } from "./characters";
|
|
3
4
|
export { CastleError } from "./errors";
|
|
4
5
|
export { deckPrefixOf, IMPORTS_DIR, IMPORTS_PREFIX, isImportedFile, resolveDeckFile, } from "./files";
|
|
5
6
|
export { Haptics } from "./haptics";
|
|
@@ -7,6 +8,7 @@ export { Leaderboard } from "./leaderboard";
|
|
|
7
8
|
export { Lifecycle } from "./lifecycle";
|
|
8
9
|
export { Multiplayer } from "./multiplayer";
|
|
9
10
|
export { MAX_CLIENT_FRAME_BYTES, MAX_PLAYERS_CAP } from "./multiplayerProtocol";
|
|
11
|
+
export { registerParams } from "./paramsRegistry";
|
|
10
12
|
export { Pass } from "./passes";
|
|
11
13
|
export { Portal } from "./portal";
|
|
12
14
|
export { CARD_RATIO, deleteFile, fileUrl, initCard, onBeforeRestart, onFilesChanged, onSaveReloadState, openFile, renameFile, requestReload, setup, takeReloadState, writeFileOnce, } from "./runtime";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { CharacterHistory, CharacterOperation, CharacterPreviewAllowance, CharacterSummary } from "./commands";
|
|
2
|
+
export interface CharacterSendInput {
|
|
3
|
+
operationId: string;
|
|
4
|
+
input: string;
|
|
5
|
+
}
|
|
6
|
+
export interface CharacterHistoryOptions {
|
|
7
|
+
limit?: number;
|
|
8
|
+
cursor?: string | null;
|
|
9
|
+
}
|
|
10
|
+
/** An immutable view of one character in one playthrough generation. */
|
|
11
|
+
export interface CharacterHandle {
|
|
12
|
+
readonly generation: number;
|
|
13
|
+
readonly definitionVersion: string;
|
|
14
|
+
readonly character: CharacterSummary;
|
|
15
|
+
readonly previewAllowance: CharacterPreviewAllowance;
|
|
16
|
+
send(input: CharacterSendInput): Promise<CharacterOperation>;
|
|
17
|
+
status(operationId: string): Promise<CharacterOperation>;
|
|
18
|
+
history(options?: CharacterHistoryOptions): Promise<CharacterHistory>;
|
|
19
|
+
/** Resets the whole deck playthrough and returns a handle in its new generation. */
|
|
20
|
+
reset(operationId: string): Promise<CharacterHandle>;
|
|
21
|
+
}
|
|
22
|
+
export interface CastleCharactersApi {
|
|
23
|
+
open(characterId: string): Promise<CharacterHandle>;
|
|
24
|
+
}
|
|
25
|
+
export declare const Characters: CastleCharactersApi;
|
|
26
|
+
export type { CharacterHistory, CharacterOperation, CharacterOperationStatus, CharacterPreviewAllowance, CharacterSelectedBeat, CharacterSummary, } from "./commands";
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { hostRequest } from "./transport";
|
|
2
|
+
const MAX_ID_BYTES = 128;
|
|
3
|
+
const MAX_CHARACTER_ID_CHARACTERS = 100;
|
|
4
|
+
const MAX_OPERATION_ID_CHARACTERS = 120;
|
|
5
|
+
const MAX_INPUT_CHARACTERS = 2000;
|
|
6
|
+
async function open(characterId) {
|
|
7
|
+
const id = requireId(characterId, "characterId", MAX_CHARACTER_ID_CHARACTERS);
|
|
8
|
+
const admission = await hostRequest("characters.open", { characterId: id });
|
|
9
|
+
return createHandle(id, admission);
|
|
10
|
+
}
|
|
11
|
+
function createHandle(characterId, admission) {
|
|
12
|
+
const character = admission.characters.find((item) => item.id === characterId);
|
|
13
|
+
if (!character) {
|
|
14
|
+
throw new TypeError(`Character ${JSON.stringify(characterId)} is not published by this deck.`);
|
|
15
|
+
}
|
|
16
|
+
const generation = requireGeneration(admission.generation);
|
|
17
|
+
return Object.freeze({
|
|
18
|
+
generation,
|
|
19
|
+
definitionVersion: admission.definitionVersion,
|
|
20
|
+
character: Object.freeze({ ...character }),
|
|
21
|
+
previewAllowance: Object.freeze({ ...admission.previewAllowance }),
|
|
22
|
+
async send(value) {
|
|
23
|
+
if (!value || typeof value !== "object") {
|
|
24
|
+
throw new TypeError("Characters.send requires an operationId and input.");
|
|
25
|
+
}
|
|
26
|
+
const operationId = requireId(value.operationId, "operationId", MAX_OPERATION_ID_CHARACTERS);
|
|
27
|
+
const input = requireInput(value.input);
|
|
28
|
+
const operation = await hostRequest("characters.send", {
|
|
29
|
+
generation,
|
|
30
|
+
characterId,
|
|
31
|
+
operationId,
|
|
32
|
+
input,
|
|
33
|
+
});
|
|
34
|
+
return requireHandleOperation(operation, characterId);
|
|
35
|
+
},
|
|
36
|
+
async status(operationId) {
|
|
37
|
+
const operation = await hostRequest("characters.status", {
|
|
38
|
+
generation,
|
|
39
|
+
operationId: requireId(operationId, "operationId", MAX_OPERATION_ID_CHARACTERS),
|
|
40
|
+
});
|
|
41
|
+
return requireHandleOperation(operation, characterId);
|
|
42
|
+
},
|
|
43
|
+
async history(options = {}) {
|
|
44
|
+
const history = await hostRequest("characters.history", {
|
|
45
|
+
generation,
|
|
46
|
+
characterId,
|
|
47
|
+
limit: optionalLimit(options.limit),
|
|
48
|
+
cursor: options.cursor ?? null,
|
|
49
|
+
});
|
|
50
|
+
for (const operation of history.operations) {
|
|
51
|
+
requireHandleOperation(operation, characterId);
|
|
52
|
+
}
|
|
53
|
+
return history;
|
|
54
|
+
},
|
|
55
|
+
async reset(operationId) {
|
|
56
|
+
const next = await hostRequest("characters.reset", {
|
|
57
|
+
generation,
|
|
58
|
+
characterId,
|
|
59
|
+
operationId: requireId(operationId, "operationId", MAX_OPERATION_ID_CHARACTERS),
|
|
60
|
+
});
|
|
61
|
+
return createHandle(characterId, next);
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function requireGeneration(value) {
|
|
66
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
67
|
+
throw new TypeError("Castle returned an invalid character playthrough generation.");
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
function requireId(value, field, maxCharacters) {
|
|
72
|
+
if (typeof value !== "string" ||
|
|
73
|
+
value.length === 0 ||
|
|
74
|
+
value.length > maxCharacters ||
|
|
75
|
+
value.includes("\0") ||
|
|
76
|
+
byteLength(value) > MAX_ID_BYTES) {
|
|
77
|
+
throw new TypeError(`${field} must be valid text of at most ${maxCharacters} characters and ${MAX_ID_BYTES} bytes.`);
|
|
78
|
+
}
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
function requireInput(value) {
|
|
82
|
+
if (typeof value !== "string" ||
|
|
83
|
+
value.length === 0 ||
|
|
84
|
+
value.length > MAX_INPUT_CHARACTERS ||
|
|
85
|
+
value.includes("\0")) {
|
|
86
|
+
throw new TypeError(`input must be valid non-empty text of at most ${MAX_INPUT_CHARACTERS} characters.`);
|
|
87
|
+
}
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
function requireHandleOperation(operation, characterId) {
|
|
91
|
+
if (operation.characterId === characterId)
|
|
92
|
+
return operation;
|
|
93
|
+
throw new TypeError("Castle returned an operation for a different character.");
|
|
94
|
+
}
|
|
95
|
+
function optionalLimit(value) {
|
|
96
|
+
if (value === undefined || value === null)
|
|
97
|
+
return null;
|
|
98
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 50) {
|
|
99
|
+
throw new TypeError("history limit must be an integer from 1 through 50.");
|
|
100
|
+
}
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
function byteLength(value) {
|
|
104
|
+
if (typeof TextEncoder !== "undefined")
|
|
105
|
+
return new TextEncoder().encode(value).length;
|
|
106
|
+
return value.length;
|
|
107
|
+
}
|
|
108
|
+
export const Characters = Object.freeze({ open });
|
package/dist/commands.d.ts
CHANGED
|
@@ -84,6 +84,47 @@ export interface MultiplayerGetSessionResult {
|
|
|
84
84
|
sessionId: string | null;
|
|
85
85
|
/** Set when `status` is "solo"; null otherwise. */
|
|
86
86
|
soloReason: MultiplayerSoloReason | null;
|
|
87
|
+
/** Internal local-serve scope for restoring this Play panel after a reload. */
|
|
88
|
+
devReconnectScope?: string;
|
|
89
|
+
}
|
|
90
|
+
export interface CharacterSummary {
|
|
91
|
+
id: string;
|
|
92
|
+
version: string;
|
|
93
|
+
name: string;
|
|
94
|
+
}
|
|
95
|
+
export interface CharacterPreviewAllowance {
|
|
96
|
+
utcDay: string;
|
|
97
|
+
allowanceUnits: number;
|
|
98
|
+
usedUnits: number;
|
|
99
|
+
remainingUnits: number;
|
|
100
|
+
}
|
|
101
|
+
export interface CharacterAdmission {
|
|
102
|
+
generation: number;
|
|
103
|
+
definitionVersion: string;
|
|
104
|
+
characters: CharacterSummary[];
|
|
105
|
+
previewAllowance: CharacterPreviewAllowance;
|
|
106
|
+
}
|
|
107
|
+
export interface CharacterSelectedBeat {
|
|
108
|
+
beatId: string;
|
|
109
|
+
eventOccurrenceId: string;
|
|
110
|
+
}
|
|
111
|
+
export type CharacterOperationStatus = "queued" | "running" | "completed" | "failed" | "interrupted";
|
|
112
|
+
/** Sanitized player-visible operation. `result` never contains private lore or traces. */
|
|
113
|
+
export interface CharacterOperation {
|
|
114
|
+
operationId: string;
|
|
115
|
+
characterId: string | null;
|
|
116
|
+
status: CharacterOperationStatus;
|
|
117
|
+
/** A compact terminal replay receipt; the full historical result was retained only recently. */
|
|
118
|
+
archived?: boolean;
|
|
119
|
+
/** Disposable partial reply while running. Replace it with the terminal result. */
|
|
120
|
+
progress?: {
|
|
121
|
+
response: string;
|
|
122
|
+
} | null;
|
|
123
|
+
result: Json | null;
|
|
124
|
+
}
|
|
125
|
+
export interface CharacterHistory {
|
|
126
|
+
operations: CharacterOperation[];
|
|
127
|
+
cursor: string | null;
|
|
87
128
|
}
|
|
88
129
|
export interface CommandParams {
|
|
89
130
|
"deckStorage.load": Record<string, never>;
|
|
@@ -140,6 +181,30 @@ export interface CommandParams {
|
|
|
140
181
|
style: HapticStyle;
|
|
141
182
|
};
|
|
142
183
|
"multiplayer.getSession": MultiplayerGetSessionParams;
|
|
184
|
+
"characters.open": {
|
|
185
|
+
characterId: string;
|
|
186
|
+
};
|
|
187
|
+
"characters.send": {
|
|
188
|
+
generation: number;
|
|
189
|
+
characterId: string;
|
|
190
|
+
operationId: string;
|
|
191
|
+
input: string;
|
|
192
|
+
};
|
|
193
|
+
"characters.status": {
|
|
194
|
+
generation: number;
|
|
195
|
+
operationId: string;
|
|
196
|
+
};
|
|
197
|
+
"characters.history": {
|
|
198
|
+
generation: number;
|
|
199
|
+
characterId: string;
|
|
200
|
+
limit?: number | null;
|
|
201
|
+
cursor?: string | null;
|
|
202
|
+
};
|
|
203
|
+
"characters.reset": {
|
|
204
|
+
generation: number;
|
|
205
|
+
characterId: string;
|
|
206
|
+
operationId: string;
|
|
207
|
+
};
|
|
143
208
|
"cauldronStorage.get": {
|
|
144
209
|
scope: StoreScope;
|
|
145
210
|
subject?: string | null;
|
|
@@ -233,6 +298,11 @@ export interface CommandResult {
|
|
|
233
298
|
"portal.prefetch": PortalPrefetchResult;
|
|
234
299
|
"haptics.play": HapticsResult;
|
|
235
300
|
"multiplayer.getSession": MultiplayerGetSessionResult;
|
|
301
|
+
"characters.open": CharacterAdmission;
|
|
302
|
+
"characters.send": CharacterOperation;
|
|
303
|
+
"characters.status": CharacterOperation;
|
|
304
|
+
"characters.history": CharacterHistory;
|
|
305
|
+
"characters.reset": CharacterAdmission;
|
|
236
306
|
"cauldronStorage.get": {
|
|
237
307
|
entries: StoreEntry[];
|
|
238
308
|
};
|
package/dist/multiplayer.js
CHANGED
|
@@ -5,6 +5,7 @@ const KEEPALIVE_INTERVAL_MS = 25_000;
|
|
|
5
5
|
const HELLO_TIMEOUT_MS = 10_000;
|
|
6
6
|
const RECONNECT_BUDGET_MS = 30_000;
|
|
7
7
|
const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];
|
|
8
|
+
const INVALID_DEV_RECONNECT_CLOSE_CODE = 4003;
|
|
8
9
|
let activeConnection = null;
|
|
9
10
|
export const Multiplayer = { join };
|
|
10
11
|
async function join(options) {
|
|
@@ -28,6 +29,7 @@ class MultiplayerConnectionImpl {
|
|
|
28
29
|
currentSoloReason = null;
|
|
29
30
|
baseUrl = "";
|
|
30
31
|
reconnectToken = "";
|
|
32
|
+
devReconnectStorageKey = null;
|
|
31
33
|
socket = null;
|
|
32
34
|
socketAttempt = 0;
|
|
33
35
|
keepalive = null;
|
|
@@ -120,9 +122,42 @@ class MultiplayerConnectionImpl {
|
|
|
120
122
|
this.solo = false;
|
|
121
123
|
this.currentSoloReason = null;
|
|
122
124
|
this.baseUrl = result.url;
|
|
125
|
+
this.devReconnectStorageKey = result.devReconnectScope
|
|
126
|
+
? `castle-dev-mp-reconnect:${result.url}:${result.sessionId}:${result.devReconnectScope}`
|
|
127
|
+
: null;
|
|
123
128
|
this.transition("connecting");
|
|
129
|
+
const savedToken = this.devReconnectStorageKey
|
|
130
|
+
? readDevReconnectToken(this.devReconnectStorageKey)
|
|
131
|
+
: null;
|
|
132
|
+
if (savedToken && await this.restoreDevReconnect(savedToken))
|
|
133
|
+
return;
|
|
134
|
+
this.assertOpen();
|
|
124
135
|
await this.openSocket("nonce", result.nonce);
|
|
125
136
|
}
|
|
137
|
+
async restoreDevReconnect(token) {
|
|
138
|
+
const startedAt = Date.now();
|
|
139
|
+
let attempt = 0;
|
|
140
|
+
while (true) {
|
|
141
|
+
this.assertOpen();
|
|
142
|
+
try {
|
|
143
|
+
await this.openSocket("reconnect", token);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (error instanceof SocketClosedError &&
|
|
148
|
+
error.code === INVALID_DEV_RECONNECT_CLOSE_CODE) {
|
|
149
|
+
clearDevReconnectToken(this.devReconnectStorageKey, token);
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
// A 503 during a server rebuild, network failure, or hello timeout
|
|
153
|
+
// does not prove the credential expired. Retain it if retries run out.
|
|
154
|
+
if (Date.now() - startedAt >= RECONNECT_BUDGET_MS)
|
|
155
|
+
throw error;
|
|
156
|
+
const delay = RECONNECT_DELAYS_MS[Math.min(attempt++, RECONNECT_DELAYS_MS.length - 1)];
|
|
157
|
+
await pause(delay);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
126
161
|
connectSolo(result) {
|
|
127
162
|
this.solo = true;
|
|
128
163
|
this.currentSoloReason = result.soloReason ?? "unavailable";
|
|
@@ -210,6 +245,8 @@ class MultiplayerConnectionImpl {
|
|
|
210
245
|
this.currentPlayerId = frame.playerId;
|
|
211
246
|
this.currentSessionId = frame.sessionId;
|
|
212
247
|
this.reconnectToken = frame.reconnectToken;
|
|
248
|
+
if (this.devReconnectStorageKey)
|
|
249
|
+
writeDevReconnectToken(this.devReconnectStorageKey, frame.reconnectToken);
|
|
213
250
|
// Re-acquisition is limited to one per outage, not one per session. A phone
|
|
214
251
|
// is backgrounded many times in a play, and each of those is its own outage;
|
|
215
252
|
// without this the second one finds the budget spent and closes for good.
|
|
@@ -342,6 +379,12 @@ class MultiplayerConnectionImpl {
|
|
|
342
379
|
finish(metadata) {
|
|
343
380
|
if (this.currentState === "closed")
|
|
344
381
|
return;
|
|
382
|
+
// A failed recovery can leave the server's reconnect token valid. Keep it
|
|
383
|
+
// for a later page reload; only a deliberate/authoritative end retires it.
|
|
384
|
+
if (this.devReconnectStorageKey &&
|
|
385
|
+
this.reconnectToken &&
|
|
386
|
+
(metadata.type === "left" || metadata.type === "bye" || metadata.type === "replaced"))
|
|
387
|
+
clearDevReconnectToken(this.devReconnectStorageKey, this.reconnectToken);
|
|
345
388
|
this.stopKeepalive();
|
|
346
389
|
const socket = this.socket;
|
|
347
390
|
this.socket = null;
|
|
@@ -419,6 +462,31 @@ function authenticatedUrl(baseUrl, credential, value) {
|
|
|
419
462
|
url.searchParams.set(credential, value);
|
|
420
463
|
return url.toString();
|
|
421
464
|
}
|
|
465
|
+
function readDevReconnectToken(key) {
|
|
466
|
+
try {
|
|
467
|
+
return sessionStorage.getItem(key);
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
function writeDevReconnectToken(key, token) {
|
|
474
|
+
try {
|
|
475
|
+
sessionStorage.setItem(key, token);
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
// Private browsing or disabled storage still has ordinary in-page recovery.
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
function clearDevReconnectToken(key, token) {
|
|
482
|
+
try {
|
|
483
|
+
if (sessionStorage.getItem(key) === token)
|
|
484
|
+
sessionStorage.removeItem(key);
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
// Storage may disappear while the page is closing.
|
|
488
|
+
}
|
|
489
|
+
}
|
|
422
490
|
function parsePlatformFrame(value) {
|
|
423
491
|
if (typeof value !== "string")
|
|
424
492
|
return null;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type TunedParamValue = number | string | boolean;
|
|
2
|
+
export interface TunedParam {
|
|
3
|
+
id: string;
|
|
4
|
+
before: TunedParamValue;
|
|
5
|
+
after: TunedParamValue;
|
|
6
|
+
}
|
|
7
|
+
export declare function registerParams(path: string, params: unknown): void;
|
|
8
|
+
export interface AppliedNotice {
|
|
9
|
+
type: "castle-params-applied";
|
|
10
|
+
path: string;
|
|
11
|
+
/** The broadcast this answers, so a late reply cannot settle a later wait. */
|
|
12
|
+
seq: number | null;
|
|
13
|
+
ok: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* What the deck tells the editor about a tuned change it was sent.
|
|
17
|
+
*
|
|
18
|
+
* A separate function because the SHAPE is the contract: the editor holds a
|
|
19
|
+
* run's worth of state on it, and a serve that starts numbering broadcasts
|
|
20
|
+
* while a deck still answers without a number has to read as "not this one"
|
|
21
|
+
* rather than as a match.
|
|
22
|
+
*/
|
|
23
|
+
export declare function appliedNotice(path: string, seq: unknown, ok: boolean): AppliedNotice;
|
|
24
|
+
/**
|
|
25
|
+
* Write tuned values into every live `PARAMS` registered under the path. False
|
|
26
|
+
* unless every slot landed on every object -- an unregistered path, a key one
|
|
27
|
+
* object does not have, a frozen object -- and the rest are still written,
|
|
28
|
+
* because a slot that cannot be applied is no reason to withhold the ones that
|
|
29
|
+
* can. An older instance missing a key a later code edit added reports false
|
|
30
|
+
* too: the run may be reading it, so the editor offers a restart.
|
|
31
|
+
*/
|
|
32
|
+
export declare function applyTunedParams(path: string, slots: TunedParam[]): boolean;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The live `PARAMS` objects of the deck's modules, so a value tuned in the
|
|
2
|
+
// editor's Parameters panel reaches the run already in progress.
|
|
3
|
+
//
|
|
4
|
+
// Nothing in a deck calls `registerParams`. The dev serve appends the call to
|
|
5
|
+
// every module that declares a `PARAMS` object (cli/src/paramsRegisterPlugin.ts)
|
|
6
|
+
// and a published deck never gets it at all -- there is no editor out there to
|
|
7
|
+
// tune anything. That is the whole reason the registry keys on the deck-relative
|
|
8
|
+
// path rather than on anything the module says about itself: the path is what
|
|
9
|
+
// the serve's broadcast names, and the module never had to agree to anything.
|
|
10
|
+
//
|
|
11
|
+
// A path holds EVERY object ever registered under it, not the latest. The serve
|
|
12
|
+
// appends the call to every transform of the module, including a kit's
|
|
13
|
+
// cache-busting `?t=` re-import, which evaluates a fresh module instance with a
|
|
14
|
+
// fresh `PARAMS` under the same path. An older instance can still be the one
|
|
15
|
+
// running -- physics-2d's play frame keeps the classes it started with and never
|
|
16
|
+
// adopts swapped ones -- and the registry cannot tell which instance a run reads,
|
|
17
|
+
// so the only honest answer is to write the tune into all of them.
|
|
18
|
+
//
|
|
19
|
+
// Each object is mutated IN PLACE, never replaced. Deck code holds the same
|
|
20
|
+
// reference (`PARAMS.jumpHeight` at the use site is the documented way to read
|
|
21
|
+
// one), so handing back a new object would leave every reader on the old one.
|
|
22
|
+
const registered = new Map();
|
|
23
|
+
export function registerParams(path, params) {
|
|
24
|
+
if (typeof path !== "string" || !path)
|
|
25
|
+
return;
|
|
26
|
+
if (typeof params !== "object" || params === null)
|
|
27
|
+
return;
|
|
28
|
+
let instances = registered.get(path);
|
|
29
|
+
if (!instances) {
|
|
30
|
+
instances = new Set();
|
|
31
|
+
registered.set(path, instances);
|
|
32
|
+
}
|
|
33
|
+
instances.add(params);
|
|
34
|
+
}
|
|
35
|
+
// Own properties only, at every level. A `ParamSlot.id` comes from a key the
|
|
36
|
+
// file wrote in an object literal, so an inherited name (`constructor`,
|
|
37
|
+
// `toString`) is never one of them -- and writing through one would add a
|
|
38
|
+
// property to an object that never had it.
|
|
39
|
+
function owns(holder, key) {
|
|
40
|
+
return Object.prototype.hasOwnProperty.call(holder, key);
|
|
41
|
+
}
|
|
42
|
+
function assign(root, id, value) {
|
|
43
|
+
const keys = id.split(".");
|
|
44
|
+
let holder = root;
|
|
45
|
+
for (let i = 0; i < keys.length - 1; i += 1) {
|
|
46
|
+
const next = owns(holder, keys[i]) ? holder[keys[i]] : undefined;
|
|
47
|
+
if (typeof next !== "object" || next === null)
|
|
48
|
+
return false;
|
|
49
|
+
holder = next;
|
|
50
|
+
}
|
|
51
|
+
const leaf = keys[keys.length - 1];
|
|
52
|
+
if (!owns(holder, leaf))
|
|
53
|
+
return false;
|
|
54
|
+
try {
|
|
55
|
+
holder[leaf] = value;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// A frozen PARAMS object. Module code is strict, so the assignment throws
|
|
59
|
+
// rather than failing quietly, and the editor can say the tune did not land.
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* What the deck tells the editor about a tuned change it was sent.
|
|
66
|
+
*
|
|
67
|
+
* A separate function because the SHAPE is the contract: the editor holds a
|
|
68
|
+
* run's worth of state on it, and a serve that starts numbering broadcasts
|
|
69
|
+
* while a deck still answers without a number has to read as "not this one"
|
|
70
|
+
* rather than as a match.
|
|
71
|
+
*/
|
|
72
|
+
export function appliedNotice(path, seq, ok) {
|
|
73
|
+
return {
|
|
74
|
+
type: "castle-params-applied",
|
|
75
|
+
path,
|
|
76
|
+
seq: typeof seq === "number" && Number.isFinite(seq) ? seq : null,
|
|
77
|
+
ok,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Write tuned values into every live `PARAMS` registered under the path. False
|
|
82
|
+
* unless every slot landed on every object -- an unregistered path, a key one
|
|
83
|
+
* object does not have, a frozen object -- and the rest are still written,
|
|
84
|
+
* because a slot that cannot be applied is no reason to withhold the ones that
|
|
85
|
+
* can. An older instance missing a key a later code edit added reports false
|
|
86
|
+
* too: the run may be reading it, so the editor offers a restart.
|
|
87
|
+
*/
|
|
88
|
+
export function applyTunedParams(path, slots) {
|
|
89
|
+
const instances = registered.get(path);
|
|
90
|
+
if (!instances || instances.size === 0)
|
|
91
|
+
return false;
|
|
92
|
+
let applied = true;
|
|
93
|
+
for (const target of instances) {
|
|
94
|
+
for (const slot of slots) {
|
|
95
|
+
if (!assign(target, slot.id, slot.after))
|
|
96
|
+
applied = false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return applied;
|
|
100
|
+
}
|
package/dist/runtime.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type CommandName, type CommandParams, type CommandResponseEnvelope } from "./commands";
|
|
2
|
+
import { type TunedParam } from "./paramsRegistry";
|
|
2
3
|
export declare const CARD_RATIO: number;
|
|
3
4
|
interface LocalResponse {
|
|
4
5
|
type: string;
|
|
@@ -11,6 +12,12 @@ export interface FileChange {
|
|
|
11
12
|
/** Deck-relative POSIX path. */
|
|
12
13
|
path: string;
|
|
13
14
|
event: "add" | "change" | "delete";
|
|
15
|
+
/**
|
|
16
|
+
* Present when the serve verified that nothing but `PARAMS` values changed.
|
|
17
|
+
* Applied to the module's registered object before listeners are told, so a
|
|
18
|
+
* consumer deciding whether to reload sees a deck that is already current.
|
|
19
|
+
*/
|
|
20
|
+
tuned?: TunedParam[];
|
|
14
21
|
/**
|
|
15
22
|
* Deck-relative paths affected via the Vite import graph: the changed file
|
|
16
23
|
* itself (when imported by anything) plus every transitive importer. Empty
|
package/dist/runtime.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CASTLE_SDK_PROTOCOL, } from "./commands";
|
|
2
2
|
import { getCastleEmbed, isEdit } from "./context";
|
|
3
|
+
import { appliedNotice, applyTunedParams, } from "./paramsRegistry";
|
|
3
4
|
export const CARD_RATIO = 5 / 7;
|
|
4
5
|
// Host -> deck capture request, and the deck's answer. Kept in sync with the
|
|
5
6
|
// editor shell (cli/src/shell/coverApi.ts).
|
|
@@ -929,6 +930,30 @@ export function onFilesChanged(listener) {
|
|
|
929
930
|
filesChangedListeners.add(listener);
|
|
930
931
|
return () => filesChangedListeners.delete(listener);
|
|
931
932
|
}
|
|
933
|
+
const isTunedValue = (v) => typeof v === "number" || typeof v === "string" || typeof v === "boolean";
|
|
934
|
+
function tunedSlots(raw) {
|
|
935
|
+
if (!Array.isArray(raw))
|
|
936
|
+
return undefined;
|
|
937
|
+
const slots = [];
|
|
938
|
+
for (const slot of raw) {
|
|
939
|
+
if (typeof slot?.id !== "string")
|
|
940
|
+
return undefined;
|
|
941
|
+
if (!isTunedValue(slot.before) || !isTunedValue(slot.after))
|
|
942
|
+
return undefined;
|
|
943
|
+
slots.push({ id: slot.id, before: slot.before, after: slot.after });
|
|
944
|
+
}
|
|
945
|
+
return slots.length > 0 ? slots : undefined;
|
|
946
|
+
}
|
|
947
|
+
// Answer whether the tune landed, even when nothing was registered under the
|
|
948
|
+
// path. The editor's Play panel waits on this before deciding whether it is
|
|
949
|
+
// showing current files, and a silence it has to time out reads as a stall.
|
|
950
|
+
function reportApplied(path, seq, ok) {
|
|
951
|
+
if (typeof window === "undefined" || window.parent === window)
|
|
952
|
+
return;
|
|
953
|
+
const origin = window
|
|
954
|
+
.__castleShellOrigin;
|
|
955
|
+
window.parent.postMessage(appliedNotice(path, seq, ok), origin || "*");
|
|
956
|
+
}
|
|
932
957
|
function dispatchFilesChanged(msg) {
|
|
933
958
|
const rawChanges = Array.isArray(msg.changes) ? msg.changes : [];
|
|
934
959
|
const changes = [];
|
|
@@ -938,9 +963,11 @@ function dispatchFilesChanged(msg) {
|
|
|
938
963
|
const event = raw.event;
|
|
939
964
|
if (event !== "add" && event !== "change" && event !== "delete")
|
|
940
965
|
continue;
|
|
966
|
+
const tuned = tunedSlots(raw.tuned);
|
|
941
967
|
changes.push({
|
|
942
968
|
path: raw.path,
|
|
943
969
|
event,
|
|
970
|
+
...(tuned ? { tuned } : {}),
|
|
944
971
|
affected: Array.isArray(raw.affected)
|
|
945
972
|
? raw.affected.filter((p) => typeof p === "string")
|
|
946
973
|
: [],
|
|
@@ -948,6 +975,13 @@ function dispatchFilesChanged(msg) {
|
|
|
948
975
|
}
|
|
949
976
|
if (changes.length === 0)
|
|
950
977
|
return;
|
|
978
|
+
// Before the listeners: a consumer asking whether to reload should be looking
|
|
979
|
+
// at a deck the tune has already reached.
|
|
980
|
+
for (const change of changes) {
|
|
981
|
+
if (!change.tuned)
|
|
982
|
+
continue;
|
|
983
|
+
reportApplied(change.path, msg.seq, applyTunedParams(change.path, change.tuned));
|
|
984
|
+
}
|
|
951
985
|
const affected = Array.isArray(msg.affected)
|
|
952
986
|
? msg.affected.filter((p) => typeof p === "string")
|
|
953
987
|
: [];
|
package/dist/server/storage.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Json } from "../types";
|
|
2
|
+
import type { CharacterHistory, CharacterOperation } from "../commands";
|
|
2
3
|
import type { PlatformHandle } from "./platformHandle";
|
|
3
4
|
export interface ServerStorageListOptions {
|
|
4
5
|
prefix?: string;
|
|
@@ -36,11 +37,87 @@ export interface ServerStorageBoard {
|
|
|
36
37
|
top(limit?: number): Promise<ServerStorageBoardScore[]>;
|
|
37
38
|
get(userId: string): Promise<ServerStorageBoardScore | null>;
|
|
38
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Experimental generation-bound game state for one player. A handle never changes
|
|
42
|
+
* generation: reset returns a new handle so work holding the old one stays fenced by
|
|
43
|
+
* the platform instead of silently writing into the new playthrough.
|
|
44
|
+
*/
|
|
45
|
+
export interface ServerPlaythrough {
|
|
46
|
+
readonly generation: number;
|
|
47
|
+
/** Explicit narrative changes for this same player and generation. */
|
|
48
|
+
readonly characters: ServerPlaythroughCharacters;
|
|
49
|
+
get<T extends Json = Json>(keys: string[]): Promise<Record<string, T>>;
|
|
50
|
+
set(values: Record<string, Json>): Promise<void>;
|
|
51
|
+
remove(keys: string[]): Promise<void>;
|
|
52
|
+
reset(operationId: string): Promise<ServerPlaythrough>;
|
|
53
|
+
}
|
|
54
|
+
export interface RecordCharacterEventInput {
|
|
55
|
+
operationId: string;
|
|
56
|
+
eventTypeId: string;
|
|
57
|
+
data?: Record<string, string | number | boolean>;
|
|
58
|
+
}
|
|
59
|
+
export interface SetCharacterGameStateInput {
|
|
60
|
+
operationId: string;
|
|
61
|
+
expectedRevision: number;
|
|
62
|
+
changes: Array<{
|
|
63
|
+
key: string;
|
|
64
|
+
value: string | number | boolean;
|
|
65
|
+
}>;
|
|
66
|
+
}
|
|
67
|
+
export interface ShareCharacterClaimInput {
|
|
68
|
+
operationId: string;
|
|
69
|
+
recipientCharacterId: string;
|
|
70
|
+
claimId: string;
|
|
71
|
+
source: {
|
|
72
|
+
type: "character";
|
|
73
|
+
characterId: string;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export type CharacterKnowledgeSource = {
|
|
77
|
+
type: "creator_event";
|
|
78
|
+
} | {
|
|
79
|
+
type: "character";
|
|
80
|
+
characterId: string;
|
|
81
|
+
};
|
|
82
|
+
export interface DeliverCharacterKnowledgeInput {
|
|
83
|
+
operationId: string;
|
|
84
|
+
recipientCharacterId: string;
|
|
85
|
+
eventOccurrenceId: string;
|
|
86
|
+
claimIds: string[];
|
|
87
|
+
source: CharacterKnowledgeSource;
|
|
88
|
+
}
|
|
89
|
+
export interface CompleteCharacterBeatInput {
|
|
90
|
+
operationId: string;
|
|
91
|
+
characterId: string;
|
|
92
|
+
beatId: string;
|
|
93
|
+
eventOccurrenceId: string;
|
|
94
|
+
outcomeId: string;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Narrative commands do not run a model. They record creator-validated events,
|
|
98
|
+
* knowledge delivery and beat completion inside this immutable playthrough.
|
|
99
|
+
*/
|
|
100
|
+
export interface ServerPlaythroughCharacters {
|
|
101
|
+
recordEvent(input: RecordCharacterEventInput): Promise<CharacterOperation>;
|
|
102
|
+
setGameState(input: SetCharacterGameStateInput): Promise<CharacterOperation>;
|
|
103
|
+
shareClaim(input: ShareCharacterClaimInput): Promise<CharacterOperation>;
|
|
104
|
+
inspect(): Promise<Json>;
|
|
105
|
+
deliverKnowledge(input: DeliverCharacterKnowledgeInput): Promise<CharacterOperation>;
|
|
106
|
+
completeBeat(input: CompleteCharacterBeatInput): Promise<CharacterOperation>;
|
|
107
|
+
status(operationId: string): Promise<CharacterOperation>;
|
|
108
|
+
history(options?: ServerCharacterHistoryOptions): Promise<CharacterHistory>;
|
|
109
|
+
}
|
|
110
|
+
export interface ServerCharacterHistoryOptions {
|
|
111
|
+
limit?: number;
|
|
112
|
+
cursor?: string | null;
|
|
113
|
+
}
|
|
39
114
|
export interface ServerStorage {
|
|
40
115
|
/** Shared by everyone playing the deck, and owned by this server. */
|
|
41
116
|
readonly deck: ServerStorageScope;
|
|
42
117
|
/** One player's public data. Readable by anyone; writable by them or by this server. */
|
|
43
118
|
user(userId: string): ServerStorageScope;
|
|
44
119
|
board(name: string): ServerStorageBoard;
|
|
120
|
+
/** Opens one player's experimental, generation-bound game namespace. */
|
|
121
|
+
playthrough(userId: string): Promise<ServerPlaythrough>;
|
|
45
122
|
}
|
|
46
123
|
export declare function createServerStorage(platformHandle: PlatformHandle): ServerStorage;
|
package/dist/server/storage.js
CHANGED
|
@@ -35,6 +35,14 @@ const REQUEST_OVERHEAD_BYTES = 4 * 1024;
|
|
|
35
35
|
*/
|
|
36
36
|
const MAX_KEY_BYTES = 128;
|
|
37
37
|
const MAX_SUBJECT_BYTES = 256;
|
|
38
|
+
const MAX_OPERATION_ID_BYTES = 128;
|
|
39
|
+
const MAX_OPERATION_ID_CHARACTERS = 120;
|
|
40
|
+
const MAX_NARRATIVE_ID_BYTES = 128;
|
|
41
|
+
const MAX_NARRATIVE_ID_CHARACTERS = 100;
|
|
42
|
+
const MAX_EVENT_OCCURRENCE_ID_BYTES = 256;
|
|
43
|
+
const MAX_EVENT_OCCURRENCE_ID_CHARACTERS = 200;
|
|
44
|
+
const MAX_NARRATIVE_IDS = 16;
|
|
45
|
+
const MAX_GENERATION = 2_147_483_647;
|
|
38
46
|
export function createServerStorage(platformHandle) {
|
|
39
47
|
const call = async (op, args) => {
|
|
40
48
|
if (typeof platformHandle.storage !== "function") {
|
|
@@ -67,8 +75,112 @@ export function createServerStorage(platformHandle) {
|
|
|
67
75
|
board(name) {
|
|
68
76
|
return createBoard(call, name);
|
|
69
77
|
},
|
|
78
|
+
async playthrough(userId) {
|
|
79
|
+
const subject = assertUserId(userId, "session.storage.playthrough");
|
|
80
|
+
const opened = await call("playthroughOpen", { subject });
|
|
81
|
+
return createPlaythrough(call, subject, assertGeneration(opened?.generation));
|
|
82
|
+
},
|
|
70
83
|
};
|
|
71
84
|
}
|
|
85
|
+
function createPlaythrough(call, subject, generation) {
|
|
86
|
+
const characters = Object.freeze({
|
|
87
|
+
async recordEvent(input) {
|
|
88
|
+
return call("characterRecordEvent", {
|
|
89
|
+
subject,
|
|
90
|
+
generation,
|
|
91
|
+
operationId: assertOperationId(input?.operationId),
|
|
92
|
+
eventTypeId: assertNarrativeId(input?.eventTypeId, "eventTypeId"),
|
|
93
|
+
...(input?.data === undefined ? {} : { data: assertEventData(input.data) }),
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
async setGameState(input) {
|
|
97
|
+
return call("characterSetGameState", {
|
|
98
|
+
subject, generation,
|
|
99
|
+
operationId: assertOperationId(input?.operationId),
|
|
100
|
+
expectedRevision: assertStateRevision(input?.expectedRevision),
|
|
101
|
+
changes: assertStateChanges(input?.changes),
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
async shareClaim(input) {
|
|
105
|
+
return call("characterShareClaim", {
|
|
106
|
+
subject, generation,
|
|
107
|
+
operationId: assertOperationId(input?.operationId),
|
|
108
|
+
recipientCharacterId: assertNarrativeId(input?.recipientCharacterId, "recipientCharacterId"),
|
|
109
|
+
claimId: assertNarrativeId(input?.claimId, "claimId"),
|
|
110
|
+
source: assertCharacterSource(input?.source),
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
async inspect() {
|
|
114
|
+
return call("characterInspect", { subject, generation });
|
|
115
|
+
},
|
|
116
|
+
async deliverKnowledge(input) {
|
|
117
|
+
return call("characterDeliverKnowledge", {
|
|
118
|
+
subject,
|
|
119
|
+
generation,
|
|
120
|
+
operationId: assertOperationId(input?.operationId),
|
|
121
|
+
recipientCharacterId: assertNarrativeId(input?.recipientCharacterId, "recipientCharacterId"),
|
|
122
|
+
eventOccurrenceId: assertEventOccurrenceId(input?.eventOccurrenceId, "eventOccurrenceId"),
|
|
123
|
+
claimIds: assertNarrativeIds(input?.claimIds, "claimIds"),
|
|
124
|
+
source: assertKnowledgeSource(input?.source),
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
async completeBeat(input) {
|
|
128
|
+
return call("characterCompleteBeat", {
|
|
129
|
+
subject,
|
|
130
|
+
generation,
|
|
131
|
+
operationId: assertOperationId(input?.operationId),
|
|
132
|
+
characterId: assertNarrativeId(input?.characterId, "characterId"),
|
|
133
|
+
beatId: assertNarrativeId(input?.beatId, "beatId"),
|
|
134
|
+
eventOccurrenceId: assertEventOccurrenceId(input?.eventOccurrenceId, "eventOccurrenceId"),
|
|
135
|
+
outcomeId: assertNarrativeId(input?.outcomeId, "outcomeId"),
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
async status(operationId) {
|
|
139
|
+
return call("characterStatus", {
|
|
140
|
+
subject,
|
|
141
|
+
generation,
|
|
142
|
+
operationId: assertOperationId(operationId),
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
async history(options = {}) {
|
|
146
|
+
return call("characterHistory", {
|
|
147
|
+
subject,
|
|
148
|
+
generation,
|
|
149
|
+
limit: assertHistoryLimit(options.limit),
|
|
150
|
+
cursor: options.cursor === undefined || options.cursor === null
|
|
151
|
+
? null
|
|
152
|
+
: assertNarrativeId(options.cursor, "cursor"),
|
|
153
|
+
});
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
return Object.freeze({
|
|
157
|
+
generation,
|
|
158
|
+
characters,
|
|
159
|
+
async get(keys) {
|
|
160
|
+
const entries = await call("playthroughGet", { subject, generation, keys: assertKeys(keys, "session.storage.playthrough.get") });
|
|
161
|
+
return decodeEntries(entries, "session.storage.playthrough.get");
|
|
162
|
+
},
|
|
163
|
+
async set(values) {
|
|
164
|
+
const entries = encodeEntries(values, "session.storage.playthrough.set");
|
|
165
|
+
for (const chunk of batched(entries, "session.storage.playthrough.set")) {
|
|
166
|
+
await call("playthroughSet", { subject, generation, entries: chunk });
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
async remove(keys) {
|
|
170
|
+
for (const chunk of chunked(assertKeys(keys, "session.storage.playthrough.remove"), MAX_KEYS_PER_CALL)) {
|
|
171
|
+
await call("playthroughDelete", { subject, generation, keys: chunk });
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
async reset(operationId) {
|
|
175
|
+
const reset = await call("playthroughReset", {
|
|
176
|
+
subject,
|
|
177
|
+
generation,
|
|
178
|
+
operationId: assertOperationId(operationId),
|
|
179
|
+
});
|
|
180
|
+
return createPlaythrough(call, subject, assertGeneration(reset?.generation));
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
72
184
|
function createScope(call, scope, subject) {
|
|
73
185
|
return {
|
|
74
186
|
async get(keys) {
|
|
@@ -79,24 +191,10 @@ function createScope(call, scope, subject) {
|
|
|
79
191
|
subject,
|
|
80
192
|
keys: assertKeys(keys, "session.storage.get"),
|
|
81
193
|
});
|
|
82
|
-
|
|
83
|
-
for (const entry of entries ?? []) {
|
|
84
|
-
// defineProperty, not assignment: a stored key named "__proto__" would
|
|
85
|
-
// otherwise reach the prototype setter rather than becoming an own property.
|
|
86
|
-
Object.defineProperty(result, entry.key, {
|
|
87
|
-
value: decodeStorageValue(entry.value, "session.storage.get"),
|
|
88
|
-
writable: true,
|
|
89
|
-
enumerable: true,
|
|
90
|
-
configurable: true,
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
return result;
|
|
194
|
+
return decodeEntries(entries, "session.storage.get");
|
|
94
195
|
},
|
|
95
196
|
async set(values) {
|
|
96
|
-
const entries =
|
|
97
|
-
key,
|
|
98
|
-
value: encodeStorageValue(value, "session.storage.set"),
|
|
99
|
-
}));
|
|
197
|
+
const entries = encodeEntries(values, "session.storage.set");
|
|
100
198
|
for (const chunk of batched(entries)) {
|
|
101
199
|
await call("set", { scope, subject, entries: chunk });
|
|
102
200
|
}
|
|
@@ -182,7 +280,7 @@ function assertUserId(userId, operation) {
|
|
|
182
280
|
* Splits entries into requests that fit both limits: the platform's key count and the
|
|
183
281
|
* channel's frame size.
|
|
184
282
|
*/
|
|
185
|
-
function batched(entries) {
|
|
283
|
+
function batched(entries, operation = "session.storage.set") {
|
|
186
284
|
const budget = MAX_REQUEST_BYTES - REQUEST_OVERHEAD_BYTES;
|
|
187
285
|
const batches = [];
|
|
188
286
|
let current = [];
|
|
@@ -190,7 +288,7 @@ function batched(entries) {
|
|
|
190
288
|
for (const entry of entries) {
|
|
191
289
|
const bytes = entryBytes(entry);
|
|
192
290
|
if (bytes > budget) {
|
|
193
|
-
throw storageError("CAULDRON_STORAGE_INVALID_VALUE", `A single stored value must be under ${budget} bytes when written from a session server.`,
|
|
291
|
+
throw storageError("CAULDRON_STORAGE_INVALID_VALUE", `A single stored value must be under ${budget} bytes when written from a session server.`, operation);
|
|
194
292
|
}
|
|
195
293
|
if (current.length === MAX_KEYS_PER_CALL || currentBytes + bytes > budget) {
|
|
196
294
|
batches.push(current);
|
|
@@ -204,6 +302,162 @@ function batched(entries) {
|
|
|
204
302
|
batches.push(current);
|
|
205
303
|
return batches;
|
|
206
304
|
}
|
|
305
|
+
function assertGeneration(value) {
|
|
306
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > MAX_GENERATION) {
|
|
307
|
+
throw storageError("CAULDRON_STORAGE_INVALID_RESPONSE", "The platform returned an invalid playthrough generation.", "session.storage.playthrough");
|
|
308
|
+
}
|
|
309
|
+
return value;
|
|
310
|
+
}
|
|
311
|
+
function assertOperationId(value) {
|
|
312
|
+
if (typeof value !== "string" ||
|
|
313
|
+
value.length === 0 ||
|
|
314
|
+
value.length > MAX_OPERATION_ID_CHARACTERS ||
|
|
315
|
+
value.includes("\0") ||
|
|
316
|
+
hasUnpairedSurrogate(value) ||
|
|
317
|
+
utf8Length(value) > MAX_OPERATION_ID_BYTES) {
|
|
318
|
+
throw storageError("CAULDRON_STORAGE_INVALID_ARGUMENT", `An operation id must be valid text of at most ${MAX_OPERATION_ID_CHARACTERS} characters and ${MAX_OPERATION_ID_BYTES} bytes.`, "session.storage.playthrough.reset");
|
|
319
|
+
}
|
|
320
|
+
return value;
|
|
321
|
+
}
|
|
322
|
+
function assertNarrativeId(value, field) {
|
|
323
|
+
if (typeof value !== "string" ||
|
|
324
|
+
value.length === 0 ||
|
|
325
|
+
value.length > MAX_NARRATIVE_ID_CHARACTERS ||
|
|
326
|
+
value.includes("\0") ||
|
|
327
|
+
hasUnpairedSurrogate(value) ||
|
|
328
|
+
utf8Length(value) > MAX_NARRATIVE_ID_BYTES) {
|
|
329
|
+
throw storageError("CAULDRON_CHARACTER_INVALID_ARGUMENT", `${field} must be valid text of at most ${MAX_NARRATIVE_ID_CHARACTERS} characters and ${MAX_NARRATIVE_ID_BYTES} bytes.`, `session.storage.playthrough.characters.${field}`);
|
|
330
|
+
}
|
|
331
|
+
return value;
|
|
332
|
+
}
|
|
333
|
+
function assertEventOccurrenceId(value, field) {
|
|
334
|
+
if (typeof value !== "string" ||
|
|
335
|
+
value.length === 0 ||
|
|
336
|
+
value.length > MAX_EVENT_OCCURRENCE_ID_CHARACTERS ||
|
|
337
|
+
value.includes("\0") ||
|
|
338
|
+
hasUnpairedSurrogate(value) ||
|
|
339
|
+
utf8Length(value) > MAX_EVENT_OCCURRENCE_ID_BYTES) {
|
|
340
|
+
throw storageError("CAULDRON_CHARACTER_INVALID_ARGUMENT", `${field} must be valid text of at most ${MAX_EVENT_OCCURRENCE_ID_CHARACTERS} characters and ${MAX_EVENT_OCCURRENCE_ID_BYTES} bytes.`, `session.storage.playthrough.characters.${field}`);
|
|
341
|
+
}
|
|
342
|
+
return value;
|
|
343
|
+
}
|
|
344
|
+
function assertNarrativeIds(value, field) {
|
|
345
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_NARRATIVE_IDS) {
|
|
346
|
+
throw storageError("CAULDRON_CHARACTER_INVALID_ARGUMENT", `${field} must contain between 1 and ${MAX_NARRATIVE_IDS} ids.`, `session.storage.playthrough.characters.${field}`);
|
|
347
|
+
}
|
|
348
|
+
return value.map((item) => assertNarrativeId(item, field));
|
|
349
|
+
}
|
|
350
|
+
function assertKnowledgeSource(value) {
|
|
351
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
352
|
+
throw storageError("CAULDRON_CHARACTER_INVALID_ARGUMENT", "source must name creator_event or a character.", "session.storage.playthrough.characters.deliverKnowledge");
|
|
353
|
+
}
|
|
354
|
+
const source = value;
|
|
355
|
+
if (source.type === "creator_event")
|
|
356
|
+
return { type: "creator_event" };
|
|
357
|
+
if (source.type === "character") {
|
|
358
|
+
return {
|
|
359
|
+
type: "character",
|
|
360
|
+
characterId: assertNarrativeId(source.characterId, "source.characterId"),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
throw storageError("CAULDRON_CHARACTER_INVALID_ARGUMENT", "source must name creator_event or a character.", "session.storage.playthrough.characters.deliverKnowledge");
|
|
364
|
+
}
|
|
365
|
+
function characterArgumentError(message) {
|
|
366
|
+
return storageError("CAULDRON_CHARACTER_INVALID_ARGUMENT", message, "session.storage.playthrough.characters");
|
|
367
|
+
}
|
|
368
|
+
function assertCharacterSource(value) {
|
|
369
|
+
if (!value || typeof value !== "object" || Array.isArray(value) ||
|
|
370
|
+
value.type !== "character") {
|
|
371
|
+
throw characterArgumentError("source must name a character.");
|
|
372
|
+
}
|
|
373
|
+
return { type: "character", characterId: assertNarrativeId(value.characterId, "source.characterId") };
|
|
374
|
+
}
|
|
375
|
+
function assertScalar(value) {
|
|
376
|
+
return typeof value === "boolean" ||
|
|
377
|
+
(typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= 1_000_000) ||
|
|
378
|
+
(typeof value === "string" && value.length <= 300 && !value.includes("\0") &&
|
|
379
|
+
!hasUnpairedSurrogate(value) && utf8Length(value) <= 1200);
|
|
380
|
+
}
|
|
381
|
+
function assertEventData(value) {
|
|
382
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).length > 8) {
|
|
383
|
+
throw characterArgumentError("event data must be an object of at most eight scalar fields.");
|
|
384
|
+
}
|
|
385
|
+
const result = Object.create(null);
|
|
386
|
+
for (const [key, field] of Object.entries(value)) {
|
|
387
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype" ||
|
|
388
|
+
!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key) || !assertScalar(field)) {
|
|
389
|
+
throw characterArgumentError("event data has an invalid field.");
|
|
390
|
+
}
|
|
391
|
+
result[key] = field;
|
|
392
|
+
}
|
|
393
|
+
return result;
|
|
394
|
+
}
|
|
395
|
+
function assertStateRevision(value) {
|
|
396
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
397
|
+
throw characterArgumentError("expectedRevision must be a nonnegative integer.");
|
|
398
|
+
}
|
|
399
|
+
return value;
|
|
400
|
+
}
|
|
401
|
+
function assertStateChanges(value) {
|
|
402
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 32) {
|
|
403
|
+
throw characterArgumentError("changes must contain one to 32 fields.");
|
|
404
|
+
}
|
|
405
|
+
const seen = new Set();
|
|
406
|
+
return value.map((change) => {
|
|
407
|
+
if (!change || typeof change !== "object" || Array.isArray(change)) {
|
|
408
|
+
throw characterArgumentError("state change must contain a key and scalar value.");
|
|
409
|
+
}
|
|
410
|
+
const { key, value: field } = change;
|
|
411
|
+
if (typeof key !== "string" || !/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key) ||
|
|
412
|
+
key === "constructor" || key === "prototype" || seen.has(key) || !assertScalar(field)) {
|
|
413
|
+
throw characterArgumentError("state change has an invalid or duplicate field.");
|
|
414
|
+
}
|
|
415
|
+
seen.add(key);
|
|
416
|
+
return { key, value: field };
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
function assertHistoryLimit(value) {
|
|
420
|
+
if (value === undefined || value === null)
|
|
421
|
+
return null;
|
|
422
|
+
if (Number.isSafeInteger(value) && value >= 1 && value <= 50) {
|
|
423
|
+
return value;
|
|
424
|
+
}
|
|
425
|
+
throw storageError("CAULDRON_CHARACTER_INVALID_ARGUMENT", "history limit must be an integer from 1 through 50.", "session.storage.playthrough.characters.history");
|
|
426
|
+
}
|
|
427
|
+
function decodeEntries(entries, operation) {
|
|
428
|
+
const result = {};
|
|
429
|
+
for (const entry of entries ?? []) {
|
|
430
|
+
// defineProperty avoids invoking the prototype setter for a stored "__proto__" key.
|
|
431
|
+
Object.defineProperty(result, entry.key, {
|
|
432
|
+
value: decodeStorageValue(entry.value, operation),
|
|
433
|
+
writable: true,
|
|
434
|
+
enumerable: true,
|
|
435
|
+
configurable: true,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
return result;
|
|
439
|
+
}
|
|
440
|
+
function encodeEntries(values, operation) {
|
|
441
|
+
return Object.entries(values).map(([key, value]) => ({
|
|
442
|
+
key,
|
|
443
|
+
value: encodeStorageValue(value, operation),
|
|
444
|
+
}));
|
|
445
|
+
}
|
|
446
|
+
function hasUnpairedSurrogate(value) {
|
|
447
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
448
|
+
const code = value.charCodeAt(index);
|
|
449
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
450
|
+
const next = value.charCodeAt(index + 1);
|
|
451
|
+
if (!(next >= 0xdc00 && next <= 0xdfff))
|
|
452
|
+
return true;
|
|
453
|
+
index += 1;
|
|
454
|
+
}
|
|
455
|
+
else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
207
461
|
function entryBytes(entry) {
|
|
208
462
|
// The ENCODED size, not the string length. `value` is already JSON, so putting it
|
|
209
463
|
// in the request escapes it a second time -- every quote and backslash doubles.
|
package/dist/server/wrapper.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { MultiplayerSessionMode, PlatformHandle, PlayerIdentity } from "./platformHandle";
|
|
2
2
|
import { type ServerStorage } from "./storage";
|
|
3
3
|
export type { MultiplayerSessionMode, PlatformHandle, PlatformSessionConfig, PlayerIdentity, } from "./platformHandle";
|
|
4
|
-
export type { ServerStorage, ServerStorageBoard, ServerStorageBoardScore, ServerStorageListEntry, ServerStorageListOptions, ServerStorageListPage, ServerStorageScope, } from "./storage";
|
|
4
|
+
export type { CharacterKnowledgeSource, CompleteCharacterBeatInput, DeliverCharacterKnowledgeInput, RecordCharacterEventInput, SetCharacterGameStateInput, ShareCharacterClaimInput, ServerStorage, ServerStorageBoard, ServerStorageBoardScore, ServerStorageListEntry, ServerStorageListOptions, ServerStorageListPage, ServerPlaythrough, ServerPlaythroughCharacters, ServerCharacterHistoryOptions, ServerStorageScope, } from "./storage";
|
|
5
5
|
export interface MultiplayerServerPlayer extends PlayerIdentity {
|
|
6
6
|
playerId: string;
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "castle-web-sdk",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.30",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/castle-xyz/castle-experimental-web.git"
|
|
7
|
+
},
|
|
4
8
|
"type": "module",
|
|
5
9
|
"main": "dist/castle.js",
|
|
6
10
|
"types": "dist/castle.d.ts",
|