castle-web-sdk 0.4.28 → 0.4.29

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 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`,
@@ -716,6 +735,24 @@ if (isEdit()) {
716
735
  }
717
736
  ```
718
737
 
738
+ ### `registerParams(path, params)`
739
+
740
+ Decks never write this call. While `castle-web serve` is running, the
741
+ dev server appends it to every module that declares a `PARAMS` object,
742
+ so a value tuned in the editor's Parameters panel is assigned into that
743
+ object in the deck that is already running instead of restarting it. A
744
+ published deck never gets the call.
745
+
746
+ For a tune to be visible, read a param at the use site rather than
747
+ destructuring it at module load:
748
+
749
+ ```js
750
+ export const PARAMS = { jumpHeight: 4.2 };
751
+
752
+ // reads the tuned value every jump
753
+ function jump() { player.vy = -PARAMS.jumpHeight; }
754
+ ```
755
+
719
756
  ### `writeFile(path, contents): Promise<void>`
720
757
 
721
758
  Writes a file in the deck directory. `path` is relative to the deck
@@ -807,3 +844,5 @@ Common codes:
807
844
  - `CASTLE_HOST_UNAVAILABLE` — the Castle host (the app or website running
808
845
  the deck) didn't handle the request — e.g. it timed out or wasn't
809
846
  reachable. Usually transient; retry or surface a gentle error.
847
+ - `CAULDRON_STORAGE_STALE_GENERATION` — the handle belongs to an older reset
848
+ 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 });
@@ -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
  };
@@ -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,30 @@
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 the module's live `PARAMS`. False when any of them
26
+ * could not land -- an unregistered path, a key the object does not have, a
27
+ * frozen object -- and the rest are still written, because a slot that cannot
28
+ * be applied is no reason to withhold the ones that can.
29
+ */
30
+ export declare function applyTunedParams(path: string, slots: TunedParam[]): boolean;
@@ -0,0 +1,83 @@
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
+ // The object is mutated IN PLACE, never replaced. Deck code holds the same
12
+ // reference (`PARAMS.jumpHeight` at the use site is the documented way to read
13
+ // one), so handing back a new object would leave every reader on the old one.
14
+ const registered = new Map();
15
+ export function registerParams(path, params) {
16
+ if (typeof path !== "string" || !path)
17
+ return;
18
+ if (typeof params !== "object" || params === null)
19
+ return;
20
+ registered.set(path, params);
21
+ }
22
+ // Own properties only, at every level. A `ParamSlot.id` comes from a key the
23
+ // file wrote in an object literal, so an inherited name (`constructor`,
24
+ // `toString`) is never one of them -- and writing through one would add a
25
+ // property to an object that never had it.
26
+ function owns(holder, key) {
27
+ return Object.prototype.hasOwnProperty.call(holder, key);
28
+ }
29
+ function assign(root, id, value) {
30
+ const keys = id.split(".");
31
+ let holder = root;
32
+ for (let i = 0; i < keys.length - 1; i += 1) {
33
+ const next = owns(holder, keys[i]) ? holder[keys[i]] : undefined;
34
+ if (typeof next !== "object" || next === null)
35
+ return false;
36
+ holder = next;
37
+ }
38
+ const leaf = keys[keys.length - 1];
39
+ if (!owns(holder, leaf))
40
+ return false;
41
+ try {
42
+ holder[leaf] = value;
43
+ }
44
+ catch {
45
+ // A frozen PARAMS object. Module code is strict, so the assignment throws
46
+ // rather than failing quietly, and the editor can say the tune did not land.
47
+ return false;
48
+ }
49
+ return true;
50
+ }
51
+ /**
52
+ * What the deck tells the editor about a tuned change it was sent.
53
+ *
54
+ * A separate function because the SHAPE is the contract: the editor holds a
55
+ * run's worth of state on it, and a serve that starts numbering broadcasts
56
+ * while a deck still answers without a number has to read as "not this one"
57
+ * rather than as a match.
58
+ */
59
+ export function appliedNotice(path, seq, ok) {
60
+ return {
61
+ type: "castle-params-applied",
62
+ path,
63
+ seq: typeof seq === "number" && Number.isFinite(seq) ? seq : null,
64
+ ok,
65
+ };
66
+ }
67
+ /**
68
+ * Write tuned values into the module's live `PARAMS`. False when any of them
69
+ * could not land -- an unregistered path, a key the object does not have, a
70
+ * frozen object -- and the rest are still written, because a slot that cannot
71
+ * be applied is no reason to withhold the ones that can.
72
+ */
73
+ export function applyTunedParams(path, slots) {
74
+ const target = registered.get(path);
75
+ if (!target)
76
+ return false;
77
+ let applied = true;
78
+ for (const slot of slots) {
79
+ if (!assign(target, slot.id, slot.after))
80
+ applied = false;
81
+ }
82
+ return applied;
83
+ }
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
  : [];
@@ -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;
@@ -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
- const result = {};
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 = Object.entries(values).map(([key, value]) => ({
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.`, "session.storage.set");
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.
@@ -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.28",
3
+ "version": "0.4.29",
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",