castle-web-sdk 0.4.25 → 0.4.26

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
@@ -10,11 +10,12 @@ there — nothing to install, and no deck `package.json` names it. Import
10
10
  what you need from `castle-web-sdk`:
11
11
 
12
12
  ```js
13
- import { setup, initCard, Storage, Leaderboard } from "castle-web-sdk";
13
+ import { setup, initCard, Store, Leaderboard } from "castle-web-sdk";
14
14
  ```
15
15
 
16
16
  ## Contents
17
17
 
18
+ - [Store](#store)
18
19
  - [Storage](#storage)
19
20
  - [SharedStorage](#sharedstorage)
20
21
  - [Leaderboard](#leaderboard)
@@ -63,8 +64,9 @@ or server `broadcast` to send its raw bytes. Receivers always get those bytes as
63
64
  `Uint8Array`; reconstruct another view type such as `Float32Array` on receipt.
64
65
  Multi-byte values use the sender platform's byte order. Every other payload uses
65
66
  JSON. Client→server messages are capped at 16 KB and server→client messages at
66
- 64 KB, for both JSON and binary frames. A binary frame includes a one-byte protocol
67
- tag. A published server bundle on an older runtime throws
67
+ 64 KB, for both JSON and binary frames. Both caps are on the frame, and a binary
68
+ frame includes a one-byte protocol tag, so the largest payload is one byte under the
69
+ cap. A published server bundle on an older runtime throws
68
70
  `TypeError: This Castle runtime does not support binary multiplayer messages yet.`
69
71
 
70
72
  Server callbacks are the default export from the deck's server entry. They are
@@ -85,71 +87,215 @@ TypeScript server entries can import callback, session, player, and platform
85
87
  handle types from `castle-web-sdk/server`. Server transport has no runtime
86
88
  dependency on a WebSocket package.
87
89
 
88
- ## Storage
90
+ ### `session.storage`
89
91
 
90
- `Storage` saves data for the current player. Nobody else can read it.
91
- Use it for save files, settings, progress.
92
+ A session ends when its last player leaves, and its memory goes with it.
93
+ `session.storage` is what outlives one, and it is the same store the deck's
94
+ clients see through [`Store`](#store):
95
+
96
+ ```js
97
+ export default {
98
+ async onPlayerJoin(session, player) {
99
+ const { wins = 0 } = await session.storage.deck.get(["wins"]);
100
+ session.send(player.playerId, { wins });
101
+ },
102
+
103
+ async onShutdown(session) {
104
+ await session.storage.deck.set({ endedAt: Date.now() });
105
+ },
106
+ };
107
+ ```
108
+
109
+ `session.storage.deck` is shared by everyone playing.
110
+ `session.storage.user(userId)` is one player's public data.
111
+ `session.storage.board(name)` is a leaderboard. Each scope has the same
112
+ `get`, `set`, `remove`, `increment` and `list` as `Store`.
113
+
114
+ Two differences follow from a session server not being a player:
115
+
116
+ - **It owns `deck` scope.** Once a deck has a published server, clients read
117
+ that data but no longer write it, which is what lets you keep authoritative
118
+ state players cannot forge.
119
+ - **It has no self.** There is no `storage.me`, and a user scope or a board
120
+ submission must name the player: `session.storage.user(player.userId)`,
121
+ `session.storage.board("high").submit(player.userId, score)`. A server can
122
+ submit anyone's score, which is what makes a server-run leaderboard worth
123
+ trusting. It can never read `private` storage, not even for its own players.
124
+
125
+ `onShutdown` is awaited, so a save made there does land — but only on a clean
126
+ stop. An out-of-memory kill or a host failing sends no shutdown at all, so
127
+ save as things change and treat `onShutdown` as the last chance, not the plan.
128
+
129
+ ## Store
92
130
 
93
- ### `Storage.get<T>(key): Promise<T | null>`
131
+ `Store` saves data one key at a time. It is the storage to use for
132
+ multiplayer decks: two players writing different keys no longer
133
+ overwrite each other, a counter can be added to safely from several
134
+ places at once, and a deck can hold much more than `Storage` allows and
135
+ read back a slice of it rather than the whole thing.
94
136
 
95
- Returns the value at `key`, or `null` if not set.
137
+ Values can be anything that converts to JSON, the same as `Storage`.
138
+
139
+ Scopes decide who owns a key:
140
+
141
+ - `Store.deck` — shared by everyone playing. If the deck publishes a
142
+ session server, the server owns these keys and players read them.
143
+ - `Store.me` — the current player's data, which other players can read.
144
+ - `Store.user(userId)` — another player's public data. Anyone can read
145
+ it; only that player, or the deck's session server, can write it.
146
+ - `Store.private` — the current player's own data. Nobody else can read
147
+ it, and a session server never sees it.
148
+
149
+ ### `Store.<scope>.get<T>(keys): Promise<Record<string, T>>`
150
+
151
+ Reads several keys at once. Keys with nothing stored are missing from
152
+ the result rather than present as `null`.
96
153
 
97
154
  ```js
98
- const level = (await Storage.get("level")) ?? 1;
155
+ const { level = 1, name } = await Store.deck.get(["level", "name"]);
99
156
  ```
100
157
 
101
- ### `Storage.set(key, value)`
158
+ ### `Store.<scope>.set(values): Promise<void>`
102
159
 
103
- Sets `key` to `value`. `value` must be something that can convert to
104
- JSON (`null`, booleans, finite numbers, strings, arrays, plain
105
- objects). The next `get(key)` returns the new value immediately. Writes
106
- save in the background.
160
+ Writes several keys. Nearby writes are grouped, so saving on every
161
+ change costs one request per burst. The promise resolves once the
162
+ platform has the write, so a deck can tell whether saving worked.
107
163
 
108
164
  ```js
109
- Storage.set("level", 7);
110
- Storage.set("settings", { sound: true, music: false });
165
+ await Store.deck.set({ level: 7, name: "Ada" });
111
166
  ```
112
167
 
113
- ### `Storage.remove(key)`
168
+ ### `Store.<scope>.remove(keys): Promise<void>`
114
169
 
115
- Removes `key`.
170
+ Removes keys.
116
171
 
117
- ## SharedStorage
172
+ ### `Store.<scope>.increment(key, delta?): Promise<number>`
173
+
174
+ Adds `delta` (default `1`) to a number and returns the new total,
175
+ starting the key at `delta` if it was not set. Two players incrementing
176
+ at the same moment both count, which is the thing a read, add and write
177
+ back cannot promise.
118
178
 
119
- `SharedStorage` saves data that other players can read. Values must
120
- be something that can convert to JSON, same as `Storage`.
179
+ ```js
180
+ const plays = await Store.deck.increment("plays");
181
+ ```
182
+
183
+ ### `Store.<scope>.list(options?): Promise<{ entries, cursor }>`
184
+
185
+ Reads a page of keys in order, optionally only those starting with
186
+ `prefix`. Pass the `cursor` from one page to get the next.
187
+
188
+ ```js
189
+ let cursor = null;
190
+ const world = [];
191
+ do {
192
+ const page = await Store.deck.list({ prefix: "block:", cursor });
193
+ world.push(...page.entries);
194
+ cursor = page.cursor;
195
+ } while (cursor);
196
+ ```
121
197
 
122
- Scopes:
198
+ Keep reading until `cursor` is `null`. A page can come back shorter than
199
+ you asked for because it reached its size limit, not because the keys
200
+ ran out, so the cursor is the only reliable end signal.
123
201
 
124
- - `'deck'` one shared bucket for the whole deck. Any player can read
125
- or write.
126
- - `'user'` a per-player public bucket. Any player can read; only the
127
- owning player can write.
202
+ A paged read is not a snapshot: something written while you are paging
203
+ may land in no page or in two. Treat the result as a starting point and
204
+ apply live updates on top, which a multiplayer deck is doing anyway.
128
205
 
129
- ### `SharedStorage.get(scope, key): Promise<T | null>`
206
+ ### `Store.board(name)`
130
207
 
131
- Reads a shared value. For `'user'`, omit the user id to read the
132
- current player's bucket, or pass one to read someone else's:
208
+ A leaderboard, separate from the keys above so that reading the top
209
+ scores stays fast no matter how many players there are.
133
210
 
134
211
  ```js
135
- const worldHighScore = await SharedStorage.get("deck", "highScore");
136
- const myColor = await SharedStorage.get("user", "color");
137
- const theirColor = await SharedStorage.get("user", otherUserId, "color");
212
+ await Store.board("highscores").submit(score);
213
+ const top = await Store.board("highscores").top(10);
214
+ const mine = await Store.board("highscores").get();
138
215
  ```
139
216
 
140
- ### `SharedStorage.set(scope, key, value)`
217
+ - `submit(score)` records the current player's score. A later submission
218
+ replaces an earlier one, so compare first if you only want to keep a
219
+ personal best.
220
+ - `top(limit?)` returns the highest scores, best first.
221
+ - `get(userId?)` returns one player's score, defaulting to the current
222
+ player, or `null` if they have none.
223
+
224
+ A deck's session server can submit any player's score, which is how a
225
+ leaderboard becomes something a player cannot fake. See the multiplayer
226
+ guide.
227
+
228
+ ### Limits
141
229
 
142
- Writes a shared value. `'user'` writes always go to the current
143
- player's bucket. Writes save in the background.
230
+ The platform caps key length, value size, how many keys one call may
231
+ touch, page size, and how much a deck may store in total, and it limits
232
+ how fast a player may read and write. Going over any of them rejects
233
+ with a `CastleError` whose `code` says which, so a deck can tell a
234
+ player it is saving too fast apart from a deck that is out of space.
235
+
236
+ ## Storage
237
+
238
+ > Older API, still supported and not going away. New decks should use
239
+ > [`Store`](#store) instead: it stores each key on its own, so concurrent
240
+ > writes do not overwrite each other, and it holds far more than this does.
241
+ > `Storage` keeps one document per player and rewrites all of it on every
242
+ > change.
243
+
244
+ `Storage` saves data for the current player. Nobody else can read it.
245
+
246
+ - **`Storage.get<T>(key): Promise<T | null>`** — the value at `key`, or
247
+ `null` if it is not set.
248
+ - **`Storage.set(key, value)`** — sets `key`. `value` must be something
249
+ that converts to JSON. The next `get(key)` returns it immediately;
250
+ the write saves in the background.
251
+ - **`Storage.remove(key)`** — removes `key`.
144
252
 
145
253
  ```js
146
- SharedStorage.set("deck", "highScore", 9001);
254
+ const level = (await Storage.get("level")) ?? 1;
255
+ Storage.set("level", 7);
256
+ ```
257
+
258
+ The same thing with `Store`, which is what a new deck should write:
259
+
260
+ ```js
261
+ const { level = 1 } = await Store.private.get(["level"]);
262
+ await Store.private.set({ level: 7 });
263
+ ```
264
+
265
+ ## SharedStorage
266
+
267
+ > Older API, still supported and not going away. New decks should use
268
+ > [`Store`](#store) instead. `SharedStorage` has no ownership rule — any
269
+ > player can overwrite any key in the `'deck'` bucket — and two players
270
+ > writing at the same moment lose one of the writes. `Store` fixes both,
271
+ > and adds counters, listing, and leaderboards.
272
+
273
+ `SharedStorage` saves data that other players can read. Values must be
274
+ something that converts to JSON, same as `Storage`.
275
+
276
+ Scopes: `'deck'` is one bucket for the whole deck, readable and writable
277
+ by any player. `'user'` is a per-player bucket that anyone can read and
278
+ only the owning player can write.
279
+
280
+ - **`SharedStorage.get(scope, key)`** — reads a shared value. For
281
+ `'user'`, pass a user id before the key to read someone else's bucket.
282
+ - **`SharedStorage.set(scope, key, value)`** — writes. `'user'` writes
283
+ always go to the current player.
284
+ - **`SharedStorage.remove(scope, key)`** — removes a shared value.
285
+
286
+ ```js
287
+ const worldHighScore = await SharedStorage.get("deck", "highScore");
288
+ const theirColor = await SharedStorage.get("user", otherUserId, "color");
147
289
  SharedStorage.set("user", "color", "red");
148
290
  ```
149
291
 
150
- ### `SharedStorage.remove(scope, key)`
292
+ The same thing with `Store`:
151
293
 
152
- Removes a shared value.
294
+ ```js
295
+ const { highScore } = await Store.deck.get(["highScore"]);
296
+ const { color } = await Store.user(otherUserId).get(["color"]);
297
+ await Store.me.set({ color: "red" });
298
+ ```
153
299
 
154
300
  ## Leaderboard
155
301
 
package/dist/castle.d.ts CHANGED
@@ -20,6 +20,8 @@ export type { FileChange, FilesChangedEvent } from "./runtime";
20
20
  export { flushSaves, hasPendingSave, onSaveState, writeFile, } from "./saveQueue";
21
21
  export type { SaveState } from "./saveQueue";
22
22
  export { SharedStorage, Storage } from "./storage";
23
+ export { Store } from "./store";
24
+ export type { CastleStoreApi, StoreBoardApi, StoreBoardScore, StoreListEntry, StoreListOptions, StoreListPage, StoreScopeApi, } from "./store";
23
25
  export { Time } from "./time";
24
26
  export type { CastleClockZone, CastleDateParts, CastleTimeApi } from "./time";
25
27
  export type { Json } from "./types";
package/dist/castle.js CHANGED
@@ -12,5 +12,6 @@ export { Portal } from "./portal";
12
12
  export { CARD_RATIO, deleteFile, fileUrl, initCard, onBeforeRestart, onFilesChanged, onSaveReloadState, openFile, renameFile, requestReload, setup, takeReloadState, writeFileOnce, } from "./runtime";
13
13
  export { flushSaves, hasPendingSave, onSaveState, writeFile, } from "./saveQueue";
14
14
  export { SharedStorage, Storage } from "./storage";
15
+ export { Store } from "./store";
15
16
  export { Time } from "./time";
16
17
  export { User } from "./user";
@@ -0,0 +1 @@
1
+ export declare function chunked<T>(items: T[], size: number): T[][];
package/dist/chunk.js ADDED
@@ -0,0 +1,15 @@
1
+ // Both deck-storage APIs send their keys and entries a batch at a time -- the
2
+ // per-key Store from the browser, session storage from a server -- so the split
3
+ // lives here rather than once in each, where the two could drift on where a
4
+ // batch ends.
5
+ export function chunked(items, size) {
6
+ if (items.length === 0)
7
+ return [];
8
+ if (items.length <= size)
9
+ return [items];
10
+ const chunks = [];
11
+ for (let i = 0; i < items.length; i += size) {
12
+ chunks.push(items.slice(i, i + size));
13
+ }
14
+ return chunks;
15
+ }
@@ -2,6 +2,20 @@ import type { Json } from "./types";
2
2
  export declare const CASTLE_SDK_PROTOCOL = 1;
3
3
  export type StorageBlob = Record<string, string>;
4
4
  export type SharedScope = "deck" | "user";
5
+ export type StoreScope = "deck" | "user" | "private";
6
+ export interface StoreEntry {
7
+ key: string;
8
+ value: string;
9
+ }
10
+ export interface StoreUsage {
11
+ rowCount: number;
12
+ byteCount: number;
13
+ }
14
+ export interface StoreBoardEntry {
15
+ subject: string;
16
+ score: number;
17
+ updatedAt: string;
18
+ }
5
19
  export interface StorageUpdate {
6
20
  key: string;
7
21
  value: string | null;
@@ -110,6 +124,53 @@ export interface CommandParams {
110
124
  style: HapticStyle;
111
125
  };
112
126
  "multiplayer.getSession": MultiplayerGetSessionParams;
127
+ "cauldronStorage.get": {
128
+ scope: StoreScope;
129
+ subject?: string | null;
130
+ keys: string[];
131
+ };
132
+ "cauldronStorage.set": {
133
+ scope: StoreScope;
134
+ subject?: string | null;
135
+ entries: StoreEntry[];
136
+ asServer?: boolean;
137
+ unload?: boolean;
138
+ };
139
+ "cauldronStorage.delete": {
140
+ scope: StoreScope;
141
+ subject?: string | null;
142
+ keys: string[];
143
+ asServer?: boolean;
144
+ unload?: boolean;
145
+ };
146
+ "cauldronStorage.increment": {
147
+ scope: StoreScope;
148
+ subject?: string | null;
149
+ key: string;
150
+ delta: number;
151
+ asServer?: boolean;
152
+ };
153
+ "cauldronStorage.list": {
154
+ scope: StoreScope;
155
+ subject?: string | null;
156
+ prefix?: string | null;
157
+ limit?: number | null;
158
+ cursor?: string | null;
159
+ };
160
+ "cauldronStorage.boardSubmit": {
161
+ board: string;
162
+ subject?: string | null;
163
+ score: number;
164
+ asServer?: boolean;
165
+ };
166
+ "cauldronStorage.boardTop": {
167
+ board: string;
168
+ limit?: number | null;
169
+ };
170
+ "cauldronStorage.boardGet": {
171
+ board: string;
172
+ subject?: string | null;
173
+ };
113
174
  }
114
175
  export interface CommandResult {
115
176
  "deckStorage.load": {
@@ -152,6 +213,31 @@ export interface CommandResult {
152
213
  "portal.prefetch": PortalPrefetchResult;
153
214
  "haptics.play": HapticsResult;
154
215
  "multiplayer.getSession": MultiplayerGetSessionResult;
216
+ "cauldronStorage.get": {
217
+ entries: StoreEntry[];
218
+ };
219
+ "cauldronStorage.set": {
220
+ usage: StoreUsage;
221
+ };
222
+ "cauldronStorage.delete": {
223
+ usage: StoreUsage;
224
+ };
225
+ "cauldronStorage.increment": {
226
+ value: string;
227
+ };
228
+ "cauldronStorage.list": {
229
+ entries: StoreEntry[];
230
+ cursor: string | null;
231
+ };
232
+ "cauldronStorage.boardSubmit": {
233
+ entry: StoreBoardEntry;
234
+ };
235
+ "cauldronStorage.boardTop": {
236
+ entries: StoreBoardEntry[];
237
+ };
238
+ "cauldronStorage.boardGet": {
239
+ entry: StoreBoardEntry | null;
240
+ };
155
241
  }
156
242
  export type CommandName = keyof CommandParams;
157
243
  export interface SerializedCommandError {
package/dist/runtime.js CHANGED
@@ -68,27 +68,35 @@ export function fileUrl(path) {
68
68
  }
69
69
  // Move a deck file. `writeFile`'s counterpart for the case where the file's
70
70
  // NAME is the thing changing -- an editor renaming a blueprint has to move the
71
- // file, not write a copy and leave the old one behind. Rejects when the
72
- // destination exists or the source is gone (the serve's own guards), so a
73
- // caller can surface the collision instead of silently clobbering.
71
+ // file, not write a copy and leave the old one behind. Rides the same local
72
+ // socket / shell bridge as `writeFile`: a content iframe cannot POST the HTTP
73
+ // files API (serve capability gate), so rename must not fetch either.
74
+ // Rejects when the destination exists or the source is gone.
74
75
  export async function renameFile(from, to) {
75
- await fileOp("rename", { from, to });
76
+ const response = await sendLocalRequest({
77
+ type: "rename_file",
78
+ from,
79
+ to,
80
+ });
81
+ if (response.ok === false) {
82
+ throw new Error(typeof response.error === "string" && response.error
83
+ ? response.error
84
+ : "castle: rename failed");
85
+ }
76
86
  }
77
87
  // Delete a deck file. Editor UI only, same as `writeFile` -- a published deck
78
- // has no serve to ask.
88
+ // has no serve to ask. Still HTTP for now (shell Files panel); content iframes
89
+ // cannot call it until it grows a bridge message like `rename_file`.
79
90
  export async function deleteFile(path) {
80
- await fileOp("delete", { path });
81
- }
82
- async function fileOp(action, body) {
83
- const res = await fetch(`/__castle/files/${action}`, {
91
+ const res = await fetch("/__castle/files/delete", {
84
92
  method: "POST",
85
93
  headers: { "content-type": "application/json" },
86
- body: JSON.stringify(body),
94
+ body: JSON.stringify({ path }),
87
95
  });
88
96
  if (res.ok)
89
97
  return;
90
98
  const detail = await res.text().catch(() => "");
91
- throw new Error(`castle: ${action} failed (${res.status}) ${detail}`.trim());
99
+ throw new Error(`castle: delete failed (${res.status}) ${detail}`.trim());
92
100
  }
93
101
  // Ask the editor shell to open (or focus) an editor for `path`. This is how a
94
102
  // kit editor hands the creator off to another file -- the creation modal
@@ -840,7 +848,8 @@ function handleLocalMessage(msg) {
840
848
  else if (msg.type === "files_changed") {
841
849
  dispatchFilesChanged(msg);
842
850
  }
843
- else if (msg.type === "write_file_response") {
851
+ else if (msg.type === "write_file_response" ||
852
+ msg.type === "rename_file_response") {
844
853
  resolveLocalRequest(msg);
845
854
  }
846
855
  else if (msg.type === "castle_command_response") {
@@ -24,5 +24,15 @@ export interface PlatformHandle {
24
24
  on(event: "binaryMessage", callback: (connId: string, bytes: Uint8Array) => unknown): void;
25
25
  on(event: "replaced", callback: () => unknown): void;
26
26
  on(event: "shutdown", callback: (reason?: string) => unknown): void;
27
+ /**
28
+ * Deck storage. Strings in and strings out, like the rest of this handle, so a
29
+ * runtime other than the Node shim can implement it without knowing what storage is.
30
+ *
31
+ * Optional because a published bundle may run on a host that predates storage.
32
+ * Rejects with an Error whose `code` is the platform's error code.
33
+ */
34
+ storage?(op: string, argsJson: string): Promise<string>;
35
+ /** False when the host cannot broker storage, so a call can fail at once. */
36
+ readonly storageEnabled?: boolean;
27
37
  readonly config: PlatformSessionConfig;
28
38
  }
@@ -0,0 +1,46 @@
1
+ import type { Json } from "../types";
2
+ import type { PlatformHandle } from "./platformHandle";
3
+ export interface ServerStorageListOptions {
4
+ prefix?: string;
5
+ limit?: number;
6
+ cursor?: string | null;
7
+ }
8
+ export interface ServerStorageListEntry<T extends Json = Json> {
9
+ key: string;
10
+ value: T;
11
+ }
12
+ export interface ServerStorageListPage<T extends Json = Json> {
13
+ entries: ServerStorageListEntry<T>[];
14
+ /** Null means there is nothing left. A short page alone does not mean that. */
15
+ cursor: string | null;
16
+ }
17
+ export interface ServerStorageScope {
18
+ get<T extends Json = Json>(keys: string[]): Promise<Record<string, T>>;
19
+ set(values: Record<string, Json>): Promise<void>;
20
+ remove(keys: string[]): Promise<void>;
21
+ increment(key: string, delta?: number): Promise<number>;
22
+ list<T extends Json = Json>(options?: ServerStorageListOptions): Promise<ServerStorageListPage<T>>;
23
+ }
24
+ export interface ServerStorageBoardScore {
25
+ subject: string;
26
+ score: number;
27
+ updatedAt: string;
28
+ }
29
+ export interface ServerStorageBoard {
30
+ /**
31
+ * Records a score for a player. A client may only submit its own; a server is the
32
+ * authority for its whole session and names whose score this is, which is the
33
+ * difference that makes a leaderboard worth trusting.
34
+ */
35
+ submit(userId: string, score: number): Promise<ServerStorageBoardScore>;
36
+ top(limit?: number): Promise<ServerStorageBoardScore[]>;
37
+ get(userId: string): Promise<ServerStorageBoardScore | null>;
38
+ }
39
+ export interface ServerStorage {
40
+ /** Shared by everyone playing the deck, and owned by this server. */
41
+ readonly deck: ServerStorageScope;
42
+ /** One player's public data. Readable by anyone; writable by them or by this server. */
43
+ user(userId: string): ServerStorageScope;
44
+ board(name: string): ServerStorageBoard;
45
+ }
46
+ export declare function createServerStorage(platformHandle: PlatformHandle): ServerStorage;