castle-web-sdk 0.4.46 → 0.4.47

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
@@ -151,6 +151,23 @@ export default {
151
151
  support the [atomic save API](#atomic-saves). Each scope has the same
152
152
  `get`, `set`, `remove`, `increment` and `list` as `Store`.
153
153
 
154
+ `session.storage.town(userId)` lets a door deck's server read and write one
155
+ signed-in player's rows in its source town's deck scope, for things such as
156
+ hats bought in town. It has `get`, `set`, `remove` and `increment`; quota counts
157
+ against the town. Every key is one of the town's per-player namespaces
158
+ (`tokens`, `spent`, `own`, `hat`, `chirp`) followed by the user id: for user
159
+ `7`, `own:7:tophat` and `hat:7` work; `own:tophat:7` and `quay:7` do not. The
160
+ rest of the town's rows are its own. The player must be live in this session
161
+ (a player who joined a few seconds ago can briefly get
162
+ `CAULDRON_HUB_NOT_IN_SESSION`, so retry later rather than giving up); in a house, only the owner's rows are
163
+ accessible. Calls reject with `CAULDRON_HUB_NO_TOWN` without a source town
164
+ (including a town calling it), `CAULDRON_HUB_NOT_IN_SESSION` for an absent
165
+ player, or `CAULDRON_HUB_NOT_OWNER` for someone other than the house's owner.
166
+ Invalid key segments reject with `CAULDRON_STORAGE_INVALID_ARGUMENT`.
167
+ During dev serve, keys live under `town/` in the dev deck's own deck scope,
168
+ so testing a door never changes a real town. An unpublished deck still needs
169
+ a published deck id for storage.
170
+
154
171
  Two differences follow from a session server not being a player:
155
172
 
156
173
  - **It owns `deck` scope.** Once a deck has a published server, clients read
@@ -173,6 +190,10 @@ kit's contract). `save-deck` publishes the block's titles and result shapes with
173
190
  the server bundle, and Castle pins them to every session of the place. Those
174
191
  sessions have `session.hub`; every other session has `session.hub === null`.
175
192
 
193
+ `session.hub.owner` is the owner's user id string on an owner door instance,
194
+ passed by the platform. It is absent on every other hub session, including the
195
+ dev serve.
196
+
176
197
  - `session.hub.place`, `.title` and `.result` are the place and the result shape
177
198
  as published (`result` is `null` for a place that reports none, such as a town).
178
199
  - `session.hub.arrival(playerId)` is how a player came in when Castle placed them
@@ -199,6 +220,43 @@ sessions have `session.hub`; every other session has `session.hub === null`.
199
220
  id the kit draws, `color` `'#rrggbb'`, `rarity` 0-9), or clears it with `null`;
200
221
  the latest wins. Castle takes it only from a curated hub place. Resolves
201
222
  `{ wearable }`; `readResults` returns it as `players[userId].wearable`.
223
+ - `session.hub.unlock(playerId, itemId)` gives a signed-in player one of the
224
+ deck's declared earned items (castle.json `hub.unlocks`: `{ id, class, shape,
225
+ name, deed, color }`, at most 8, `class` one of `badge`, `pin`, `belt`, `sash`,
226
+ `held`). v1.70 also permits up to 6 home items separately: `furniture` or
227
+ `deed`, with `piece` in place of `shape` (the same lowercase id format).
228
+ Each id is unique across both lists. Castle takes it only from a curated hub
229
+ place and only for an id the saved deck declares; a first wearable unlock
230
+ is also shown. Resolves
231
+ `{ unlocked: true, isNew, earned }`, where `earned` is what the player now
232
+ shows (`{ deckId, id, class, shape, name, deed, color }`) or `null`, including
233
+ for home grants. A home grant never changes what the avatar shows; a home
234
+ item appears only in the owned list returned by `readUnlocks`.
235
+ `readResults` returns the shown one as `players[userId].earned`, absent when
236
+ none.
237
+ - `session.hub.wearEarned(playerId, { deckId, id } | null)` chooses which owned
238
+ earned item a player shows, or none. Castle takes it only from the town.
239
+ Home items are refused with `CAULDRON_HUB_EARNED_NOT_WEARABLE`.
240
+ Resolves `{ earned }`.
241
+ - `session.hub.readUnlocks(userIds)` reads the earned items players here own:
242
+ `{ players: { [userId]: { owned: [...] } } }`, including home items with
243
+ their `class` and `piece`. `MultiplayerHubEarnedItem` and
244
+ `MultiplayerHubEarnedClass` describe wearable items only;
245
+ `MultiplayerHubHomeItem` and `MultiplayerHubHomeClass` describe home items.
246
+ Only the owned list uses the union of both item types; unlock/wear receipts
247
+ and arrival results keep the wearable-only type.
248
+ - These three reject with `CAULDRON_HUB_UNAVAILABLE` on a runtime without them.
249
+ - `session.hub.friendsAmong(playerId, userIds)` resolves the subset of candidate
250
+ user ids that are this connected player's friends (`Promise<string[]>`).
251
+ The platform checks mutual follows, age, blocks, the hub gate and the player's
252
+ recent client friends ask. It uses `hubFriendsAmong` or the older runtime's
253
+ storage broker and rejects on platform errors. The hub-game kit wraps it with
254
+ decimal-id filtering, a 100-candidate cap, caching and a silent `[]` fallback.
255
+ - `session.hub.relationsAmong(playerId, userIds)` makes the same single host read
256
+ and resolves `{ friends, follows, followedBy }`, each limited to asked string
257
+ ids. Missing lists are empty on older hosts. Mutual friends keep the chat age
258
+ and block rules; follows in either direction require no block, with no age rule.
259
+ These lists belong to the viewer only and must never go to another client.
202
260
  - `session.hub.hiddenPair(playerIdA, playerIdB)` is true when a block hides these
203
261
  two players from each other (either one blocked the other). Never send either
204
262
  one the other's pose, avatar or name. The block lists themselves never reach
@@ -895,7 +953,7 @@ itself (ones Castle runs across decks), on the serve only.
895
953
 
896
954
  The player's platform avatar: one per account, worn in every hub place
897
955
  (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
956
+ hub-game kit's character templates, with an optional colour choice. The
899
957
  hub-game kit is the caller: it dresses the player and offers the choice in its
900
958
  picker. Other players' avatars are not read here; a place's server learns them
901
959
  from ghost.
@@ -907,7 +965,7 @@ every account has (`chosen: false`). Anonymous accounts have one too.
907
965
  `{ status: "unavailable", avatar: null }` for a signed-out visitor; a host from
908
966
  before avatars rejects with `UNKNOWN_COMMAND`. Either way the deck picks its own.
909
967
 
910
- ### `Avatar.set(avatar): Promise<AvatarResult>`
968
+ ### `Avatar.set(avatar, options?: { color?: number }): Promise<AvatarResult>`
911
969
 
912
970
  Makes `avatar` the account's from now on, everywhere. Only a curated hub place
913
971
  (a deck an enabled hub door leads into or out of) may: anywhere else, and for a
@@ -915,6 +973,15 @@ signed-out visitor, the answer is `unavailable` and nothing changes. Ghost
915
973
  refuses an index outside its template set (`INVALID_ARGUMENT`) and more than 30
916
974
  choices a minute (`RATE_LIMIT_EXCEEDED`).
917
975
 
976
+ A host that supports avatar colours includes `color` in `AvatarResult`; the
977
+ field is absent from older hosts. `Avatar.colors === true` marks an SDK whose
978
+ `Avatar.set` carries `{ color }`; the hub-game kit requires both the host field
979
+ and this marker to offer platform colours. Only after that support is known, pass
980
+ `{ color }` to choose a non-negative integer colour for the current avatar.
981
+ The host accepts 0–255; ghost holds the offered range. Changing character
982
+ without options clears its colour. Omitting colour sends the existing command
983
+ and mutation unchanged. The read query will ask for colour once ghost serves it.
984
+
918
985
  ## Pass
919
986
 
920
987
  A pass is something a creator sells to players for Castle bricks (the
@@ -1625,3 +1692,13 @@ Common codes:
1625
1692
  that lacks it.
1626
1693
  - `CAULDRON_STORAGE_STALE_GENERATION` — the handle belongs to an older reset
1627
1694
  generation; reopen explicitly rather than replaying work into the new one.
1695
+
1696
+ ### Your games: hub hearts
1697
+
1698
+ `Hub.hearts()` returns `{ status: 'ok', hearts: string[] }` for the signed-in
1699
+ account. `Hub.setHearts(doorIds)` replaces that list with at most eight distinct
1700
+ door ids, oldest first. The host supplies the current deck id; only curated hub
1701
+ places may save hearts. Guests and unavailable hosts return
1702
+ `{ status: 'unavailable', hearts: [] }`; older hosts throw `UNKNOWN_COMMAND`.
1703
+ Invalid lists throw `INVALID_ARGUMENT`, and other host failures use `CastleError`.
1704
+ The dev serve keeps hearts per account in memory until it stops.
package/dist/avatar.d.ts CHANGED
@@ -1,12 +1,16 @@
1
1
  import type { AvatarResult } from "./commands";
2
2
  export type { AvatarResult } from "./commands";
3
3
  export interface CastleAvatarApi {
4
+ /** Present only in an SDK whose Avatar.set carries { color }; the hub-game kit offers colours only when it is here. */
5
+ readonly colors?: true;
4
6
  /**
5
7
  * The player's avatar: their choice, or the default every account has.
6
8
  * `unavailable` for a signed-out visitor, or on a host without avatars.
7
9
  */
8
10
  get(): Promise<AvatarResult>;
9
- /** Choose the avatar this player wears everywhere from now on. */
10
- set(avatar: number): Promise<AvatarResult>;
11
+ /** Choose the avatar, and optionally its colour, worn everywhere. Requires a host with colours for options.color. */
12
+ set(avatar: number, options?: {
13
+ color?: number;
14
+ }): Promise<AvatarResult>;
11
15
  }
12
16
  export declare const Avatar: CastleAvatarApi;
package/dist/avatar.js CHANGED
@@ -1,17 +1,17 @@
1
1
  // Avatar — the player's platform avatar (cauldron-game-server docs/HUB.md,
2
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.
3
+ // hub-game kit's character templates, with optional colour choices.
4
4
  //
5
5
  // The hub-game kit is the one caller: it reads the avatar to dress the player
6
6
  // and offers the choice in its picker. Other players' avatars are not read
7
7
  // here; a place's server learns them from ghost.
8
8
  import { CastleError } from "./errors";
9
9
  import { hostRequest } from "./transport";
10
- export const Avatar = { get, set };
10
+ export const Avatar = { get, set, colors: true };
11
11
  async function get() {
12
12
  return hostRequest("avatar.get", {});
13
13
  }
14
- async function set(avatar) {
14
+ async function set(avatar, options) {
15
15
  if (typeof avatar !== "number" || !Number.isInteger(avatar) || avatar < 0) {
16
16
  throw new CastleError({
17
17
  code: "INVALID_ARGUMENT",
@@ -19,5 +19,13 @@ async function set(avatar) {
19
19
  operation: "Avatar.set",
20
20
  });
21
21
  }
22
- return hostRequest("avatar.set", { avatar });
22
+ const color = options?.color;
23
+ if (color !== undefined && (!Number.isInteger(color) || color < 0)) {
24
+ throw new CastleError({
25
+ code: "INVALID_ARGUMENT",
26
+ message: "Avatar.set requires a non-negative integer colour.",
27
+ operation: "Avatar.set",
28
+ });
29
+ }
30
+ return hostRequest("avatar.set", color === undefined ? { avatar } : { avatar, color });
23
31
  }
package/dist/castle.d.ts CHANGED
@@ -13,7 +13,7 @@ export { deckPrefixOf, IMPORTS_DIR, IMPORTS_PREFIX, isImportedFile, resolveDeckF
13
13
  export { Haptics } from "./haptics";
14
14
  export type { CastleHapticsApi, HapticsResult, HapticsStatus, HapticStyle } from "./haptics";
15
15
  export { Hub } from "./hub";
16
- export type { CastleHubApi, HubBackResult, HubCancelResult, HubCancelStatus, HubChatMessage, HubChatResult, HubDoor, HubDoorsResult, HubFriend, HubFriendsResult, HubGoParams, HubGoResult, HubHandoffResult, HubHandoffStatus, HubJumpResult, HubPlacementKind, HubReportResult, HubSayResult, } from "./hub";
16
+ export type { CastleHubApi, HubBackResult, HubCancelResult, HubCancelStatus, HubChatMessage, HubChatResult, HubDoor, HubDoorsResult, HubFriend, HubFriendsResult, HubHeartsResult, HubGoParams, HubGoResult, HubHandoffResult, HubHandoffStatus, HubJumpResult, HubPlacementKind, HubReportResult, HubSayResult, } from "./hub";
17
17
  export { Leaderboard } from "./leaderboard";
18
18
  export type { LeaderboardData, LeaderboardEntry, LeaderboardOptions, LeaderboardScope, LeaderboardShowOptions, LeaderboardShowResult, LeaderboardShowStatus, LeaderboardSort, } from "./leaderboard";
19
19
  export { Lifecycle } from "./lifecycle";
@@ -158,6 +158,11 @@ export type HubFriendsResult = {
158
158
  status: "unavailable";
159
159
  friends: null;
160
160
  };
161
+ /** Account-wide hub door hearts, in oldest-first order (at most eight). */
162
+ export interface HubHeartsResult {
163
+ status: "ok" | "unavailable";
164
+ hearts: string[];
165
+ }
161
166
  /** Hub.chat: only the messages after a cursor an earlier answer returned. */
162
167
  export interface HubChatParams {
163
168
  after?: string;
@@ -508,6 +513,7 @@ export interface CommandParams {
508
513
  "avatar.get": Record<string, never>;
509
514
  "avatar.set": {
510
515
  avatar: number;
516
+ color?: number;
511
517
  };
512
518
  "pass.offer": {
513
519
  passId: string;
@@ -530,6 +536,10 @@ export interface CommandParams {
530
536
  "hub.back": Record<string, never>;
531
537
  "hub.doors": Record<string, never>;
532
538
  "hub.friends": Record<string, never>;
539
+ "hub.hearts": Record<string, never>;
540
+ "hub.setHearts": {
541
+ doorIds: string[];
542
+ };
533
543
  "hub.jump": HubJumpParams;
534
544
  "hub.report": HubReportParams;
535
545
  "hub.chat": HubChatParams;
@@ -700,6 +710,8 @@ export interface CommandResult {
700
710
  "hub.back": HubBackResult;
701
711
  "hub.doors": HubDoorsResult;
702
712
  "hub.friends": HubFriendsResult;
713
+ "hub.hearts": HubHeartsResult;
714
+ "hub.setHearts": HubHeartsResult;
703
715
  "hub.jump": HubJumpResult;
704
716
  "hub.report": HubReportResult;
705
717
  "hub.chat": HubChatResult;
@@ -775,6 +787,8 @@ export interface UserPayload {
775
787
  color: string | null;
776
788
  }
777
789
  export interface AvatarResult {
790
+ /** Absent from a host before avatar colours. */
791
+ color?: number;
778
792
  status: "ok" | "unavailable";
779
793
  avatar: number | null;
780
794
  /** False while the player wears the default ghost gives every account. */
package/dist/hub.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { HubBackResult, HubCancelResult, HubChatResult, HubDoorsResult, HubFriendsResult, HubGoResult, HubJumpResult, HubReportResult, HubSayResult } from "./commands";
2
- export type { HubBackResult, HubCancelResult, HubCancelStatus, HubChatMessage, HubChatResult, HubDoor, HubDoorsResult, HubFriend, HubFriendsResult, HubGoParams, HubGoResult, HubHandoffResult, HubHandoffStatus, HubJumpResult, HubPlacementKind, HubReportResult, HubSayResult, } from "./commands";
1
+ import type { HubBackResult, HubCancelResult, HubChatResult, HubDoorsResult, HubFriendsResult, HubHeartsResult, HubGoResult, HubJumpResult, HubReportResult, HubSayResult } from "./commands";
2
+ export type { HubBackResult, HubCancelResult, HubCancelStatus, HubChatMessage, HubChatResult, HubDoor, HubDoorsResult, HubFriend, HubFriendsResult, HubHeartsResult, HubGoParams, HubGoResult, HubHandoffResult, HubHandoffStatus, HubJumpResult, HubPlacementKind, HubReportResult, HubSayResult, } from "./commands";
3
3
  /**
4
4
  * Doors between hub places. Every hub-kit deck is a place; ghost's door table
5
5
  * is the allowlist, and the host supplies real authentication and the current
@@ -35,6 +35,10 @@ export interface CastleHubApi {
35
35
  * on hosts without hub support.
36
36
  */
37
37
  friends(): Promise<HubFriendsResult>;
38
+ /** Read this signed-in account's hub door hearts. */
39
+ hearts(): Promise<HubHeartsResult>;
40
+ /** Replace the hearts, oldest first, at most eight distinct door ids. */
41
+ setHearts(doorIds: string[]): Promise<HubHeartsResult>;
38
42
  /**
39
43
  * Acquire the layer of this place a friend is in. The destination is this
40
44
  * same place in another session; join it exactly like a door's
package/dist/hub.js CHANGED
@@ -5,6 +5,8 @@ export const Hub = {
5
5
  back,
6
6
  doors,
7
7
  friends,
8
+ hearts,
9
+ setHearts,
8
10
  jump,
9
11
  report,
10
12
  chat,
@@ -32,6 +34,17 @@ async function doors() {
32
34
  async function friends() {
33
35
  return hostRequest("hub.friends", {});
34
36
  }
37
+ async function hearts() {
38
+ return hostRequest("hub.hearts", {});
39
+ }
40
+ async function setHearts(doorIds) {
41
+ if (!Array.isArray(doorIds) || doorIds.length > 8 ||
42
+ doorIds.some((id) => typeof id !== "string" || !id.length || id.length > 64) ||
43
+ new Set(doorIds).size !== doorIds.length) {
44
+ throw new CastleError({ code: "INVALID_ARGUMENT", message: "Hub.setHearts requires at most eight distinct door ids.", operation: "Hub.setHearts" });
45
+ }
46
+ return hostRequest("hub.setHearts", { doorIds });
47
+ }
35
48
  async function jump(userId) {
36
49
  if (typeof userId !== "string" || userId.length === 0) {
37
50
  throw new CastleError({
@@ -7,6 +7,13 @@ const HELLO_TIMEOUT_MS = 10_000;
7
7
  const RECONNECT_BUDGET_MS = 30_000;
8
8
  const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];
9
9
  const INVALID_DEV_RECONNECT_CLOSE_CODE = 4003;
10
+ // The play proxy closes a handshake it refuses with a reason (cauldron-game-server
11
+ // shared/src/constants.ts). The host that owns the session rejecting the credential is
12
+ // final. "Session ended" is ghost's routing verdict, which a host drain or a ghost
13
+ // error can also produce, so it takes two in a row before the session is given up on.
14
+ const PROXY_CREDENTIAL_REJECTED_CLOSE_CODE = 4401;
15
+ const PROXY_SESSION_ENDED_CLOSE_CODE = 4404;
16
+ const SESSION_ENDED_CONFIRMATIONS = 2;
10
17
  // The host may spend the full 15-second grant window waiting for author admission,
11
18
  // then up to 10 seconds on the fenced control-plane request. Keep the client above
12
19
  // that complete lifecycle so it cannot discard a valid durable receipt.
@@ -623,6 +630,7 @@ class MultiplayerConnectionImpl {
623
630
  async recover() {
624
631
  const startedAt = Date.now();
625
632
  let attempt = 0;
633
+ let sessionEndedCloses = 0;
626
634
  while (Date.now() - startedAt < RECONNECT_BUDGET_MS) {
627
635
  const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
628
636
  attempt += 1;
@@ -633,7 +641,15 @@ class MultiplayerConnectionImpl {
633
641
  return;
634
642
  }
635
643
  catch (error) {
636
- if (error instanceof SocketClosedError && error.code === 1008)
644
+ const code = error instanceof SocketClosedError ? error.code : null;
645
+ if (code === 1008 || code === PROXY_CREDENTIAL_REJECTED_CLOSE_CODE)
646
+ break;
647
+ // Retrying a session that is gone only spends the budget; ask for a fresh join.
648
+ // Any other failure in between means the two refusals were not in a row.
649
+ sessionEndedCloses = code === PROXY_SESSION_ENDED_CLOSE_CODE
650
+ ? sessionEndedCloses + 1
651
+ : 0;
652
+ if (sessionEndedCloses >= SESSION_ENDED_CONFIRMATIONS)
637
653
  break;
638
654
  }
639
655
  }
package/dist/save.js CHANGED
@@ -60,7 +60,8 @@ const MAX_SUBJECT_BYTES = 256;
60
60
  const MAX_VALUE_BYTES = 256 * 1024;
61
61
  const encoder = new TextEncoder();
62
62
  const byteLength = (text) => encoder.encode(text).length;
63
- const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
63
+ // Unicode mode consumes valid pairs together; avoid lookbehind for Safari < 16.4.
64
+ const LONE_SURROGATE = /[\uD800-\uDFFF]/u;
64
65
  function invalid(message) {
65
66
  throw new SaveError({
66
67
  code: "CAULDRON_STORAGE_INVALID_ARGUMENT",
@@ -64,6 +64,52 @@ export interface MultiplayerHubWearReceipt {
64
64
  /** What the player now wears; null when cleared. */
65
65
  wearable: MultiplayerHubWearable | null;
66
66
  }
67
+ /** The classes of earned item a hub game may give (contract v1.53). */
68
+ export type MultiplayerHubEarnedClass = "badge" | "pin" | "belt" | "sash" | "held";
69
+ /** The home classes a hub game may give (contract v1.70/v1.73), never worn. */
70
+ export type MultiplayerHubHomeClass = "furniture" | "deed" | "pet";
71
+ /**
72
+ * An earned item: something a hub game gave a player for a stated deed, declared
73
+ * in that game's castle.json `hub.unlocks` and kept by Castle per account. It has
74
+ * no rarity. `deckId` is the game that declared it; `id` and `shape` match
75
+ * /^[a-z][a-z0-9-]{0,23}$/, `color` is '#rrggbb'.
76
+ */
77
+ export interface MultiplayerHubEarnedItem {
78
+ deckId: string;
79
+ id: string;
80
+ class: MultiplayerHubEarnedClass;
81
+ shape: string;
82
+ name: string;
83
+ deed: string;
84
+ color: string;
85
+ }
86
+ /** A furniture piece, area deed or adopted pet owned for the house, never shown on an avatar. */
87
+ export interface MultiplayerHubHomeItem {
88
+ deckId: string;
89
+ id: string;
90
+ class: MultiplayerHubHomeClass;
91
+ piece: string;
92
+ name: string;
93
+ deed: string;
94
+ color: string;
95
+ }
96
+ export interface MultiplayerHubUnlockReceipt {
97
+ unlocked: true;
98
+ /** True when this call gave the player the item for the first time. */
99
+ isNew: boolean;
100
+ /** What the player now shows in the earned slot (never a home item); null when none. */
101
+ earned: MultiplayerHubEarnedItem | null;
102
+ }
103
+ export interface MultiplayerHubWearEarnedReceipt {
104
+ /** What the player now shows in the earned slot; null when cleared. */
105
+ earned: MultiplayerHubEarnedItem | null;
106
+ }
107
+ export interface MultiplayerHubUnlocksRead {
108
+ /** Each player's owned wearable and home items, by user id. */
109
+ players: Record<string, {
110
+ owned: (MultiplayerHubEarnedItem | MultiplayerHubHomeItem)[];
111
+ }>;
112
+ }
67
113
  export interface MultiplayerHubPadUpdateInput {
68
114
  /** A pad door of this place, from ghost's door table. */
69
115
  doorId: string;
@@ -91,8 +137,12 @@ export interface MultiplayerHubPlayerResults {
91
137
  records: MultiplayerHubRecord[];
92
138
  /** The player's platform avatar, chosen or default; null when ghost has none to say. */
93
139
  avatar: number | null;
140
+ /** Account-wide door hearts, oldest first; absent on older ghosts. */
141
+ hearts?: string[];
94
142
  /** The player's wearable; null when none. Absent from a ghost older than wearables. */
95
143
  wearable?: MultiplayerHubWearable | null;
144
+ /** The earned item the player shows (contract v1.53); absent when none. */
145
+ earned?: MultiplayerHubEarnedItem;
96
146
  }
97
147
  export interface MultiplayerHubBoard {
98
148
  toDeckId: string;
@@ -134,6 +184,8 @@ export interface MultiplayerHubResultsRead {
134
184
  * and for a deck that declares no hub block.
135
185
  */
136
186
  export interface MultiplayerServerHub {
187
+ /** The owner user id, only in an owner door instance. */
188
+ readonly owner?: string;
137
189
  readonly place: string | null;
138
190
  readonly title: string | null;
139
191
  readonly result: MultiplayerHubResultShape | null;
@@ -156,6 +208,41 @@ export interface MultiplayerServerHub {
156
208
  * without it).
157
209
  */
158
210
  wear(playerId: string, wearable: MultiplayerHubWearable | null): Promise<MultiplayerHubWearReceipt>;
211
+ /**
212
+ * Gives a signed-in player one of this deck's declared earned items (castle.json
213
+ * `hub.unlocks`), by its id. Castle takes it only from a curated hub place, for a
214
+ * declared id; a first wearable unlock is also shown. Home items are never worn.
215
+ * Rejects with a `CAULDRON_HUB_*` code
216
+ * (`CAULDRON_HUB_UNAVAILABLE` on a runtime without it).
217
+ */
218
+ unlock(playerId: string, itemId: string): Promise<MultiplayerHubUnlockReceipt>;
219
+ /**
220
+ * Chooses which owned earned item a signed-in player shows, or none with null.
221
+ * Castle takes it only from the town; home items are refused.
222
+ * Rejects with a `CAULDRON_HUB_*` code
223
+ * (`CAULDRON_HUB_UNAVAILABLE` on a runtime without it).
224
+ */
225
+ wearEarned(playerId: string, earned: {
226
+ deckId: string;
227
+ id: string;
228
+ } | null): Promise<MultiplayerHubWearEarnedReceipt>;
229
+ /**
230
+ * The earned items players here own, by user id. Rejects with a `CAULDRON_HUB_*`
231
+ * code (`CAULDRON_HUB_UNAVAILABLE` on a runtime without it).
232
+ */
233
+ readUnlocks(userIds: readonly string[]): Promise<MultiplayerHubUnlocksRead>;
234
+ /** The connected player's friends among these candidates; platform refusals reject. */
235
+ friendsAmong(playerId: string, userIds: readonly string[]): Promise<string[]>;
236
+ /**
237
+ * The connected player's mutual friends and follows in either direction among
238
+ * these candidates, in one host read. Missing lists are empty; refusals reject.
239
+ * These relations belong to this player only; never send them to another client.
240
+ */
241
+ relationsAmong(playerId: string, userIds: readonly string[]): Promise<{
242
+ friends: string[];
243
+ follows: string[];
244
+ followedBy: string[];
245
+ }>;
159
246
  /**
160
247
  * Whether a block hides these two players from each other: either one blocked
161
248
  * the other, in either direction. Symmetric. The block lists themselves stay
@@ -39,6 +39,13 @@ function hubHiddenUserIds(raw) {
39
39
  }
40
40
  return ids.size > 0 ? ids : null;
41
41
  }
42
+ function unavailable(op, what) {
43
+ return new CastleError({
44
+ code: "CAULDRON_HUB_UNAVAILABLE",
45
+ message: `This host's runtime cannot ${what}.`,
46
+ operation: `session.hub.${op}`,
47
+ });
48
+ }
42
49
  export function createServerHub(platformHandle, arrivals, hiddenPair = () => false) {
43
50
  const pinned = platformHandle.hub;
44
51
  if (!pinned ||
@@ -69,12 +76,41 @@ export function createServerHub(platformHandle, arrivals, hiddenPair = () => fal
69
76
  place: typeof pinned.place === "string" ? pinned.place : null,
70
77
  title: typeof pinned.title === "string" ? pinned.title : null,
71
78
  result: pinned.result ?? null,
79
+ ...(typeof pinned.owner === "string" && pinned.owner.length > 0 ? { owner: pinned.owner } : {}),
72
80
  arrival(playerId) {
73
81
  return arrivals.get(playerId) ?? null;
74
82
  },
75
83
  hiddenPair(playerIdA, playerIdB) {
76
84
  return hiddenPair(playerIdA, playerIdB);
77
85
  },
86
+ async friendsAmong(playerId, userIds) {
87
+ const json = JSON.stringify({ playerId, userIds: [...userIds] });
88
+ const raw = typeof platformHandle.hubFriendsAmong === "function"
89
+ ? await platformHandle.hubFriendsAmong(json)
90
+ : await platformHandle.storage?.("hubFriendsAmong", json);
91
+ const answer = JSON.parse(raw ?? "null");
92
+ return Array.isArray(answer?.userIds) ? answer.userIds : [];
93
+ },
94
+ async relationsAmong(playerId, userIds) {
95
+ const asked = new Set(userIds);
96
+ const json = JSON.stringify({ playerId, userIds: [...userIds] });
97
+ let raw;
98
+ if (typeof platformHandle.hubFriendsAmong === "function") {
99
+ raw = await platformHandle.hubFriendsAmong(json);
100
+ }
101
+ else {
102
+ raw = await platformHandle.storage?.("hubFriendsAmong", json);
103
+ }
104
+ const answer = JSON.parse(raw ?? "null");
105
+ const list = (value) => Array.isArray(value)
106
+ ? value.filter((id) => typeof id === "string" && asked.has(id))
107
+ : [];
108
+ return {
109
+ friends: list(answer?.userIds),
110
+ follows: list(answer?.follows),
111
+ followedBy: list(answer?.followedBy),
112
+ };
113
+ },
78
114
  async reportResult(playerId, result) {
79
115
  const { place, outcome, score, stats } = result ?? {};
80
116
  return (await call("hubResult", {
@@ -121,5 +157,39 @@ export function createServerHub(platformHandle, arrivals, hiddenPair = () => fal
121
157
  wearable: (receipt.wearable ?? null),
122
158
  };
123
159
  },
160
+ async unlock(playerId, itemId) {
161
+ if (typeof platformHandle.hubUnlock !== "function") {
162
+ throw unavailable("hubUnlock", "give earned items");
163
+ }
164
+ const receipt = await call("hubUnlock", { playerId, itemId });
165
+ return {
166
+ unlocked: true,
167
+ isNew: receipt.isNew === true,
168
+ earned: (receipt.earned ?? null),
169
+ };
170
+ },
171
+ async wearEarned(playerId, earned) {
172
+ if (typeof platformHandle.hubWearEarned !== "function") {
173
+ throw unavailable("hubWearEarned", "choose earned items");
174
+ }
175
+ const receipt = await call("hubWearEarned", {
176
+ playerId,
177
+ earned: earned === null || earned === undefined
178
+ ? null
179
+ : { deckId: earned.deckId, id: earned.id },
180
+ });
181
+ return {
182
+ earned: (receipt.earned ?? null),
183
+ };
184
+ },
185
+ async readUnlocks(userIds) {
186
+ if (typeof platformHandle.hubUnlocksRead !== "function") {
187
+ throw unavailable("hubUnlocksRead", "read earned items");
188
+ }
189
+ const read = await call("hubUnlocksRead", { userIds: [...(userIds ?? [])] });
190
+ return {
191
+ players: (read.players ?? {}),
192
+ };
193
+ },
124
194
  });
125
195
  }
@@ -53,6 +53,7 @@ export interface PlatformHandle {
53
53
  on(event: "worldCharacterTurn", callback: (request: PlatformWorldCharacterTurn) => unknown): void;
54
54
  on(event: "worldCharacterCommand", callback: (request: PlatformWorldCharacterCommand) => unknown): void;
55
55
  on(event: "worldCharacterRead", callback: (request: PlatformWorldCharacterRead) => unknown): void;
56
+ on(event: "worldCharacterTool", callback: (request: PlatformWorldCharacterTool) => unknown): void;
56
57
  /**
57
58
  * Deck storage. Strings in and strings out, like the rest of this handle, so a
58
59
  * runtime other than the Node shim can implement it without knowing what storage is.
@@ -68,12 +69,18 @@ export interface PlatformHandle {
68
69
  /** Persistent-world checkpoint broker. The host stamps the hidden world binding. */
69
70
  worldCheckpointRead?(): Promise<string>;
70
71
  worldCheckpointCommit?(argsJson: string): Promise<string>;
71
- worldCharacterSend?(grantId: string): Promise<string>;
72
+ /** `tools` is the turn's place tools, passed only when non-empty. */
73
+ worldCharacterSend?(grantId: string, tools?: readonly PlatformPlaceTool[]): Promise<string>;
72
74
  worldCharacterCommand?(grantId: string): Promise<string>;
73
75
  worldCharacterRead?(grantId: string): Promise<string>;
74
76
  worldCharacterRuntimeCreate?(argsJson: string): Promise<string>;
75
77
  worldCharacterRuntimeList?(): Promise<string>;
76
78
  worldCharacterReject?(grantId: string, code?: string): void;
79
+ /**
80
+ * Answers a `worldCharacterTool` event with at most 1 KiB of UTF-8. Present only
81
+ * when the host serves place tools, which is also what lets a send declare them.
82
+ */
83
+ worldCharacterToolResult?(requestId: string, result: string): Promise<string>;
77
84
  /**
78
85
  * The hub place this session runs, as ghost pinned it from the published
79
86
  * castle.json `hub` block. Present only when the host services the hub ops
@@ -83,8 +90,16 @@ export interface PlatformHandle {
83
90
  hubResult?(argsJson: string): Promise<string>;
84
91
  hubPadUpdate?(argsJson: string): Promise<string>;
85
92
  hubResultsRead?(argsJson: string): Promise<string>;
93
+ /** Friends among candidates (hub contract v1.36); older images use storage. */
94
+ hubFriendsAmong?(argsJson: string): Promise<string>;
86
95
  /** Absent on a runtime older than wearables (contract v1.4). */
87
96
  hubWear?(argsJson: string): Promise<string>;
97
+ /** Absent on a runtime older than earned items (contract v1.53). */
98
+ hubUnlock?(argsJson: string): Promise<string>;
99
+ /** Absent on a runtime older than earned items (contract v1.53). */
100
+ hubWearEarned?(argsJson: string): Promise<string>;
101
+ /** Absent on a runtime older than earned items (contract v1.53). */
102
+ hubUnlocksRead?(argsJson: string): Promise<string>;
88
103
  readonly config: PlatformSessionConfig;
89
104
  }
90
105
  export interface PlatformHubResultShape {
@@ -98,6 +113,7 @@ export interface PlatformHubResultShape {
98
113
  stats: Record<string, "number" | "string">;
99
114
  }
100
115
  export interface PlatformHubPlace {
116
+ owner?: string;
101
117
  place?: string;
102
118
  title?: string | null;
103
119
  result?: PlatformHubResultShape | null;
@@ -124,6 +140,20 @@ export interface PlatformWorldCharacterRead {
124
140
  };
125
141
  query: unknown;
126
142
  }
143
+ /** A lookup a place declares for one character turn. */
144
+ export interface PlatformPlaceTool {
145
+ name: string;
146
+ description: string;
147
+ input_schema: Record<string, unknown>;
148
+ }
149
+ export interface PlatformWorldCharacterTool {
150
+ requestId: string;
151
+ operationId: string;
152
+ characterId: string;
153
+ playerId: string;
154
+ name: string;
155
+ input: Record<string, unknown>;
156
+ }
127
157
  export interface PlatformWorldCharacterTurn {
128
158
  grantId: string;
129
159
  requestId: string;
@@ -23,6 +23,12 @@ export interface ServerStorageScope extends SaveBucketApi {
23
23
  increment(key: string, delta?: number): Promise<number>;
24
24
  list<T extends Json = Json>(options?: ServerStorageListOptions): Promise<ServerStorageListPage<T>>;
25
25
  }
26
+ export interface ServerTownStorage {
27
+ get<T extends Json = Json>(keys: string[]): Promise<Record<string, T>>;
28
+ set(values: Record<string, Json>): Promise<void>;
29
+ remove(keys: string[]): Promise<void>;
30
+ increment(key: string, delta?: number): Promise<number>;
31
+ }
26
32
  export interface ServerStorageBoardScore {
27
33
  subject: string;
28
34
  score: number;
@@ -263,6 +269,13 @@ export interface ServerStorage extends SaveApi {
263
269
  readonly deck: ServerStorageScope;
264
270
  /** One player's public data. Readable by anyone; writable by them or by this server. */
265
271
  user(userId: string): ServerStorageScope;
272
+ /**
273
+ * The door deck's source town's deck-scope rows, for one player in this session.
274
+ * Only a door deck's server may call it; a town gets CAULDRON_HUB_NO_TOWN.
275
+ * In an owner house, only the owner's rows are accessible. Quota counts against
276
+ * the town. Each key must be tokens|spent|own|hat|chirp, then userId, as its first two segments.
277
+ */
278
+ town(userId: string): ServerTownStorage;
266
279
  board(name: string): ServerStorageBoard;
267
280
  /** Opens one player's experimental, generation-bound game namespace. */
268
281
  playthrough(userId: string): Promise<ServerPlaythrough>;
@@ -91,6 +91,9 @@ export function createServerStorage(platformHandle) {
91
91
  }
92
92
  return createScope(call, "USER", userId, saveApi);
93
93
  },
94
+ town(userId) {
95
+ return createTownStorage(call, userId);
96
+ },
94
97
  board(name) {
95
98
  return createBoard(call, name);
96
99
  },
@@ -243,6 +246,49 @@ function createPlaythrough(call, subject, generation) {
243
246
  },
244
247
  });
245
248
  }
249
+ /** The town's per-player namespaces a door deck may reach; ghost holds the same list. */
250
+ const TOWN_PLAYER_NAMESPACES = ["tokens", "spent", "own", "hat", "chirp"];
251
+ function createTownStorage(call, subject) {
252
+ const args = { scope: "DECK", subject, place: "town" };
253
+ // Validate in the async verbs, so a bad subject poisons the handle without
254
+ // throwing when the author creates it. Validate every key before any chunk lands.
255
+ const keysFor = (keys, operation) => {
256
+ assertUserId(subject, operation);
257
+ const checked = assertKeys(keys, operation);
258
+ for (const key of checked) {
259
+ const segments = key.split(":");
260
+ if (!TOWN_PLAYER_NAMESPACES.includes(segments[0]) || segments[1] !== subject) {
261
+ throw storageError("CAULDRON_STORAGE_INVALID_ARGUMENT", `Town storage key ${JSON.stringify(key)} must be ${TOWN_PLAYER_NAMESPACES.join("|")}:${subject}[:...].`, operation);
262
+ }
263
+ }
264
+ return checked;
265
+ };
266
+ return {
267
+ async get(keys) {
268
+ const entries = [];
269
+ for (const chunk of chunked(keysFor(keys, "session.storage.town.get"), MAX_KEYS_PER_CALL)) {
270
+ entries.push(...await call("get", { ...args, keys: chunk }));
271
+ }
272
+ return decodeEntries(entries, "session.storage.town.get");
273
+ },
274
+ async set(values) {
275
+ keysFor(Object.keys(values), "session.storage.town.set");
276
+ const entries = encodeEntries(values, "session.storage.town.set");
277
+ for (const chunk of batched(entries, "session.storage.town.set")) {
278
+ await call("set", { ...args, entries: chunk });
279
+ }
280
+ },
281
+ async remove(keys) {
282
+ for (const chunk of chunked(keysFor(keys, "session.storage.town.remove"), MAX_KEYS_PER_CALL)) {
283
+ await call("delete", { ...args, keys: chunk });
284
+ }
285
+ },
286
+ async increment(key, delta = 1) {
287
+ keysFor([key], "session.storage.town.increment");
288
+ return Number(await call("increment", { ...args, key, delta }));
289
+ },
290
+ };
291
+ }
246
292
  function createScope(call, scope, subject, saveApi) {
247
293
  return {
248
294
  ...createSaveBucket(saveApi, scope === "DECK" ? "deck" : "user", subject ?? undefined),
@@ -1,4 +1,4 @@
1
- import type { MultiplayerSessionMode, PlatformHandle, PlayerIdentity } from "./platformHandle";
1
+ import type { MultiplayerSessionMode, PlatformHandle, PlatformPlaceTool, PlayerIdentity } from "./platformHandle";
2
2
  import type { Json } from "../types";
3
3
  import type { MultiplayerWorldCharacterCommand, MultiplayerWorldCharacterCommandResult, MultiplayerWorldCharacterCommandResultValue } from "../multiplayer";
4
4
  import { type ServerStorage } from "./storage";
@@ -8,8 +8,8 @@ export { createSaveCoordinator } from "../saveCoordinator";
8
8
  export type { CoordinatedChange, CoordinatedSave, SaveCoordinator, SaveStatus, } from "../saveCoordinator";
9
9
  export { SaveError } from "../save";
10
10
  export type { SaveApi, SaveBucketApi, SaveBucketRead, SaveChange, SaveCheck, SaveClaimed, SaveClaimInput, SaveErrorEnvelope, SaveInitial, SaveInitializeInput, SaveOpened, SaveOperation, SaveReadTarget, SaveReceipt, SaveRevision, SaveRows, SaveScope, SaveSelection, SaveSnapshot, SaveTarget, SaveTransactionInput, SaveWriter, } from "../save";
11
- export type { MultiplayerHubArrival, MultiplayerHubBoard, MultiplayerHubOutcome, MultiplayerHubPadUpdateInput, MultiplayerHubPadUpdateResult, MultiplayerHubPlayerResults, MultiplayerHubRecord, MultiplayerHubReportReceipt, MultiplayerHubResultInput, MultiplayerHubResultRow, MultiplayerHubResultShape, MultiplayerHubResultsRead, MultiplayerHubResultsReadInput, MultiplayerHubWearable, MultiplayerHubWearReceipt, MultiplayerServerHub, } from "./hub";
12
- export type { CharacterActionNotice, CharacterGameStateOperation, CharacterGameStateResult, CharacterKnowledgeDeliveryOperation, CharacterKnowledgeDeliveryResult, CharacterKnowledgeSource, CompleteCharacterBeatInput, DeliverCharacterKnowledgeInput, RecordCharacterEventInput, SetCharacterGameStateInput, ShareCharacterClaimInput, ServerStorage, ServerStorageBoard, ServerStorageBoardScore, ServerStorageListEntry, ServerStorageListOptions, ServerStorageListPage, ServerPlaythrough, ServerPlaythroughCharacters, ServerCharacterHistoryOptions, ServerStorageScope, } from "./storage";
11
+ export type { MultiplayerHubArrival, MultiplayerHubBoard, MultiplayerHubOutcome, MultiplayerHubPadUpdateInput, MultiplayerHubPadUpdateResult, MultiplayerHubPlayerResults, MultiplayerHubRecord, MultiplayerHubReportReceipt, MultiplayerHubResultInput, MultiplayerHubResultRow, MultiplayerHubResultShape, MultiplayerHubResultsRead, MultiplayerHubResultsReadInput, MultiplayerHubWearable, MultiplayerHubWearReceipt, MultiplayerHubEarnedClass, MultiplayerHubEarnedItem, MultiplayerHubHomeClass, MultiplayerHubHomeItem, MultiplayerHubUnlockReceipt, MultiplayerHubUnlocksRead, MultiplayerHubWearEarnedReceipt, MultiplayerServerHub, } from "./hub";
12
+ export type { CharacterActionNotice, CharacterGameStateOperation, CharacterGameStateResult, CharacterKnowledgeDeliveryOperation, CharacterKnowledgeDeliveryResult, CharacterKnowledgeSource, CompleteCharacterBeatInput, DeliverCharacterKnowledgeInput, RecordCharacterEventInput, SetCharacterGameStateInput, ShareCharacterClaimInput, ServerStorage, ServerStorageBoard, ServerStorageBoardScore, ServerStorageListEntry, ServerStorageListOptions, ServerStorageListPage, ServerPlaythrough, ServerPlaythroughCharacters, ServerCharacterHistoryOptions, ServerStorageScope, ServerTownStorage, } from "./storage";
13
13
  export interface MultiplayerServerPlayer extends PlayerIdentity {
14
14
  playerId: string;
15
15
  readonly participantId: string;
@@ -268,11 +268,35 @@ export interface MultiplayerWorldCharacterTurn {
268
268
  readonly players: readonly MultiplayerServerPlayer[];
269
269
  readonly characterIds: readonly string[];
270
270
  };
271
- /** Durably admits the exact host-authorized request. No arguments can broaden it. */
272
- send(): Promise<MultiplayerWorldCharacterAdmission>;
271
+ /**
272
+ * Durably admits the exact host-authorized request. No arguments can broaden it:
273
+ * `tools` only declares lookups the character may make of this place during the
274
+ * turn (answered by `onWorldCharacterTool`), and is dropped on a host that does
275
+ * not serve place tools.
276
+ */
277
+ send(options?: MultiplayerWorldCharacterSendOptions): Promise<MultiplayerWorldCharacterAdmission>;
273
278
  /** Explicitly rejects the request before admission. */
274
279
  reject(code?: string): void;
275
280
  }
281
+ /**
282
+ * A lookup a place offers its character for one turn. The host drops an invalid
283
+ * list whole (the turn proceeds without tools): at most 8, a unique `name` matching
284
+ * `^[A-Za-z][A-Za-z0-9_]{0,31}$` other than `perform_action` or `capture_claim`,
285
+ * a 1..300 character `description`, and a JSON-object `input_schema` of at most
286
+ * 1 KiB, with the whole list at most 6 KiB.
287
+ */
288
+ export type MultiplayerPlaceTool = PlatformPlaceTool;
289
+ export interface MultiplayerWorldCharacterSendOptions {
290
+ tools?: readonly MultiplayerPlaceTool[];
291
+ }
292
+ /** One place-tool use by a character during a turn this place admitted. */
293
+ export interface MultiplayerWorldCharacterToolRequest {
294
+ readonly operationId: string;
295
+ readonly characterId: string;
296
+ readonly playerId: string;
297
+ readonly name: string;
298
+ readonly input: Readonly<Record<string, unknown>>;
299
+ }
276
300
  type CallbackResult = unknown;
277
301
  export interface MultiplayerServerCallbacks {
278
302
  onStart?: (session: MultiplayerServerSession) => CallbackResult;
@@ -284,6 +308,13 @@ export interface MultiplayerServerCallbacks {
284
308
  onWorldCharacterTurn?: (session: MultiplayerServerSession, turn: MultiplayerWorldCharacterTurn) => CallbackResult;
285
309
  onWorldCharacterCommand?: (session: MultiplayerServerSession, request: MultiplayerWorldCharacterCommandRequest) => CallbackResult;
286
310
  onWorldCharacterRead?: (session: MultiplayerServerSession, request: MultiplayerWorldCharacterReadRequest) => CallbackResult;
311
+ /**
312
+ * Answers a place-tool use declared through `turn.send({ tools })`. Return a
313
+ * string or a JSON value (stringified); a result over 1 KiB becomes
314
+ * `{"error":"result too large"}` and a throw `{"error":"tool failed"}`. The host
315
+ * waits 1.5 s, then the character answers without it.
316
+ */
317
+ onWorldCharacterTool?: (request: MultiplayerWorldCharacterToolRequest, session: MultiplayerServerSession) => unknown;
287
318
  onTick?: (session: MultiplayerServerSession) => CallbackResult;
288
319
  onReplaced?: (session: MultiplayerServerSession) => CallbackResult;
289
320
  /**
@@ -69,18 +69,15 @@ async function bootSession(platformHandle, callbacks) {
69
69
  players: Object.freeze(request.audience.players.map((player) => Object.freeze(joinedPlayer(player.playerId, player)))),
70
70
  characterIds: Object.freeze([...request.audience.characterIds]),
71
71
  }),
72
- async send() {
72
+ async send(options) {
73
73
  if (settled)
74
74
  throw new TypeError("This shared character request is already settled.");
75
75
  settled = true;
76
- let rawReceipt;
77
- try {
78
- rawReceipt = await platformHandle.worldCharacterSend(request.grantId);
79
- }
80
- catch (error) {
76
+ const rawReceipt = await sendWorldCharacterTurn(platformHandle, request.grantId, options)
77
+ .catch((error) => {
81
78
  brokerFailure.rejection = { error };
82
79
  throw error;
83
- }
80
+ });
84
81
  return parseWorldCharacterAdmission(rawReceipt, request.operationId);
85
82
  },
86
83
  reject(code) {
@@ -103,6 +100,7 @@ async function bootSession(platformHandle, callbacks) {
103
100
  }
104
101
  });
105
102
  }
103
+ listenForWorldCharacterTools(platformHandle, callbacks, session);
106
104
  if (callbacks.onWorldCharacterCommand &&
107
105
  hasWorldCharacterPlatform(platformHandle, "worldCharacterCommand")) {
108
106
  platformHandle.on("worldCharacterCommand", async (raw) => {
@@ -1081,12 +1079,13 @@ function assertRuntimeCharacterCapabilities(value) {
1081
1079
  }
1082
1080
  return Object.freeze({ actionIds: Object.freeze([...normalized].sort()) });
1083
1081
  }
1082
+ const LONE_SURROGATE = /[\uD800-\uDFFF]/u;
1084
1083
  function assertRuntimeCharacterText(value, field, maxCharacters, maxBytes) {
1085
1084
  if (typeof value !== "string" ||
1086
1085
  value.length === 0 ||
1087
1086
  value.length > maxCharacters ||
1088
1087
  value.includes("\0") ||
1089
- /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(value) ||
1088
+ LONE_SURROGATE.test(value) ||
1090
1089
  new TextEncoder().encode(value).byteLength > maxBytes) {
1091
1090
  throw new TypeError(`session.world.characters.create: ${field} must be valid non-empty text of at most ${maxCharacters} characters.`);
1092
1091
  }
@@ -1190,6 +1189,82 @@ function assertPlatformWorldCharacterRead(value) {
1190
1189
  }
1191
1190
  return value;
1192
1191
  }
1192
+ // Tools ride only to a host that can answer them (an older host refuses a send
1193
+ // carrying anything beyond the grant); otherwise this is exactly the tool-less send.
1194
+ function sendWorldCharacterTurn(platformHandle, grantId, options) {
1195
+ const tools = options?.tools;
1196
+ return Array.isArray(tools) && tools.length > 0 &&
1197
+ typeof platformHandle.worldCharacterToolResult === "function"
1198
+ ? platformHandle.worldCharacterSend(grantId, tools)
1199
+ : platformHandle.worldCharacterSend(grantId);
1200
+ }
1201
+ // Only a handler on a host that answers place tools listens; without one the
1202
+ // host times the lookup out and the character answers without it.
1203
+ function listenForWorldCharacterTools(platformHandle, callbacks, session) {
1204
+ const handler = callbacks.onWorldCharacterTool;
1205
+ const answer = platformHandle.worldCharacterToolResult?.bind(platformHandle);
1206
+ if (!handler || !answer)
1207
+ return;
1208
+ try {
1209
+ platformHandle.on("worldCharacterTool", async (raw) => {
1210
+ const call = platformWorldCharacterTool(raw);
1211
+ if (!call)
1212
+ return;
1213
+ const result = await worldCharacterToolResult(handler, call.request, session);
1214
+ try {
1215
+ await answer(call.requestId, result);
1216
+ }
1217
+ catch {
1218
+ // The host has stopped waiting (or refused); the character answers without it.
1219
+ }
1220
+ });
1221
+ }
1222
+ catch {
1223
+ // Older shims may reject event names they do not recognize.
1224
+ }
1225
+ }
1226
+ const MAX_WORLD_CHARACTER_TOOL_RESULT_BYTES = 1024;
1227
+ const WORLD_CHARACTER_TOOL_TOO_LARGE = '{"error":"result too large"}';
1228
+ const WORLD_CHARACTER_TOOL_FAILED = '{"error":"tool failed"}';
1229
+ function platformWorldCharacterTool(value) {
1230
+ if (!value ||
1231
+ typeof value !== "object" ||
1232
+ typeof value.requestId !== "string" ||
1233
+ typeof value.operationId !== "string" ||
1234
+ typeof value.characterId !== "string" ||
1235
+ typeof value.playerId !== "string" ||
1236
+ typeof value.name !== "string" ||
1237
+ !value.input ||
1238
+ typeof value.input !== "object" ||
1239
+ Array.isArray(value.input)) {
1240
+ return null;
1241
+ }
1242
+ return {
1243
+ requestId: value.requestId,
1244
+ request: Object.freeze({
1245
+ operationId: value.operationId,
1246
+ characterId: value.characterId,
1247
+ playerId: value.playerId,
1248
+ name: value.name,
1249
+ input: deepFreezeJsonObject(value.input),
1250
+ }),
1251
+ };
1252
+ }
1253
+ async function worldCharacterToolResult(handler, request, session) {
1254
+ let result;
1255
+ try {
1256
+ const value = await handler(request, session);
1257
+ result = typeof value === "string" ? value : JSON.stringify(value);
1258
+ }
1259
+ catch {
1260
+ return WORLD_CHARACTER_TOOL_FAILED;
1261
+ }
1262
+ if (typeof result !== "string")
1263
+ return WORLD_CHARACTER_TOOL_FAILED;
1264
+ return new TextEncoder().encode(result).byteLength > MAX_WORLD_CHARACTER_TOOL_RESULT_BYTES
1265
+ ? WORLD_CHARACTER_TOOL_TOO_LARGE
1266
+ : result;
1267
+ }
1193
1268
  function hasWorldCharacterPlatform(platformHandle, operation) {
1194
1269
  return (typeof platformHandle[operation] === "function" &&
1195
1270
  typeof platformHandle.worldCharacterReject === "function");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-sdk",
3
- "version": "0.4.46",
3
+ "version": "0.4.47",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/castle-xyz/castle-experimental-web.git"