castle-web-sdk 0.4.45 → 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 +449 -53
- package/dist/agent.js +4 -4
- package/dist/avatar.d.ts +12 -0
- package/dist/avatar.js +23 -0
- package/dist/castle.d.ts +10 -1
- package/dist/castle.js +5 -0
- package/dist/commands.d.ts +223 -0
- package/dist/commands.js +16 -0
- package/dist/context.d.ts +6 -0
- package/dist/experiments.d.ts +15 -0
- package/dist/experiments.js +83 -0
- package/dist/hub.d.ts +85 -0
- package/dist/hub.js +107 -0
- package/dist/multiplayer.d.ts +86 -6
- package/dist/multiplayer.js +209 -8
- package/dist/save.d.ts +197 -0
- package/dist/save.js +525 -0
- package/dist/saveCoordinator.d.ts +39 -0
- package/dist/saveCoordinator.js +252 -0
- package/dist/server/hub.d.ts +169 -0
- package/dist/server/hub.js +125 -0
- package/dist/server/platformHandle.d.ts +49 -2
- package/dist/server/storage.d.ts +58 -4
- package/dist/server/storage.js +25 -5
- package/dist/server/wrapper.d.ts +89 -6
- package/dist/server/wrapper.js +395 -30
- package/dist/store.d.ts +3 -2
- package/dist/store.js +5 -0
- package/dist/transport.d.ts +2 -1
- package/dist/transport.js +13 -1
- package/package.json +1 -1
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.
|
|
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,73 @@ 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
|
+
|
|
154
236
|
### Persistent worlds
|
|
155
237
|
|
|
156
238
|
An opted-in persistent-world route has `session.world`; other sessions have
|
|
@@ -189,6 +271,49 @@ leave, and shutdown presence automatically, and `participants.read` reflects
|
|
|
189
271
|
those host writes. The SDK never writes participant presence, and there is no
|
|
190
272
|
public touch method.
|
|
191
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
|
+
|
|
192
317
|
## Store
|
|
193
318
|
|
|
194
319
|
`Store` saves data one key at a time. It is the storage to use for
|
|
@@ -351,6 +476,146 @@ A value is JSON, so binary data such as a picture or a video clip costs
|
|
|
351
476
|
about a third more once encoded, and a clip of more than a few seconds
|
|
352
477
|
does not fit in one. Ship media with the deck instead.
|
|
353
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
|
+
|
|
354
619
|
## Storage
|
|
355
620
|
|
|
356
621
|
> Older API, still supported and not going away. New decks should use
|
|
@@ -564,6 +829,92 @@ if (them?.photoUrl) {
|
|
|
564
829
|
}
|
|
565
830
|
```
|
|
566
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
|
+
|
|
567
918
|
## Pass
|
|
568
919
|
|
|
569
920
|
A pass is something a creator sells to players for Castle bricks (the
|
|
@@ -657,6 +1008,98 @@ Snapshot.onTaken(() => {
|
|
|
657
1008
|
});
|
|
658
1009
|
```
|
|
659
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
|
+
|
|
660
1103
|
## Portal
|
|
661
1104
|
|
|
662
1105
|
A portal sends the player from this deck to another Castle deck,
|
|
@@ -808,8 +1251,9 @@ be coalesced, so it's safe to call on frequent events.
|
|
|
808
1251
|
through Castle's deck-agent service, so the deck holds no key. Castle pays
|
|
809
1252
|
for each player's calls out of a daily play allowance, which Castle
|
|
810
1253
|
characters draw from too; once a player has used it up, calls resolve
|
|
811
|
-
`'allowance_exhausted'` until it resets at midnight UTC. Agent.chat
|
|
812
|
-
|
|
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'`.
|
|
813
1257
|
|
|
814
1258
|
### `Agent.chat(params): Promise<AgentChatResult>`
|
|
815
1259
|
|
|
@@ -900,7 +1344,8 @@ for (let round = 0; round < 8; round++) {
|
|
|
900
1344
|
display; it is not part of `message`. `finishReason` is `'stop'`,
|
|
901
1345
|
`'tool_calls'` or `'length'`. `usage` carries `prompt_tokens`,
|
|
902
1346
|
`completion_tokens` and `total_tokens`.
|
|
903
|
-
- `'refused'` — the player
|
|
1347
|
+
- `'refused'` — the player has no access to the agent. The model was not
|
|
1348
|
+
called.
|
|
904
1349
|
- `'allowance_exhausted'` — the player's play allowance for the day is used
|
|
905
1350
|
up. The model was not called. It resets at midnight UTC.
|
|
906
1351
|
- `'unavailable'` — this host has no route to the deck-agent service, or
|
|
@@ -1046,55 +1491,6 @@ Host-specific layout flags such as `CastleEmbed.feed` are deprecated.
|
|
|
1046
1491
|
New code should let Castle hosts provide the frame and let the SDK infer
|
|
1047
1492
|
whether it is running standalone or embedded.
|
|
1048
1493
|
|
|
1049
|
-
### Filling the screen: `<meta name="castle-display">`
|
|
1050
|
-
|
|
1051
|
-
By default a deck is a 5:7 portrait card. A deck that can lay itself out at
|
|
1052
|
-
other shapes opts out of the card in its own `index.html`:
|
|
1053
|
-
|
|
1054
|
-
```html
|
|
1055
|
-
<meta name="castle-display" content="layout=fill">
|
|
1056
|
-
<!-- or narrow the shapes it accepts (width / height, as 16/9, 16:9 or 1.78) -->
|
|
1057
|
-
<meta name="castle-display" content="layout=fill, min-aspect=9/16, max-aspect=16/9">
|
|
1058
|
-
```
|
|
1059
|
-
|
|
1060
|
-
A `fill` deck gets the host's whole frame, edge to edge with no rounded
|
|
1061
|
-
corners, whenever that frame's aspect is inside its range (default 9/21 to
|
|
1062
|
-
21/9); outside it, the deck gets the largest in-range box, centered. Castle's
|
|
1063
|
-
web player gives a fill deck a bigger stage and a fullscreen button. Hosts
|
|
1064
|
-
that don't know the tag still give it a 5:7 frame, so a fill deck should look
|
|
1065
|
-
right at 5:7 as well.
|
|
1066
|
-
|
|
1067
|
-
Lay out against the stage: the element `initCard()` returns, or
|
|
1068
|
-
`--castle-card-w`/`--castle-card-h` in CSS, with a resize listener. Not the
|
|
1069
|
-
window: the stage is smaller than the window when the host frame is outside
|
|
1070
|
-
the deck's range, and inside `castle-web serve`'s own page. `getDisplay()` returns the parsed declaration and `fitStage(w, h,
|
|
1071
|
-
display)` the box the SDK would pick.
|
|
1072
|
-
|
|
1073
|
-
Size HUD, buttons and text in the card unit, not in `vw` or `cqw`:
|
|
1074
|
-
|
|
1075
|
-
```css
|
|
1076
|
-
.score { font-size: calc(5 * var(--castle-unit)); top: calc(3 * var(--castle-unit)); }
|
|
1077
|
-
```
|
|
1078
|
-
|
|
1079
|
-
`--castle-unit` is 1% of the width of the largest 5:7 card that fits in the
|
|
1080
|
-
stage. On a card, or a stage taller than a card, it is 1% of the stage width
|
|
1081
|
-
(the same as `1vw`); on a wide stage it stops growing, so a HUD keeps its card
|
|
1082
|
-
size while the playfield gets the extra room. Anything sized in `vw` grows with
|
|
1083
|
-
the stage instead, and a 16:9 monitor makes it huge. Canvas code gets the same
|
|
1084
|
-
number from `cardUnit(width, height)` (px). The SDK sets the exact value for
|
|
1085
|
-
its own stage; in edit mode, and in a kit's scene editor that draws the stage
|
|
1086
|
-
smaller than the window, it falls back to the window's.
|
|
1087
|
-
|
|
1088
|
-
A 3D camera tuned for the card keeps its framing with `fovForAspect(fov,
|
|
1089
|
-
aspect)`: it returns `fov` at 5:7 and wider (a wide stage shows more to the
|
|
1090
|
-
sides) and widens it on a stage narrower than 5:7, so a tall phone still sees
|
|
1091
|
-
the card's full width.
|
|
1092
|
-
|
|
1093
|
-
```js
|
|
1094
|
-
camera.fov = fovForAspect(50, width / height);
|
|
1095
|
-
camera.updateProjectionMatrix();
|
|
1096
|
-
```
|
|
1097
|
-
|
|
1098
1494
|
### `CARD_RATIO`
|
|
1099
1495
|
|
|
1100
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
|
|
10
|
-
// `refused`. A player whose allowance for the day is
|
|
11
|
-
// `allowance_exhausted`. A host with no route to the service
|
|
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) {
|
package/dist/avatar.d.ts
ADDED
|
@@ -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
|
+
}
|