castle-web-sdk 0.4.44 → 0.4.46

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
@@ -21,9 +21,12 @@ import { setup, initCard, Store, Leaderboard } from "castle-web-sdk";
21
21
  - [Leaderboard](#leaderboard)
22
22
  - [Time](#time)
23
23
  - [User](#user)
24
+ - [Experiments](#experiments)
25
+ - [Avatar](#avatar)
24
26
  - [Pass](#pass)
25
27
  - [Snapshot](#snapshot)
26
28
  - [Portal](#portal)
29
+ - [Hub](#hub)
27
30
  - [Decks](#decks)
28
31
  - [Clipboard](#clipboard)
29
32
  - [Haptics](#haptics)
@@ -50,6 +53,12 @@ connection.onMessage((message) => receiveServerMessage(message));
50
53
  connection.send({ type: "ready" });
51
54
  ```
52
55
 
56
+ Attach `onMessage` in the same step that receives the connection (right after
57
+ `await Multiplayer.join(...)`). On a hub join (`mode: "hub"`), which resolves a
58
+ little after the socket's hello, messages the place sent in between, such as
59
+ the snapshot it greets a player with, are kept for that first listener. A
60
+ listener attached later gets only what arrives after it.
61
+
53
62
  Named rooms use `{ mode: "named", key: "room-name" }`; party sessions use
54
63
  `{ mode: "party" }`. Only one connection can be live per deck instance. The SDK
55
64
  reconnects temporary socket drops automatically. `onStateChange` reports
@@ -112,6 +121,11 @@ TypeScript server entries can import callback, session, player, and platform
112
121
  handle types from `castle-web-sdk/server`. Server transport has no runtime
113
122
  dependency on a WebSocket package.
114
123
 
124
+ `session.sessionId`, `session.deckId` and `session.mode` say which session this
125
+ is. `session.place` is the hub place the host started the session for (see
126
+ [`session.hub`](#sessionhub)), and `null` otherwise. It is never taken from a
127
+ client.
128
+
115
129
  ### `session.storage`
116
130
 
117
131
  A session ends when its last player leaves, and its memory goes with it.
@@ -133,7 +147,8 @@ export default {
133
147
 
134
148
  `session.storage.deck` is shared by everyone playing.
135
149
  `session.storage.user(userId)` is one player's public data.
136
- `session.storage.board(name)` is a leaderboard. Each scope has the same
150
+ `session.storage.board(name)` is a leaderboard. The root and its buckets also
151
+ support the [atomic save API](#atomic-saves). Each scope has the same
137
152
  `get`, `set`, `remove`, `increment` and `list` as `Store`.
138
153
 
139
154
  Two differences follow from a session server not being a player:
@@ -151,6 +166,154 @@ Two differences follow from a session server not being a player:
151
166
  stop. An out-of-memory kill or a host failing sends no shutdown at all, so
152
167
  save as things change and treat `onShutdown` as the last chance, not the plan.
153
168
 
169
+ ### `session.hub`
170
+
171
+ A deck whose `castle.json` declares a `hub` block is a hub place (the hub-game
172
+ kit's contract). `save-deck` publishes the block's titles and result shapes with
173
+ the server bundle, and Castle pins them to every session of the place. Those
174
+ sessions have `session.hub`; every other session has `session.hub === null`.
175
+
176
+ - `session.hub.place`, `.title` and `.result` are the place and the result shape
177
+ as published (`result` is `null` for a place that reports none, such as a town).
178
+ - `session.hub.arrival(playerId)` is how a player came in when Castle placed them
179
+ through a hub door: `{ fromDeckId, doorId }` (`doorId` is `null` when the door
180
+ names none), else `null`. It is Castle's word, never a client's, and it is also
181
+ the third argument of `onPlayerJoin`.
182
+ - `session.hub.reportResult(playerId, { outcome?, score?, stats? })` records a
183
+ signed-in player's result for this visit, once. The host checks it against the
184
+ published shape and Castle keeps it only for a place behind a curated door;
185
+ resolves `{ recorded, record }` (`recorded: false, reason: "duplicate"` for a
186
+ visit that already has one).
187
+ - `session.hub.updatePad({ doorId, padId, memberUserIds, fire? })` names who is
188
+ standing on a pad of one of this place's doors (only players connected to this
189
+ session count); `fire: true` sends them through together. Resolves
190
+ `{ status: "gathering" | "fired", members }`. A fired pad's group is fixed, so
191
+ the next gathering uses a new `padId`.
192
+ - `session.hub.readResults({ userIds?, doorIds? })` reads players' recent results,
193
+ records, platform avatars and wearables (`players[userId]`) and the best scores behind this
194
+ place's doors (`boards[doorId]`). A board also carries the door's title,
195
+ whether it is featured, and its group: `minPlayers`, `maxPlayers` and
196
+ `lateJoin` (null where the door does not say).
197
+ - `session.hub.wear(playerId, { kind, color, rarity } | null)` puts one wearable
198
+ on a signed-in player's platform avatar in every hub place (`kind` a lowercase
199
+ id the kit draws, `color` `'#rrggbb'`, `rarity` 0-9), or clears it with `null`;
200
+ the latest wins. Castle takes it only from a curated hub place. Resolves
201
+ `{ wearable }`; `readResults` returns it as `players[userId].wearable`.
202
+ - `session.hub.hiddenPair(playerIdA, playerIdB)` is true when a block hides these
203
+ two players from each other (either one blocked the other). Never send either
204
+ one the other's pose, avatar or name. The block lists themselves never reach
205
+ the deck, and player objects never carry them.
206
+
207
+ Refusals reject with a `CAULDRON_HUB_*` code, such as
208
+ `CAULDRON_HUB_RESULT_ACCOUNT_REQUIRED` for a guest, `CAULDRON_HUB_RESULT_DUPLICATE`
209
+ or `CAULDRON_HUB_PAD_NOT_READY`.
210
+
211
+ ### `session.experiment`
212
+
213
+ A deck with [experiments](#experiments) reads a player's variants on the server
214
+ too. Castle resolves them and delivers them with the join, so they are the same
215
+ arms the player's page reads and nothing a client can claim.
216
+
217
+ - `session.experiment(player, name)` is the variant a connected player (the
218
+ player object or its `playerId`) has in `name`, or `null` when they are in no
219
+ such experiment.
220
+ - `session.experiments(player)` is all of them, `{ name: variant }`.
221
+
222
+ Both answer from `onPlayerJoin` until the player leaves, and `null` / `{}`
223
+ after. The variants are never on the player object, so a roster or state built
224
+ from `session.players` does not show one player's arm to another; send a variant
225
+ to other clients only when the game means to.
226
+
227
+ ```js
228
+ export default {
229
+ onPlayerJoin(session, player) {
230
+ const lives = session.experiment(player, "starting_lives") === "five" ? 5 : 3;
231
+ state.players[player.playerId] = { lives };
232
+ },
233
+ };
234
+ ```
235
+
236
+ ### Persistent worlds
237
+
238
+ An opted-in persistent-world route has `session.world`; other sessions have
239
+ `session.world === null`. Alongside its checkpoint, the world keeps a journal
240
+ of its latest 512 entries and a last-seen row for each participant:
241
+
242
+ ```js
243
+ await session.world.journal.append({
244
+ operationId: "market-opened-day-12",
245
+ entries: [{
246
+ kind: "market.opened",
247
+ disclosure: "world",
248
+ data: { day: 12 },
249
+ }],
250
+ });
251
+
252
+ const page = await session.world.journal.read({
253
+ afterSeq: savedCursor,
254
+ participantId: player.participantId,
255
+ });
256
+ const { participants } = await session.world.participants.read({
257
+ participantIds: [player.participantId],
258
+ });
259
+ ```
260
+
261
+ `journal.append` accepts up to 16 entries per operation; each optional `data`
262
+ value must be JSON no larger than 2 KiB. Save `nextSeq` from `journal.read` as
263
+ the next cursor, and compare it with `oldestRetainedSeq` to detect a cursor that
264
+ fell behind retention. Entries disclosed to `participants` name participant
265
+ ids for the trusted deck server to project to those players; never broadcast
266
+ those entries to the whole session.
267
+
268
+ Every `MultiplayerServerPlayer` has a stable `participantId` for these calls
269
+ (`scopedUserId` when available, otherwise `userId`). The host records join,
270
+ leave, and shutdown presence automatically, and `participants.read` reflects
271
+ those host writes. The SDK never writes participant presence, and there is no
272
+ public touch method.
273
+
274
+ A world-notification template is published copy with named slots; push copy is
275
+ always authored by the deck and is never generated at runtime. Ghost owns
276
+ delivery, muting, expiry, and rate caps, including notifications scheduled for
277
+ later. `notifications.notify` and `notifications.cancel` return counts only, so
278
+ deck code cannot learn which participants were muted or unavailable.
279
+
280
+ ```js
281
+ await session.world.notifications.notify({
282
+ key: "lantern-overdue",
283
+ participantIds: [player.participantId],
284
+ templateId: "lantern_overdue",
285
+ slots: { npc: "Mara" },
286
+ at: "2026-10-01T18:00:00Z",
287
+ });
288
+
289
+ await session.world.notifications.cancel({
290
+ key: "lantern-overdue",
291
+ participantIds: [player.participantId],
292
+ });
293
+ ```
294
+
295
+ A destination can opt into real elapsed time with
296
+ `worldTime: { advance: "wallClock" }`. On boot, `catchUp` reads the latest
297
+ checkpoint, clamps the elapsed duration, and commits the full checkpoint
298
+ returned by `apply`; it retries one revision conflict and never advances an
299
+ authored game clock on its own.
300
+
301
+ The checkpoint marker prevents an elapsed interval from being counted twice
302
+ and never moves backwards if the host clock regresses. If a prior commit landed
303
+ without its acknowledgement being observed, the next boot counts only time
304
+ elapsed after that marker, so `apply` may run again for that new interval; it
305
+ returns `{ applied: false, elapsedMs: 0 }` without calling `apply` only when no
306
+ time has passed since the marker.
307
+
308
+ ```js
309
+ const result = await session.world.catchUp({
310
+ maxElapsedMs: 7 * 24 * 60 * 60 * 1000,
311
+ apply({ elapsedMs, checkpoint }) {
312
+ return advanceWorld(checkpoint, elapsedMs);
313
+ },
314
+ });
315
+ ```
316
+
154
317
  ## Store
155
318
 
156
319
  `Store` saves data one key at a time. It is the storage to use for
@@ -313,6 +476,146 @@ A value is JSON, so binary data such as a picture or a video clip costs
313
476
  about a third more once encoded, and a clip of more than a few seconds
314
477
  does not fit in one. Ship media with the deck instead.
315
478
 
479
+ ### Atomic saves
480
+
481
+ `Store` and `session.storage` expose the same typed save methods:
482
+
483
+ - `snapshot({ targets: [{ target, select? }] })` reads every selected target at
484
+ one consistent point. `select` is `{ prefix }` or `{ keys }` within the
485
+ target; omitted selection reads its whole prefix. The result has `access`
486
+ and `targets: [{ target, revision, rows, complete }]`. `complete` describes
487
+ coverage of the entire target. A successful `null` revision means absent;
488
+ an initialized empty target has a revision. A failed read never means absent.
489
+ - `transaction({ operationId, expectedAccess, checks?, changes })` commits all
490
+ changes together. Each change has `target`, `expectedRevision` (string or
491
+ `null`), optional `writer`, and either `put`/`remove` or `replace`. `replace`
492
+ removes omitted rows within the prefix. Each target appears once. A `null`
493
+ expected revision creates only an absent target; an explicit `replace` also
494
+ supports importing unregistered legacy rows. `checks` are read-only target
495
+ checks with an expected revision and/or writer; they leave revisions intact.
496
+ - `claim({ operationId, expectedAccess?, target, initial?, load? })` explicitly
497
+ takes ownership of a target and returns `{ writer, receipt, snapshot }`.
498
+ `initial: [{ target, rows }]` initializes absent targets only. An absent
499
+ claimed target initializes empty if defaults were omitted. Claiming an
500
+ existing target leaves its data revision unchanged. The latest committed
501
+ claim wins; claimed targets require the current writer on every change.
502
+ - `initialize({ operationId, expectedAccess?, initial, load? })` initializes
503
+ only absent targets and returns `{ receipt, snapshot }`. Concurrent defaults
504
+ are applied once. It never claims ownership or replaces existing data.
505
+ - `operation(operationId)` returns `{ state: "committed", receipt }` or
506
+ `{ state: "unknown" }`. Unknown does not cancel a delayed request. Settle it
507
+ by replaying the original input and ID.
508
+
509
+ A target is `{ scope: "deck" | "user" | "private", subject?, prefix }`.
510
+ The prefix must be nonempty, and registered prefixes in a bucket cannot
511
+ intersect. All row keys stay inside their target. `subject` is only for user
512
+ scope; omitted subject means the current player. Session servers must name a
513
+ user and cannot access private targets. Permissions and quotas are unchanged:
514
+ deck-scoped bags remain publicly readable and share the deck allowance.
515
+ `rows`, `put` and `replace` are maps of keys to JSON values, validated through
516
+ the same JSON handling as ordinary Store writes.
517
+
518
+ A receipt contains `operationId`, `access`, `revisions: [{ target, revision }]`
519
+ and, for claims, `writer`. Access and revisions are opaque. Use snapshot access
520
+ as `expectedAccess`; reset and undo invalidate old access, revisions and writer
521
+ tokens. Claim/initialize may omit expected access for a deliberate first open.
522
+ When `load` is omitted, the SDK requests the claimed/initial targets. Supply
523
+ an explicit `load` to choose other targets or smaller projections.
524
+
525
+ Every bucket also has `target(prefix)` and the same methods with a `prefix`
526
+ wherever the root takes a `target`: `snapshot({ prefix, select? })`,
527
+ `transaction({ changes: [{ prefix, ... }], checks?: [{ prefix, ... }] })`,
528
+ `claim({ prefix, initial?: [{ prefix, rows }], load?: [{ prefix, select? }] })`,
529
+ `initialize({ initial: [{ prefix, rows }], load? })` and `operation(id)`.
530
+ Root methods can combine buckets atomically:
531
+
532
+ ```js
533
+ const room = session.storage.deck.target(`room:${roomId}:`);
534
+ const bag = session.storage.deck.target(`bag:${roomId}:${userId}:`);
535
+ const opened = await session.storage.claim({
536
+ operationId: crypto.randomUUID(),
537
+ target: room,
538
+ initial: [{ target: room, rows: roomDefaults }, { target: bag, rows: starterBag }],
539
+ load: [{ target: room }, { target: bag }],
540
+ });
541
+ const receipt = await session.storage.transaction({
542
+ operationId: actionId,
543
+ expectedAccess: opened.snapshot.access,
544
+ changes: [
545
+ { target: room, expectedRevision: opened.snapshot.targets[0].revision,
546
+ writer: opened.writer, put: changedRoomRows, remove: deletedRoomKeys },
547
+ { target: bag, expectedRevision: opened.snapshot.targets[1].revision,
548
+ put: changedBagRows },
549
+ ],
550
+ });
551
+ // Acknowledge the gameplay action only after this receipt.
552
+ ```
553
+
554
+ Atomic saves bypass ordinary coalescing and 32-key splitting. They are never
555
+ split into partial writes or retried as ordinary SETs. A request or snapshot
556
+ that exceeds a transport or single-charge byte allowance fails with
557
+ `CAULDRON_STORAGE_TOO_LARGE`; narrow the selection or reduce the atomic payload.
558
+ Ordinary `set`, `remove` and `increment` reject keys inside registered targets
559
+ with `CAULDRON_STORAGE_MANAGED_TARGET`.
560
+
561
+ Save methods reject with `SaveError`, exported from the client and server SDK.
562
+ It carries `code`, `message`, `operationId`, `retryable`,
563
+ `committed: false | true | "unknown"`, and optional `receipt`, `retryAfterMs`
564
+ and `target`. Extra platform details remain in `extensions`. Conflicts, writer
565
+ loss and stale access require explicit reload/rebuild. A replayed historical
566
+ claim can fail with writer loss while `committed === true` and its original
567
+ receipt; it never takes ownership again. Transport ambiguity keeps the outcome
568
+ unknown until receipt lookup or identical replay settles it.
569
+
570
+ ### Save coordinator
571
+
572
+ `createSaveCoordinator`, from `castle-web-sdk` or `castle-web-sdk/server`,
573
+ sends a deck's transactions in order and keeps each one until it settles:
574
+
575
+ ```js
576
+ const saves = createSaveCoordinator(session.storage, opened.snapshot, [
577
+ { target: room, writer: opened.writer },
578
+ ]);
579
+ const receipt = await saves.submit({
580
+ operationId: actionId,
581
+ checks: [{ target: room, writer: opened.writer }],
582
+ changes: [{ target: bag, put: changedBagRows }],
583
+ });
584
+ await saves.flush();
585
+ // saves.status is "pending", "saved" or "blocked".
586
+ ```
587
+
588
+ `submit` copies the save and resolves with its receipt once it commits. A
589
+ change without `expectedRevision` takes the target's revision from the latest
590
+ receipt, filled in when the save is first sent; a change without `writer`
591
+ takes the one passed in for its target. Saves are sent one at a time, in
592
+ order. A save whose reply was lost is looked up by its id and, when the
593
+ platform has no receipt for it, sent again unchanged.
594
+
595
+ A failure that cannot be settled that way sets `status` to `"blocked"` and
596
+ holds back every save behind it. When the outcome is unknown or the error is
597
+ retryable, the save's promise stays pending and `retry()` sends the same
598
+ request again. When the save definitely did not commit (a conflict, a lost
599
+ writer), its promise rejects and the coordinator stays blocked: reload a
600
+ snapshot and start a new coordinator. It never claims or rebuilds a save on
601
+ its own. `error`, `pendingOperationIds` and `revision(target)` show its state.
602
+
603
+ `flush()` resolves once every save submitted before it has committed, and
604
+ rejects if the queue blocks first. Saves submitted after it do not hold it up,
605
+ and an earlier receipt never marks a later save saved. For start-over, call
606
+ `discardUnsent()` and submit the replacement: saves already sent still settle
607
+ first, and the replacement goes out at the revision they leave.
608
+
609
+ Keep the coordinator in the page when a private worker is replaced. A page or
610
+ process that dies loses the saves it had not sent and the ids of those it had;
611
+ reopening restores the last committed snapshot.
612
+
613
+ During `castle-web serve`, the serve answers the save API itself, from one
614
+ in-memory store per project shared by its pages and named sessions. Its rows,
615
+ receipts and writers survive a runtime restart and are lost when the serve
616
+ exits. Ordinary `get`, `set` and `list` still use the deck's real storage, and
617
+ the two do not see each other's rows.
618
+
316
619
  ## Storage
317
620
 
318
621
  > Older API, still supported and not going away. New decks should use
@@ -526,6 +829,92 @@ if (them?.photoUrl) {
526
829
  }
527
830
  ```
528
831
 
832
+ ## Experiments
833
+
834
+ A/B tests. A deck declares its experiments in `castle.json`; Castle gives each
835
+ player one variant of each, the same one every time they play, and the deck
836
+ reads it. The guide, with the caveats worth knowing before reading results, is
837
+ [docs/ab-tests.md](../docs/ab-tests.md).
838
+
839
+ ```json
840
+ "experiments": {
841
+ "tutorial_length": { "variants": ["short", "long"] },
842
+ "shop_price": { "variants": ["low", "mid", "high"], "weights": [50, 25, 25] }
843
+ }
844
+ ```
845
+
846
+ - An experiment's name and its variants' names are 1 to 32 characters of `a-z`,
847
+ `0-9` and `_`. A deck declares at most 8 experiments, each with 2 to 8
848
+ variants.
849
+ - `weights` are whole percentages, one per variant, summing to 100. Leave them
850
+ out for an equal split.
851
+ - `winner` (one of the variants) ends the test: every player gets that variant.
852
+ - `save-deck` sends the block to Castle, and `castle-web check` and `save-deck`
853
+ both refuse one that breaks a rule. Removing the block ends every experiment
854
+ at the next save.
855
+
856
+ ```js
857
+ import { experiment, experimentsReady } from "castle-web-sdk";
858
+
859
+ await experimentsReady();
860
+ const tutorial = experiment("tutorial_length") === "long" ? longTutorial : shortTutorial;
861
+ ```
862
+
863
+ ### `experiment(name): string | null`
864
+
865
+ The variant this player has in `name`. Synchronous. `null` means the player is
866
+ in no such experiment: the deck does not declare it, it was removed, or the host
867
+ is an older one that knows nothing of experiments. Always give `null` a
868
+ behavior, normally the one the deck had before the test.
869
+
870
+ ### `experiments(): Record<string, string>`
871
+
872
+ Every experiment this player is in on this deck, as `{ name: variant }`.
873
+
874
+ ### `experimentsReady(): Promise<Record<string, string>>`
875
+
876
+ Resolves with that same map once the host has answered, and never rejects.
877
+ Await it once at boot, before the first read: some hosts hand the answer over
878
+ with the page, where it is already resolved, and the rest answer one request. A
879
+ read made before then returns `null` and logs a warning.
880
+
881
+ A multiplayer deck's server reads the same answer with
882
+ [`session.experiment(player, name)`](#sessionexperiment).
883
+
884
+ On `castle-web serve` the experiments come from the folder's `castle.json`,
885
+ assigned to you exactly as they will be once the deck is saved. Add
886
+ `?experiment.<name>=<variant>` to the serve's URL to force an arm (a declared
887
+ experiment and one of its variants); it holds for the whole serve, client and
888
+ local server alike, until `?experiment.<name>=` clears it or the serve stops.
889
+ An open deck page keeps the arm it loaded with until it is reloaded.
890
+ Nothing published reads that parameter. `devExperiments` in `castle.json`, a
891
+ `{ "name": "variant" }` map, answers for experiments the deck does not declare
892
+ itself (ones Castle runs across decks), on the serve only.
893
+
894
+ ## Avatar
895
+
896
+ The player's platform avatar: one per account, worn in every hub place
897
+ (cauldron-game-server `docs/HUB.md`, decision 4). In v1 it is an index into the
898
+ hub-game kit's character templates; there is no editor and no cosmetics. The
899
+ hub-game kit is the caller: it dresses the player and offers the choice in its
900
+ picker. Other players' avatars are not read here; a place's server learns them
901
+ from ghost.
902
+
903
+ ### `Avatar.get(): Promise<AvatarResult>`
904
+
905
+ `{ status: "ok", avatar, chosen }` with the player's choice, or the default
906
+ every account has (`chosen: false`). Anonymous accounts have one too.
907
+ `{ status: "unavailable", avatar: null }` for a signed-out visitor; a host from
908
+ before avatars rejects with `UNKNOWN_COMMAND`. Either way the deck picks its own.
909
+
910
+ ### `Avatar.set(avatar): Promise<AvatarResult>`
911
+
912
+ Makes `avatar` the account's from now on, everywhere. Only a curated hub place
913
+ (a deck an enabled hub door leads into or out of) may: anywhere else, and for a
914
+ signed-out visitor, the answer is `unavailable` and nothing changes. Ghost
915
+ refuses an index outside its template set (`INVALID_ARGUMENT`) and more than 30
916
+ choices a minute (`RATE_LIMIT_EXCEEDED`).
917
+
529
918
  ## Pass
530
919
 
531
920
  A pass is something a creator sells to players for Castle bricks (the
@@ -619,6 +1008,98 @@ Snapshot.onTaken(() => {
619
1008
  });
620
1009
  ```
621
1010
 
1011
+ ## Hub
1012
+
1013
+ `Hub` moves a player between hub places: every deck built on the hub-game kit is
1014
+ a place, and a door is a row in Castle's curated door table. A deck names only a
1015
+ door; the host supplies the current place, the session it joined there and the
1016
+ party, and calls Castle with the player's own account. Hosts without hub support
1017
+ answer `unavailable`; older hosts reject with `CastleError` code `UNKNOWN_COMMAND`.
1018
+
1019
+ - `Hub.doors()` reads the current place's doors and who is inside them:
1020
+ `{ status: "ok", doors }` or `{ status: "unavailable", doors: null }`. A row has
1021
+ `doorId`, `launcherKind` (`door`, `table`, `cabinet`, `gate`), `placementKind`
1022
+ (`layer`, `pad`, `exact`), `placement`, `padSize`, `enabled`, `featured` and
1023
+ `status: { playersInside, friendsInside }`. Where a door leads stays with the host.
1024
+ - `Hub.go(doorId)` acquires the destination behind a door of the current place.
1025
+ A door used within seconds of arriving can take up to 15 seconds to answer
1026
+ while Castle catches up with the arrival.
1027
+ - `Hub.back()` acquires the way back through the door the player came in by: the
1028
+ layer they left if it has room, else their party's, else the usual pick. The
1029
+ deck cannot choose where it leads; `not_found` when there is no way back.
1030
+ - `Hub.friends()` reads the mutual friends in a layer of the current place right
1031
+ now, as far as their now-playing privacy lets this player see them (at most
1032
+ 20): `{ status: "ok", friends: [{ userId, username, here, door }] }` or
1033
+ `{ status: "unavailable", friends: null }`. `here` is true for a friend in the
1034
+ player's own layer; `door` is set instead for a friend inside the place behind
1035
+ one of this place's doors (that door's id), and null otherwise.
1036
+ - `Hub.jump(userId)` acquires the layer of the current place that friend is in:
1037
+ the same place in another session, joined exactly like a door's. The way back
1038
+ does not change. `not_found` when the friend is not in another layer of it.
1039
+ - `Hub.report(userId, reason?)` reports another player in this hub session to
1040
+ Castle's moderators, with an optional short reason (at most 500 characters).
1041
+ The host names the place and session. `{ status: "ok" }`, or `unavailable`
1042
+ outside a hub session or on hosts without hub support. Hub chrome only.
1043
+ - `Hub.chat(after?)` reads this hub session's chat:
1044
+ `{ status: "ok", canChat, peers, messages: [{ id, fromUserId, fromUsername, text, at }], cursor }`.
1045
+ Who may chat is Castle's rule, not the deck's: players who follow each other
1046
+ and are both over 13. Each player is returned only the messages they may
1047
+ read (at most 50), and `peers` are the user ids here who would be sent what
1048
+ they say. Poll it, passing the last `cursor` as `after` for only what is newer.
1049
+ `unavailable` outside a hub session or on hosts without hub support.
1050
+ - `Hub.say(text)` says something in that chat (at most 200 characters). A deck
1051
+ cannot choose recipients: Castle sends it to the players here who may chat
1052
+ with this one. `{ status, id }`, where `status` is `sent`, `none` (nobody to
1053
+ send it to), `refused`, `limited` (said too fast) or `unavailable`.
1054
+ - `Hub.reportChat(messageId)` reports a chat message the player was returned to
1055
+ Castle's moderators. The host names the place and session. `{ status: "ok" }`
1056
+ or `unavailable`. Hub chrome only.
1057
+ - `Hub.cancel()` gives back an acquisition the deck will not join. Optional: one
1058
+ that is never joined expires within two minutes.
1059
+ - `Hub.onBack(listener)` hears the host's Back control, which a game cannot hide.
1060
+ Answer it with `Hub.back()` behind the door animation; a host that hears
1061
+ nothing takes the player home itself. Returns an unsubscribe function.
1062
+
1063
+ A player Castle has barred from the hub is refused at every hub door and at the
1064
+ town itself with `CastleError` code `CAULDRON_HUB_BANNED` (from `Hub.go`,
1065
+ `Hub.back`, `Hub.jump` and the place's `Multiplayer.join`). Do not retry it.
1066
+
1067
+ `go`, `back` and `jump` return a `HubHandoffResult`: `status: "ok"` with `url`, `nonce`
1068
+ (single use, valid for 60 seconds), `sessionId`, `bundleHash` (the
1069
+ destination session's content pack hash) and `contentUrl` (that pack's immutable
1070
+ URL, or null when the destination has none), or `cancelled`, `full`,
1071
+ `unavailable`, `not_found`, `forbidden` or `party_required` with those fields null.
1072
+
1073
+ A door can also lead to a cabinet: a classic Castle deck the host plays itself.
1074
+ `Hub.go` then answers `status: "ok"` with `kind: "classic"` and null `url`, `nonce`
1075
+ and `sessionId`: there is no session to join, and the trip is already committed.
1076
+ Do not pass that handoff to `Multiplayer.join`; cover the place and wait for the
1077
+ host's Back control, which `Hub.back()` answers like any other way back. `kind` is
1078
+ absent on every other handoff.
1079
+
1080
+ Success is an acquisition; nothing has moved yet. Load the destination's content,
1081
+ then join it:
1082
+
1083
+ ```js
1084
+ import { Hub, Multiplayer } from "castle-web-sdk";
1085
+
1086
+ const handoff = await Hub.go("race");
1087
+ if (handoff.status === "ok") {
1088
+ // ...the door opens; load the destination's scene behind it...
1089
+ const connection = await Multiplayer.join({ mode: "hub", handoff });
1090
+ }
1091
+ ```
1092
+
1093
+ When that socket first says hello the SDK tells the host, which commits the trip:
1094
+ the player's presence moves and the way back is recorded, and from then on
1095
+ every SDK call speaks for the destination. `join` resolves once that is done,
1096
+ and rejects when the trip could not be completed (the player is still where they
1097
+ were). A join that fails
1098
+ before then cancels it, and the player is still where they were. A hub
1099
+ connection reconnects with its session's own token like any other, but cannot be
1100
+ acquired again: when one closes for good, go back. The engine stays in the same
1101
+ page throughout; the previous place is left, not held.
1102
+
622
1103
  ## Portal
623
1104
 
624
1105
  A portal sends the player from this deck to another Castle deck,
@@ -770,8 +1251,9 @@ be coalesced, so it's safe to call on frequent events.
770
1251
  through Castle's deck-agent service, so the deck holds no key. Castle pays
771
1252
  for each player's calls out of a daily play allowance, which Castle
772
1253
  characters draw from too; once a player has used it up, calls resolve
773
- `'allowance_exhausted'` until it resets at midnight UTC. Agent.chat is
774
- limited to Castle admins for now; every other player gets `'refused'`.
1254
+ `'allowance_exhausted'` until it resets at midnight UTC. Agent.chat answers
1255
+ Castle admins and the players Castle has given access; every other player
1256
+ gets `'refused'`.
775
1257
 
776
1258
  ### `Agent.chat(params): Promise<AgentChatResult>`
777
1259
 
@@ -862,7 +1344,8 @@ for (let round = 0; round < 8; round++) {
862
1344
  display; it is not part of `message`. `finishReason` is `'stop'`,
863
1345
  `'tool_calls'` or `'length'`. `usage` carries `prompt_tokens`,
864
1346
  `completion_tokens` and `total_tokens`.
865
- - `'refused'` — the player is not a Castle admin. The model was not called.
1347
+ - `'refused'` — the player has no access to the agent. The model was not
1348
+ called.
866
1349
  - `'allowance_exhausted'` — the player's play allowance for the day is used
867
1350
  up. The model was not called. It resets at midnight UTC.
868
1351
  - `'unavailable'` — this host has no route to the deck-agent service, or
@@ -1008,55 +1491,6 @@ Host-specific layout flags such as `CastleEmbed.feed` are deprecated.
1008
1491
  New code should let Castle hosts provide the frame and let the SDK infer
1009
1492
  whether it is running standalone or embedded.
1010
1493
 
1011
- ### Filling the screen: `<meta name="castle-display">`
1012
-
1013
- By default a deck is a 5:7 portrait card. A deck that can lay itself out at
1014
- other shapes opts out of the card in its own `index.html`:
1015
-
1016
- ```html
1017
- <meta name="castle-display" content="layout=fill">
1018
- <!-- or narrow the shapes it accepts (width / height, as 16/9, 16:9 or 1.78) -->
1019
- <meta name="castle-display" content="layout=fill, min-aspect=9/16, max-aspect=16/9">
1020
- ```
1021
-
1022
- A `fill` deck gets the host's whole frame, edge to edge with no rounded
1023
- corners, whenever that frame's aspect is inside its range (default 9/21 to
1024
- 21/9); outside it, the deck gets the largest in-range box, centered. Castle's
1025
- web player gives a fill deck a bigger stage and a fullscreen button. Hosts
1026
- that don't know the tag still give it a 5:7 frame, so a fill deck should look
1027
- right at 5:7 as well.
1028
-
1029
- Lay out against the stage: the element `initCard()` returns, or
1030
- `--castle-card-w`/`--castle-card-h` in CSS, with a resize listener. Not the
1031
- window: the stage is smaller than the window when the host frame is outside
1032
- the deck's range, and inside `castle-web serve`'s own page. `getDisplay()` returns the parsed declaration and `fitStage(w, h,
1033
- display)` the box the SDK would pick.
1034
-
1035
- Size HUD, buttons and text in the card unit, not in `vw` or `cqw`:
1036
-
1037
- ```css
1038
- .score { font-size: calc(5 * var(--castle-unit)); top: calc(3 * var(--castle-unit)); }
1039
- ```
1040
-
1041
- `--castle-unit` is 1% of the width of the largest 5:7 card that fits in the
1042
- stage. On a card, or a stage taller than a card, it is 1% of the stage width
1043
- (the same as `1vw`); on a wide stage it stops growing, so a HUD keeps its card
1044
- size while the playfield gets the extra room. Anything sized in `vw` grows with
1045
- the stage instead, and a 16:9 monitor makes it huge. Canvas code gets the same
1046
- number from `cardUnit(width, height)` (px). The SDK sets the exact value for
1047
- its own stage; in edit mode, and in a kit's scene editor that draws the stage
1048
- smaller than the window, it falls back to the window's.
1049
-
1050
- A 3D camera tuned for the card keeps its framing with `fovForAspect(fov,
1051
- aspect)`: it returns `fov` at 5:7 and wider (a wide stage shows more to the
1052
- sides) and widens it on a stage narrower than 5:7, so a tall phone still sees
1053
- the card's full width.
1054
-
1055
- ```js
1056
- camera.fov = fovForAspect(50, width / height);
1057
- camera.updateProjectionMatrix();
1058
- ```
1059
-
1060
1494
  ### `CARD_RATIO`
1061
1495
 
1062
1496
  The card aspect ratio (`5 / 7`). Use this if you need to size something
package/dist/agent.js CHANGED
@@ -6,10 +6,10 @@
6
6
  // promise resolves with the whole message. A deck runs any tool calls itself
7
7
  // and calls Agent.chat again with the results.
8
8
  //
9
- // Agent.chat is limited to Castle admins for now: every other player gets
10
- // `refused`. A player whose allowance for the day is spent gets
11
- // `allowance_exhausted`. A host with no route to the service answers
12
- // `unavailable`.
9
+ // Agent.chat answers Castle admins and the players Castle has given access;
10
+ // every other player gets `refused`. A player whose allowance for the day is
11
+ // spent gets `allowance_exhausted`. A host with no route to the service
12
+ // answers `unavailable`.
13
13
  import { CastleError } from "./errors";
14
14
  import { hostRequest } from "./transport";
15
15
  async function chat(params) {
@@ -0,0 +1,12 @@
1
+ import type { AvatarResult } from "./commands";
2
+ export type { AvatarResult } from "./commands";
3
+ export interface CastleAvatarApi {
4
+ /**
5
+ * The player's avatar: their choice, or the default every account has.
6
+ * `unavailable` for a signed-out visitor, or on a host without avatars.
7
+ */
8
+ get(): Promise<AvatarResult>;
9
+ /** Choose the avatar this player wears everywhere from now on. */
10
+ set(avatar: number): Promise<AvatarResult>;
11
+ }
12
+ export declare const Avatar: CastleAvatarApi;
package/dist/avatar.js ADDED
@@ -0,0 +1,23 @@
1
+ // Avatar — the player's platform avatar (cauldron-game-server docs/HUB.md,
2
+ // decision 4): one per account, worn in every hub place. v1 is an index into the
3
+ // hub-game kit's character templates; there is no editor and no cosmetics.
4
+ //
5
+ // The hub-game kit is the one caller: it reads the avatar to dress the player
6
+ // and offers the choice in its picker. Other players' avatars are not read
7
+ // here; a place's server learns them from ghost.
8
+ import { CastleError } from "./errors";
9
+ import { hostRequest } from "./transport";
10
+ export const Avatar = { get, set };
11
+ async function get() {
12
+ return hostRequest("avatar.get", {});
13
+ }
14
+ async function set(avatar) {
15
+ if (typeof avatar !== "number" || !Number.isInteger(avatar) || avatar < 0) {
16
+ throw new CastleError({
17
+ code: "INVALID_ARGUMENT",
18
+ message: "Avatar.set requires a template index.",
19
+ operation: "Avatar.set",
20
+ });
21
+ }
22
+ return hostRequest("avatar.set", { avatar });
23
+ }