castle-web-sdk 0.4.25 → 0.4.27
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 +253 -40
- package/dist/castle.d.ts +3 -0
- package/dist/castle.js +1 -0
- package/dist/chunk.d.ts +1 -0
- package/dist/chunk.js +15 -0
- package/dist/commands.d.ts +116 -1
- package/dist/commands.js +9 -0
- package/dist/lifecycle.d.ts +20 -0
- package/dist/lifecycle.js +7 -1
- package/dist/multiplayer.d.ts +6 -0
- package/dist/multiplayer.js +6 -0
- package/dist/resumeState.d.ts +2 -0
- package/dist/resumeState.js +84 -0
- package/dist/runtime.js +86 -12
- package/dist/server/platformHandle.d.ts +10 -0
- package/dist/server/storage.d.ts +46 -0
- package/dist/server/storage.js +218 -0
- package/dist/server/wrapper.d.ts +14 -1
- package/dist/server/wrapper.js +4 -1
- package/dist/storage.d.ts +15 -0
- package/dist/storage.js +29 -86
- package/dist/storageJson.d.ts +5 -0
- package/dist/storageJson.js +69 -0
- package/dist/store.d.ts +67 -0
- package/dist/store.js +313 -0
- package/dist/transport.d.ts +3 -0
- package/dist/transport.js +39 -5
- package/dist/unloadFlush.d.ts +3 -0
- package/dist/unloadFlush.js +30 -0
- package/dist/unloadHandshake.d.ts +6 -0
- package/dist/unloadHandshake.js +47 -0
- package/package.json +1 -1
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,
|
|
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)
|
|
@@ -54,7 +55,9 @@ metadata distinguishes a server `bye`, a replaced server version, an explicit
|
|
|
54
55
|
|
|
55
56
|
When Castle selects solo fallback, the returned connection has `isSolo === true`,
|
|
56
57
|
a one-player roster, and no server process. Calls to `send` are accepted but do
|
|
57
|
-
nothing.
|
|
58
|
+
nothing. `soloReason` says why: `private_deck` or `no_server_bundle` are the
|
|
59
|
+
creator's to fix (change the deck's visibility, or publish a deck that has a
|
|
60
|
+
server entry); anything else reports `unavailable`. Client→server frames are limited to 16 KB on the wire.
|
|
58
61
|
|
|
59
62
|
### Binary messages
|
|
60
63
|
|
|
@@ -63,8 +66,9 @@ or server `broadcast` to send its raw bytes. Receivers always get those bytes as
|
|
|
63
66
|
`Uint8Array`; reconstruct another view type such as `Float32Array` on receipt.
|
|
64
67
|
Multi-byte values use the sender platform's byte order. Every other payload uses
|
|
65
68
|
JSON. Client→server messages are capped at 16 KB and server→client messages at
|
|
66
|
-
64 KB, for both JSON and binary frames.
|
|
67
|
-
tag
|
|
69
|
+
64 KB, for both JSON and binary frames. Both caps are on the frame, and a binary
|
|
70
|
+
frame includes a one-byte protocol tag, so the largest payload is one byte under the
|
|
71
|
+
cap. A published server bundle on an older runtime throws
|
|
68
72
|
`TypeError: This Castle runtime does not support binary multiplayer messages yet.`
|
|
69
73
|
|
|
70
74
|
Server callbacks are the default export from the deck's server entry. They are
|
|
@@ -85,71 +89,215 @@ TypeScript server entries can import callback, session, player, and platform
|
|
|
85
89
|
handle types from `castle-web-sdk/server`. Server transport has no runtime
|
|
86
90
|
dependency on a WebSocket package.
|
|
87
91
|
|
|
88
|
-
|
|
92
|
+
### `session.storage`
|
|
89
93
|
|
|
90
|
-
|
|
91
|
-
|
|
94
|
+
A session ends when its last player leaves, and its memory goes with it.
|
|
95
|
+
`session.storage` is what outlives one, and it is the same store the deck's
|
|
96
|
+
clients see through [`Store`](#store):
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
export default {
|
|
100
|
+
async onPlayerJoin(session, player) {
|
|
101
|
+
const { wins = 0 } = await session.storage.deck.get(["wins"]);
|
|
102
|
+
session.send(player.playerId, { wins });
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
async onShutdown(session) {
|
|
106
|
+
await session.storage.deck.set({ endedAt: Date.now() });
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
```
|
|
92
110
|
|
|
93
|
-
|
|
111
|
+
`session.storage.deck` is shared by everyone playing.
|
|
112
|
+
`session.storage.user(userId)` is one player's public data.
|
|
113
|
+
`session.storage.board(name)` is a leaderboard. Each scope has the same
|
|
114
|
+
`get`, `set`, `remove`, `increment` and `list` as `Store`.
|
|
94
115
|
|
|
95
|
-
|
|
116
|
+
Two differences follow from a session server not being a player:
|
|
117
|
+
|
|
118
|
+
- **It owns `deck` scope.** Once a deck has a published server, clients read
|
|
119
|
+
that data but no longer write it, which is what lets you keep authoritative
|
|
120
|
+
state players cannot forge.
|
|
121
|
+
- **It has no self.** There is no `storage.me`, and a user scope or a board
|
|
122
|
+
submission must name the player: `session.storage.user(player.userId)`,
|
|
123
|
+
`session.storage.board("high").submit(player.userId, score)`. A server can
|
|
124
|
+
submit anyone's score, which is what makes a server-run leaderboard worth
|
|
125
|
+
trusting. It can never read `private` storage, not even for its own players.
|
|
126
|
+
|
|
127
|
+
`onShutdown` is awaited, so a save made there does land — but only on a clean
|
|
128
|
+
stop. An out-of-memory kill or a host failing sends no shutdown at all, so
|
|
129
|
+
save as things change and treat `onShutdown` as the last chance, not the plan.
|
|
130
|
+
|
|
131
|
+
## Store
|
|
132
|
+
|
|
133
|
+
`Store` saves data one key at a time. It is the storage to use for
|
|
134
|
+
multiplayer decks: two players writing different keys no longer
|
|
135
|
+
overwrite each other, a counter can be added to safely from several
|
|
136
|
+
places at once, and a deck can hold much more than `Storage` allows and
|
|
137
|
+
read back a slice of it rather than the whole thing.
|
|
138
|
+
|
|
139
|
+
Values can be anything that converts to JSON, the same as `Storage`.
|
|
140
|
+
|
|
141
|
+
Scopes decide who owns a key:
|
|
142
|
+
|
|
143
|
+
- `Store.deck` — shared by everyone playing. If the deck publishes a
|
|
144
|
+
session server, the server owns these keys and players read them.
|
|
145
|
+
- `Store.me` — the current player's data, which other players can read.
|
|
146
|
+
- `Store.user(userId)` — another player's public data. Anyone can read
|
|
147
|
+
it; only that player, or the deck's session server, can write it.
|
|
148
|
+
- `Store.private` — the current player's own data. Nobody else can read
|
|
149
|
+
it, and a session server never sees it.
|
|
150
|
+
|
|
151
|
+
### `Store.<scope>.get<T>(keys): Promise<Record<string, T>>`
|
|
152
|
+
|
|
153
|
+
Reads several keys at once. Keys with nothing stored are missing from
|
|
154
|
+
the result rather than present as `null`.
|
|
96
155
|
|
|
97
156
|
```js
|
|
98
|
-
const level =
|
|
157
|
+
const { level = 1, name } = await Store.deck.get(["level", "name"]);
|
|
99
158
|
```
|
|
100
159
|
|
|
101
|
-
### `
|
|
160
|
+
### `Store.<scope>.set(values): Promise<void>`
|
|
102
161
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
save in the background.
|
|
162
|
+
Writes several keys. Nearby writes are grouped, so saving on every
|
|
163
|
+
change costs one request per burst. The promise resolves once the
|
|
164
|
+
platform has the write, so a deck can tell whether saving worked.
|
|
107
165
|
|
|
108
166
|
```js
|
|
109
|
-
|
|
110
|
-
Storage.set("settings", { sound: true, music: false });
|
|
167
|
+
await Store.deck.set({ level: 7, name: "Ada" });
|
|
111
168
|
```
|
|
112
169
|
|
|
113
|
-
### `
|
|
170
|
+
### `Store.<scope>.remove(keys): Promise<void>`
|
|
114
171
|
|
|
115
|
-
Removes
|
|
172
|
+
Removes keys.
|
|
116
173
|
|
|
117
|
-
|
|
174
|
+
### `Store.<scope>.increment(key, delta?): Promise<number>`
|
|
175
|
+
|
|
176
|
+
Adds `delta` (default `1`) to a number and returns the new total,
|
|
177
|
+
starting the key at `delta` if it was not set. Two players incrementing
|
|
178
|
+
at the same moment both count, which is the thing a read, add and write
|
|
179
|
+
back cannot promise.
|
|
180
|
+
|
|
181
|
+
```js
|
|
182
|
+
const plays = await Store.deck.increment("plays");
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### `Store.<scope>.list(options?): Promise<{ entries, cursor }>`
|
|
186
|
+
|
|
187
|
+
Reads a page of keys in order, optionally only those starting with
|
|
188
|
+
`prefix`. Pass the `cursor` from one page to get the next.
|
|
118
189
|
|
|
119
|
-
|
|
120
|
-
|
|
190
|
+
```js
|
|
191
|
+
let cursor = null;
|
|
192
|
+
const world = [];
|
|
193
|
+
do {
|
|
194
|
+
const page = await Store.deck.list({ prefix: "block:", cursor });
|
|
195
|
+
world.push(...page.entries);
|
|
196
|
+
cursor = page.cursor;
|
|
197
|
+
} while (cursor);
|
|
198
|
+
```
|
|
121
199
|
|
|
122
|
-
|
|
200
|
+
Keep reading until `cursor` is `null`. A page can come back shorter than
|
|
201
|
+
you asked for because it reached its size limit, not because the keys
|
|
202
|
+
ran out, so the cursor is the only reliable end signal.
|
|
123
203
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
owning player can write.
|
|
204
|
+
A paged read is not a snapshot: something written while you are paging
|
|
205
|
+
may land in no page or in two. Treat the result as a starting point and
|
|
206
|
+
apply live updates on top, which a multiplayer deck is doing anyway.
|
|
128
207
|
|
|
129
|
-
### `
|
|
208
|
+
### `Store.board(name)`
|
|
130
209
|
|
|
131
|
-
|
|
132
|
-
|
|
210
|
+
A leaderboard, separate from the keys above so that reading the top
|
|
211
|
+
scores stays fast no matter how many players there are.
|
|
133
212
|
|
|
134
213
|
```js
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
const
|
|
214
|
+
await Store.board("highscores").submit(score);
|
|
215
|
+
const top = await Store.board("highscores").top(10);
|
|
216
|
+
const mine = await Store.board("highscores").get();
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
- `submit(score)` records the current player's score. A later submission
|
|
220
|
+
replaces an earlier one, so compare first if you only want to keep a
|
|
221
|
+
personal best.
|
|
222
|
+
- `top(limit?)` returns the highest scores, best first.
|
|
223
|
+
- `get(userId?)` returns one player's score, defaulting to the current
|
|
224
|
+
player, or `null` if they have none.
|
|
225
|
+
|
|
226
|
+
A deck's session server can submit any player's score, which is how a
|
|
227
|
+
leaderboard becomes something a player cannot fake. See the multiplayer
|
|
228
|
+
guide.
|
|
229
|
+
|
|
230
|
+
### Limits
|
|
231
|
+
|
|
232
|
+
The platform caps key length, value size, how many keys one call may
|
|
233
|
+
touch, page size, and how much a deck may store in total, and it limits
|
|
234
|
+
how fast a player may read and write. Going over any of them rejects
|
|
235
|
+
with a `CastleError` whose `code` says which, so a deck can tell a
|
|
236
|
+
player it is saving too fast apart from a deck that is out of space.
|
|
237
|
+
|
|
238
|
+
## Storage
|
|
239
|
+
|
|
240
|
+
> Older API, still supported and not going away. New decks should use
|
|
241
|
+
> [`Store`](#store) instead: it stores each key on its own, so concurrent
|
|
242
|
+
> writes do not overwrite each other, and it holds far more than this does.
|
|
243
|
+
> `Storage` keeps one document per player and rewrites all of it on every
|
|
244
|
+
> change.
|
|
245
|
+
|
|
246
|
+
`Storage` saves data for the current player. Nobody else can read it.
|
|
247
|
+
|
|
248
|
+
- **`Storage.get<T>(key): Promise<T | null>`** — the value at `key`, or
|
|
249
|
+
`null` if it is not set.
|
|
250
|
+
- **`Storage.set(key, value)`** — sets `key`. `value` must be something
|
|
251
|
+
that converts to JSON. The next `get(key)` returns it immediately;
|
|
252
|
+
the write saves in the background.
|
|
253
|
+
- **`Storage.remove(key)`** — removes `key`.
|
|
254
|
+
|
|
255
|
+
```js
|
|
256
|
+
const level = (await Storage.get("level")) ?? 1;
|
|
257
|
+
Storage.set("level", 7);
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
The same thing with `Store`, which is what a new deck should write:
|
|
261
|
+
|
|
262
|
+
```js
|
|
263
|
+
const { level = 1 } = await Store.private.get(["level"]);
|
|
264
|
+
await Store.private.set({ level: 7 });
|
|
138
265
|
```
|
|
139
266
|
|
|
140
|
-
|
|
267
|
+
## SharedStorage
|
|
268
|
+
|
|
269
|
+
> Older API, still supported and not going away. New decks should use
|
|
270
|
+
> [`Store`](#store) instead. `SharedStorage` has no ownership rule — any
|
|
271
|
+
> player can overwrite any key in the `'deck'` bucket — and two players
|
|
272
|
+
> writing at the same moment lose one of the writes. `Store` fixes both,
|
|
273
|
+
> and adds counters, listing, and leaderboards.
|
|
274
|
+
|
|
275
|
+
`SharedStorage` saves data that other players can read. Values must be
|
|
276
|
+
something that converts to JSON, same as `Storage`.
|
|
277
|
+
|
|
278
|
+
Scopes: `'deck'` is one bucket for the whole deck, readable and writable
|
|
279
|
+
by any player. `'user'` is a per-player bucket that anyone can read and
|
|
280
|
+
only the owning player can write.
|
|
141
281
|
|
|
142
|
-
|
|
143
|
-
|
|
282
|
+
- **`SharedStorage.get(scope, key)`** — reads a shared value. For
|
|
283
|
+
`'user'`, pass a user id before the key to read someone else's bucket.
|
|
284
|
+
- **`SharedStorage.set(scope, key, value)`** — writes. `'user'` writes
|
|
285
|
+
always go to the current player.
|
|
286
|
+
- **`SharedStorage.remove(scope, key)`** — removes a shared value.
|
|
144
287
|
|
|
145
288
|
```js
|
|
146
|
-
SharedStorage.
|
|
289
|
+
const worldHighScore = await SharedStorage.get("deck", "highScore");
|
|
290
|
+
const theirColor = await SharedStorage.get("user", otherUserId, "color");
|
|
147
291
|
SharedStorage.set("user", "color", "red");
|
|
148
292
|
```
|
|
149
293
|
|
|
150
|
-
|
|
294
|
+
The same thing with `Store`:
|
|
151
295
|
|
|
152
|
-
|
|
296
|
+
```js
|
|
297
|
+
const { highScore } = await Store.deck.get(["highScore"]);
|
|
298
|
+
const { color } = await Store.user(otherUserId).get(["color"]);
|
|
299
|
+
await Store.me.set({ color: "red" });
|
|
300
|
+
```
|
|
153
301
|
|
|
154
302
|
## Leaderboard
|
|
155
303
|
|
|
@@ -399,7 +547,8 @@ be coalesced, so it's safe to call on frequent events.
|
|
|
399
547
|
|
|
400
548
|
`Lifecycle` tells the host when the deck has painted its first frame, so
|
|
401
549
|
the Castle feed can reveal it right away instead of waiting out a fixed
|
|
402
|
-
delay
|
|
550
|
+
delay — and carries state across an unload, so a deck the player leaves
|
|
551
|
+
mid-play and comes back to picks up where it was.
|
|
403
552
|
|
|
404
553
|
### `Lifecycle.ready()`
|
|
405
554
|
|
|
@@ -419,6 +568,70 @@ createRoot(root).render(<App />);
|
|
|
419
568
|
requestAnimationFrame(() => requestAnimationFrame(() => Lifecycle.ready()));
|
|
420
569
|
```
|
|
421
570
|
|
|
571
|
+
### Resuming across an unload
|
|
572
|
+
|
|
573
|
+
The host can unload a deck's page mid-play and mount it again later, and
|
|
574
|
+
by default the deck starts over when it does. Resume state closes that:
|
|
575
|
+
the host asks the deck for a snapshot of its current state, keeps it in
|
|
576
|
+
memory, and offers it back the next time the deck mounts.
|
|
577
|
+
|
|
578
|
+
It is **pause, not save**. The host holds the snapshot briefly and in
|
|
579
|
+
memory only: it does not outlive the app, and the player restarting the
|
|
580
|
+
deck clears it. Anything that should outlive the app goes in
|
|
581
|
+
[`Store`](#store) or [`Storage`](#storage).
|
|
582
|
+
|
|
583
|
+
Reach for resume state when:
|
|
584
|
+
|
|
585
|
+
- the state must NOT persist by design — a daily-challenge attempt in
|
|
586
|
+
progress, a roguelike run, an anti-farm timer;
|
|
587
|
+
- the state is too big or too churny for `Store`;
|
|
588
|
+
- the state only needs to survive the player leaving and coming back.
|
|
589
|
+
|
|
590
|
+
Don't substitute `localStorage`: it outlives the app and the host cannot
|
|
591
|
+
clear it, so the deck could never be restarted cleanly.
|
|
592
|
+
|
|
593
|
+
Not every host keeps resume state. Where one doesn't, `restoreState()`
|
|
594
|
+
resolves `null` and the provider is never called, so a deck written for
|
|
595
|
+
it works unchanged.
|
|
596
|
+
|
|
597
|
+
### `Lifecycle.provideState(provide): () => void`
|
|
598
|
+
|
|
599
|
+
Register a function returning the deck's current state, for the host to
|
|
600
|
+
hand back if it unloads and remounts this deck. Returns a function that
|
|
601
|
+
unregisters it; registering again replaces the provider.
|
|
602
|
+
|
|
603
|
+
The host calls it whenever it might need a snapshot: at arbitrary
|
|
604
|
+
moments while the deck plays on, and often. A call does not mean the deck
|
|
605
|
+
is about to unload, so treat it as a read of your state and nothing more
|
|
606
|
+
— it must be **synchronous, cheap and side-effect-free**. Return
|
|
607
|
+
`undefined` to decline; the deck then starts fresh the next time it
|
|
608
|
+
mounts.
|
|
609
|
+
|
|
610
|
+
Whatever you return is `JSON.stringify`'d, so keep it small — tens of
|
|
611
|
+
kilobytes, not megabytes. A value over 1 MB is dropped with a warning.
|
|
612
|
+
|
|
613
|
+
```js
|
|
614
|
+
import { Lifecycle } from "castle-web-sdk";
|
|
615
|
+
|
|
616
|
+
Lifecycle.provideState(() => ({ level, score, elapsedMs: clock.elapsed() }));
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
### `Lifecycle.restoreState<T>(): Promise<T | null>`
|
|
620
|
+
|
|
621
|
+
The state this deck last provided, or `null` — nothing kept, the player
|
|
622
|
+
restarted the deck, or the host doesn't keep resume state. It never
|
|
623
|
+
throws for any of those, so there is nothing to catch. Reading doesn't
|
|
624
|
+
consume it.
|
|
625
|
+
|
|
626
|
+
Call it before your first paint and before `ready()`, so a deck that is
|
|
627
|
+
resuming doesn't flash its fresh state first:
|
|
628
|
+
|
|
629
|
+
```js
|
|
630
|
+
const saved = await Lifecycle.restoreState();
|
|
631
|
+
startGame(saved ?? newGame());
|
|
632
|
+
Lifecycle.ready();
|
|
633
|
+
```
|
|
634
|
+
|
|
422
635
|
## Setup
|
|
423
636
|
|
|
424
637
|
Startup, editor-mode check, and a file-write call for editor UI.
|
package/dist/castle.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export { Lifecycle } from "./lifecycle";
|
|
|
9
9
|
export type { CastleLifecycleApi } from "./lifecycle";
|
|
10
10
|
export { Multiplayer } from "./multiplayer";
|
|
11
11
|
export type { CastleMultiplayerApi, MultiplayerConnection, MultiplayerConnectionState, MultiplayerJoinOptions, MultiplayerRoster, MultiplayerStateMetadata, } from "./multiplayer";
|
|
12
|
+
export type { MultiplayerSoloReason } from "./commands";
|
|
12
13
|
export { MAX_CLIENT_FRAME_BYTES, MAX_PLAYERS_CAP } from "./multiplayerProtocol";
|
|
13
14
|
export type { ClientByeFrame, ClientHelloFrame, ClientMessageFrame, ClientPingFrame, ClientPongFrame, ClientReplacedFrame, ClientRosterFrame, ClientToPlatformFrame, ClientToPlatformMessageFrame, MultiplayerPlayer, PlayerIdentity, PlatformToClientFrame, SessionPlayer, SessionType, } from "./multiplayerProtocol";
|
|
14
15
|
export { Pass } from "./passes";
|
|
@@ -20,6 +21,8 @@ export type { FileChange, FilesChangedEvent } from "./runtime";
|
|
|
20
21
|
export { flushSaves, hasPendingSave, onSaveState, writeFile, } from "./saveQueue";
|
|
21
22
|
export type { SaveState } from "./saveQueue";
|
|
22
23
|
export { SharedStorage, Storage } from "./storage";
|
|
24
|
+
export { Store } from "./store";
|
|
25
|
+
export type { CastleStoreApi, StoreBoardApi, StoreBoardScore, StoreListEntry, StoreListOptions, StoreListPage, StoreScopeApi, } from "./store";
|
|
23
26
|
export { Time } from "./time";
|
|
24
27
|
export type { CastleClockZone, CastleDateParts, CastleTimeApi } from "./time";
|
|
25
28
|
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";
|
package/dist/chunk.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/commands.d.ts
CHANGED
|
@@ -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;
|
|
@@ -47,16 +61,29 @@ export type HapticsStatus = "triggered" | "unavailable";
|
|
|
47
61
|
export interface HapticsResult {
|
|
48
62
|
status: HapticsStatus;
|
|
49
63
|
}
|
|
64
|
+
export type LifecycleRestoreStateStatus = "restored" | "none" | "unavailable";
|
|
65
|
+
export interface LifecycleRestoreStateResult {
|
|
66
|
+
status: LifecycleRestoreStateStatus;
|
|
67
|
+
/** The deck-serialized JSON string it saved. Present iff status is "restored". */
|
|
68
|
+
state?: string;
|
|
69
|
+
}
|
|
50
70
|
export type MultiplayerMode = "named" | "party" | "public";
|
|
51
71
|
export interface MultiplayerGetSessionParams {
|
|
52
72
|
mode: MultiplayerMode;
|
|
53
73
|
key?: string | null;
|
|
54
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Why a join fell back to solo. `private_deck` and `no_server_bundle` are the
|
|
77
|
+
* creator's to fix; anything else the platform reports as `unavailable`.
|
|
78
|
+
*/
|
|
79
|
+
export type MultiplayerSoloReason = "private_deck" | "no_server_bundle" | "unavailable";
|
|
55
80
|
export interface MultiplayerGetSessionResult {
|
|
56
81
|
status: "ok" | "solo" | "unavailable";
|
|
57
82
|
url: string | null;
|
|
58
83
|
nonce: string | null;
|
|
59
84
|
sessionId: string | null;
|
|
85
|
+
/** Set when `status` is "solo"; null otherwise. */
|
|
86
|
+
soloReason: MultiplayerSoloReason | null;
|
|
60
87
|
}
|
|
61
88
|
export interface CommandParams {
|
|
62
89
|
"deckStorage.load": Record<string, never>;
|
|
@@ -110,6 +137,54 @@ export interface CommandParams {
|
|
|
110
137
|
style: HapticStyle;
|
|
111
138
|
};
|
|
112
139
|
"multiplayer.getSession": MultiplayerGetSessionParams;
|
|
140
|
+
"cauldronStorage.get": {
|
|
141
|
+
scope: StoreScope;
|
|
142
|
+
subject?: string | null;
|
|
143
|
+
keys: string[];
|
|
144
|
+
};
|
|
145
|
+
"cauldronStorage.set": {
|
|
146
|
+
scope: StoreScope;
|
|
147
|
+
subject?: string | null;
|
|
148
|
+
entries: StoreEntry[];
|
|
149
|
+
asServer?: boolean;
|
|
150
|
+
unload?: boolean;
|
|
151
|
+
};
|
|
152
|
+
"cauldronStorage.delete": {
|
|
153
|
+
scope: StoreScope;
|
|
154
|
+
subject?: string | null;
|
|
155
|
+
keys: string[];
|
|
156
|
+
asServer?: boolean;
|
|
157
|
+
unload?: boolean;
|
|
158
|
+
};
|
|
159
|
+
"cauldronStorage.increment": {
|
|
160
|
+
scope: StoreScope;
|
|
161
|
+
subject?: string | null;
|
|
162
|
+
key: string;
|
|
163
|
+
delta: number;
|
|
164
|
+
asServer?: boolean;
|
|
165
|
+
};
|
|
166
|
+
"cauldronStorage.list": {
|
|
167
|
+
scope: StoreScope;
|
|
168
|
+
subject?: string | null;
|
|
169
|
+
prefix?: string | null;
|
|
170
|
+
limit?: number | null;
|
|
171
|
+
cursor?: string | null;
|
|
172
|
+
};
|
|
173
|
+
"cauldronStorage.boardSubmit": {
|
|
174
|
+
board: string;
|
|
175
|
+
subject?: string | null;
|
|
176
|
+
score: number;
|
|
177
|
+
asServer?: boolean;
|
|
178
|
+
};
|
|
179
|
+
"cauldronStorage.boardTop": {
|
|
180
|
+
board: string;
|
|
181
|
+
limit?: number | null;
|
|
182
|
+
};
|
|
183
|
+
"cauldronStorage.boardGet": {
|
|
184
|
+
board: string;
|
|
185
|
+
subject?: string | null;
|
|
186
|
+
};
|
|
187
|
+
"lifecycle.restoreState": Record<string, never>;
|
|
113
188
|
}
|
|
114
189
|
export interface CommandResult {
|
|
115
190
|
"deckStorage.load": {
|
|
@@ -152,6 +227,32 @@ export interface CommandResult {
|
|
|
152
227
|
"portal.prefetch": PortalPrefetchResult;
|
|
153
228
|
"haptics.play": HapticsResult;
|
|
154
229
|
"multiplayer.getSession": MultiplayerGetSessionResult;
|
|
230
|
+
"cauldronStorage.get": {
|
|
231
|
+
entries: StoreEntry[];
|
|
232
|
+
};
|
|
233
|
+
"cauldronStorage.set": {
|
|
234
|
+
usage: StoreUsage;
|
|
235
|
+
};
|
|
236
|
+
"cauldronStorage.delete": {
|
|
237
|
+
usage: StoreUsage;
|
|
238
|
+
};
|
|
239
|
+
"cauldronStorage.increment": {
|
|
240
|
+
value: string;
|
|
241
|
+
};
|
|
242
|
+
"cauldronStorage.list": {
|
|
243
|
+
entries: StoreEntry[];
|
|
244
|
+
cursor: string | null;
|
|
245
|
+
};
|
|
246
|
+
"cauldronStorage.boardSubmit": {
|
|
247
|
+
entry: StoreBoardEntry;
|
|
248
|
+
};
|
|
249
|
+
"cauldronStorage.boardTop": {
|
|
250
|
+
entries: StoreBoardEntry[];
|
|
251
|
+
};
|
|
252
|
+
"cauldronStorage.boardGet": {
|
|
253
|
+
entry: StoreBoardEntry | null;
|
|
254
|
+
};
|
|
255
|
+
"lifecycle.restoreState": LifecycleRestoreStateResult;
|
|
155
256
|
}
|
|
156
257
|
export type CommandName = keyof CommandParams;
|
|
157
258
|
export interface SerializedCommandError {
|
|
@@ -174,8 +275,22 @@ export interface CommandResponseEnvelope {
|
|
|
174
275
|
error?: SerializedCommandError;
|
|
175
276
|
}
|
|
176
277
|
export declare function isResponseEnvelope(value: unknown): value is CommandResponseEnvelope;
|
|
177
|
-
export type LifecycleEvent = "ready";
|
|
278
|
+
export type LifecycleEvent = "ready" | "unloadInterest";
|
|
178
279
|
export interface LifecycleEnvelope {
|
|
179
280
|
castleSdk: typeof CASTLE_SDK_PROTOCOL;
|
|
180
281
|
lifecycle: LifecycleEvent;
|
|
181
282
|
}
|
|
283
|
+
export interface UnloadRequestEnvelope {
|
|
284
|
+
castleSdk: typeof CASTLE_SDK_PROTOCOL;
|
|
285
|
+
unload: {
|
|
286
|
+
unloadId: string;
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
export declare function unloadRequestId(value: unknown): string | null;
|
|
290
|
+
export interface UnloadDoneEnvelope {
|
|
291
|
+
castleSdk: typeof CASTLE_SDK_PROTOCOL;
|
|
292
|
+
unloadDone: {
|
|
293
|
+
unloadId: string;
|
|
294
|
+
state?: string;
|
|
295
|
+
};
|
|
296
|
+
}
|
package/dist/commands.js
CHANGED
|
@@ -14,3 +14,12 @@ export function isResponseEnvelope(value) {
|
|
|
14
14
|
typeof record.requestId === "string" &&
|
|
15
15
|
typeof record.ok === "boolean");
|
|
16
16
|
}
|
|
17
|
+
export function unloadRequestId(value) {
|
|
18
|
+
if (typeof value !== "object" || value === null)
|
|
19
|
+
return null;
|
|
20
|
+
const record = value;
|
|
21
|
+
if (record.castleSdk !== CASTLE_SDK_PROTOCOL)
|
|
22
|
+
return null;
|
|
23
|
+
const unload = record.unload;
|
|
24
|
+
return typeof unload?.unloadId === "string" ? unload.unloadId : null;
|
|
25
|
+
}
|
package/dist/lifecycle.d.ts
CHANGED
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
export interface CastleLifecycleApi {
|
|
2
2
|
ready(): void;
|
|
3
|
+
/**
|
|
4
|
+
* Register a function returning the deck's current state, for the host to hand
|
|
5
|
+
* back if it unloads and remounts this deck. Returns a function that
|
|
6
|
+
* unregisters it; registering again replaces the provider.
|
|
7
|
+
*
|
|
8
|
+
* The host may call it at any moment, repeatedly, while the deck plays on; a
|
|
9
|
+
* call does not mean the deck is about to unload. It must be synchronous,
|
|
10
|
+
* cheap and side-effect-free. Return `undefined` to decline; the deck then
|
|
11
|
+
* starts fresh the next time it mounts.
|
|
12
|
+
*/
|
|
13
|
+
provideState(provide: () => unknown): () => void;
|
|
14
|
+
/**
|
|
15
|
+
* The state this deck last provided, or `null` — nothing kept, the player
|
|
16
|
+
* restarted the deck, or the host doesn't keep resume state. Never throws for
|
|
17
|
+
* any of those.
|
|
18
|
+
*
|
|
19
|
+
* Call it before your first paint (and before `ready()`), so a deck that is
|
|
20
|
+
* resuming doesn't flash its fresh state first.
|
|
21
|
+
*/
|
|
22
|
+
restoreState<T = unknown>(): Promise<T | null>;
|
|
3
23
|
}
|
|
4
24
|
export declare const Lifecycle: CastleLifecycleApi;
|
package/dist/lifecycle.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
// Deck-side lifecycle signals. The feed reveals the deck when it reports `ready`
|
|
2
|
-
// (its first presentable frame) instead of waiting out a fixed timer
|
|
2
|
+
// (its first presentable frame) instead of waiting out a fixed timer, and asks
|
|
3
|
+
// it for state to resume from when it may be about to unload it.
|
|
4
|
+
import { provideState, restoreState } from "./resumeState";
|
|
3
5
|
import { hostNotify } from "./transport";
|
|
4
6
|
let readySent = false;
|
|
7
|
+
const FIRST_FRAME_READY_MARKER = "[castle-lifecycle] first-frame-ready";
|
|
5
8
|
function ready() {
|
|
6
9
|
if (readySent)
|
|
7
10
|
return;
|
|
8
11
|
readySent = true;
|
|
12
|
+
console.log(FIRST_FRAME_READY_MARKER);
|
|
9
13
|
hostNotify("ready");
|
|
10
14
|
}
|
|
11
15
|
export const Lifecycle = {
|
|
12
16
|
ready,
|
|
17
|
+
provideState,
|
|
18
|
+
restoreState,
|
|
13
19
|
};
|
package/dist/multiplayer.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { MultiplayerSoloReason } from "./commands";
|
|
1
2
|
import { CastleError } from "./errors";
|
|
2
3
|
import { type MultiplayerPlayer } from "./multiplayerProtocol";
|
|
3
4
|
export type MultiplayerJoinOptions = {
|
|
@@ -31,6 +32,11 @@ export interface MultiplayerConnection {
|
|
|
31
32
|
readonly playerId: string;
|
|
32
33
|
readonly sessionId: string;
|
|
33
34
|
readonly isSolo: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Why the join fell back to solo; null while multiplayer is live.
|
|
37
|
+
* `private_deck` and `no_server_bundle` are the creator's to fix.
|
|
38
|
+
*/
|
|
39
|
+
readonly soloReason: MultiplayerSoloReason | null;
|
|
34
40
|
/** Sends byte payloads as binary frames and all other payloads as JSON. */
|
|
35
41
|
send(data: unknown): void;
|
|
36
42
|
/** Receives binary payloads as Uint8Array values and JSON payloads otherwise. */
|