castle-web-sdk 0.4.45 → 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.
@@ -0,0 +1,195 @@
1
+ import { CastleError } from "../errors";
2
+ const DOOR_ID = /^[A-Za-z0-9_.:-]{1,64}$/;
3
+ export function hubArrival(raw) {
4
+ if (!raw || typeof raw !== "object")
5
+ return null;
6
+ const { fromDeckId, doorId } = raw;
7
+ if (typeof fromDeckId !== "string" || fromDeckId.length === 0 || fromDeckId.length > 64) {
8
+ return null;
9
+ }
10
+ return Object.freeze({
11
+ fromDeckId,
12
+ doorId: typeof doorId === "string" && DOOR_ID.test(doorId) ? doorId : null,
13
+ });
14
+ }
15
+ // The co-present users a block hides from a joining hub player, as the host
16
+ // sends them on the join (`player.hiddenUserIds`): decimal user ids, or null
17
+ // when there are none or the list is malformed.
18
+ const HIDDEN_USER_ID = /^\d{1,20}$/;
19
+ const HIDDEN_USER_IDS_MAX = 1000;
20
+ export function recordHiddenUserIds(hidden, playerId, raw) {
21
+ const ids = hubHiddenUserIds(raw);
22
+ if (ids) {
23
+ hidden.set(playerId, ids);
24
+ }
25
+ else {
26
+ hidden.delete(playerId);
27
+ }
28
+ }
29
+ function hubHiddenUserIds(raw) {
30
+ if (!raw || typeof raw !== "object")
31
+ return null;
32
+ const list = raw.hiddenUserIds;
33
+ if (!Array.isArray(list) || list.length === 0)
34
+ return null;
35
+ const ids = new Set();
36
+ for (const id of list.slice(0, HIDDEN_USER_IDS_MAX)) {
37
+ if (typeof id === "string" && HIDDEN_USER_ID.test(id))
38
+ ids.add(id);
39
+ }
40
+ return ids.size > 0 ? ids : null;
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
+ }
49
+ export function createServerHub(platformHandle, arrivals, hiddenPair = () => false) {
50
+ const pinned = platformHandle.hub;
51
+ if (!pinned ||
52
+ typeof platformHandle.hubResult !== "function" ||
53
+ typeof platformHandle.hubPadUpdate !== "function" ||
54
+ typeof platformHandle.hubResultsRead !== "function") {
55
+ return null;
56
+ }
57
+ const call = async (op, args) => {
58
+ const raw = await platformHandle[op](JSON.stringify(args));
59
+ let parsed;
60
+ try {
61
+ parsed = JSON.parse(raw);
62
+ }
63
+ catch {
64
+ parsed = null;
65
+ }
66
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
67
+ throw new CastleError({
68
+ code: "CAULDRON_HUB_INVALID_RESPONSE",
69
+ message: `The platform returned an invalid ${op} response.`,
70
+ operation: `session.hub.${op}`,
71
+ });
72
+ }
73
+ return parsed;
74
+ };
75
+ return Object.freeze({
76
+ place: typeof pinned.place === "string" ? pinned.place : null,
77
+ title: typeof pinned.title === "string" ? pinned.title : null,
78
+ result: pinned.result ?? null,
79
+ ...(typeof pinned.owner === "string" && pinned.owner.length > 0 ? { owner: pinned.owner } : {}),
80
+ arrival(playerId) {
81
+ return arrivals.get(playerId) ?? null;
82
+ },
83
+ hiddenPair(playerIdA, playerIdB) {
84
+ return hiddenPair(playerIdA, playerIdB);
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
+ },
114
+ async reportResult(playerId, result) {
115
+ const { place, outcome, score, stats } = result ?? {};
116
+ return (await call("hubResult", {
117
+ playerId,
118
+ ...(place === undefined ? {} : { place }),
119
+ outcome,
120
+ ...(score === undefined || score === null ? {} : { score }),
121
+ ...(stats === undefined ? {} : { stats }),
122
+ }));
123
+ },
124
+ async updatePad(input) {
125
+ return (await call("hubPadUpdate", {
126
+ doorId: input?.doorId,
127
+ padId: input?.padId,
128
+ memberUserIds: [...(input?.memberUserIds ?? [])],
129
+ ...(input?.fire === true ? { fire: true } : {}),
130
+ }));
131
+ },
132
+ async readResults(input = {}) {
133
+ const read = await call("hubResultsRead", {
134
+ ...(input.userIds ? { userIds: [...input.userIds] } : {}),
135
+ ...(input.doorIds ? { doorIds: [...input.doorIds] } : {}),
136
+ });
137
+ return {
138
+ players: (read.players ?? {}),
139
+ boards: (read.boards ?? {}),
140
+ };
141
+ },
142
+ async wear(playerId, wearable) {
143
+ if (typeof platformHandle.hubWear !== "function") {
144
+ throw new CastleError({
145
+ code: "CAULDRON_HUB_UNAVAILABLE",
146
+ message: "This host's runtime cannot change wearables.",
147
+ operation: "session.hub.hubWear",
148
+ });
149
+ }
150
+ const receipt = await call("hubWear", {
151
+ playerId,
152
+ wearable: wearable === null || wearable === undefined
153
+ ? null
154
+ : { kind: wearable.kind, color: wearable.color, rarity: wearable.rarity },
155
+ });
156
+ return {
157
+ wearable: (receipt.wearable ?? null),
158
+ };
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
+ },
194
+ });
195
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Json } from "../types";
2
+ import type { MultiplayerWorldCharacterCommand } from "../multiplayer";
2
3
  export type MultiplayerSessionMode = "named" | "party" | "public";
3
4
  export interface PlayerIdentity {
4
5
  userId: string;
@@ -13,6 +14,16 @@ export interface PlatformSessionConfig {
13
14
  readonly tickRate: number;
14
15
  readonly maxPlayers: number;
15
16
  readonly world?: PlatformWorldSnapshot;
17
+ /**
18
+ * True when the host brokers a hub place's shared characters into this
19
+ * session without a world (a layer of the place). Absent on hosts that do not.
20
+ */
21
+ readonly placeCharacters?: boolean;
22
+ /**
23
+ * The place this session was started for, stamped by a host that placed it
24
+ * through a door (the hub's door table). Absent on hosts that do not.
25
+ */
26
+ readonly place?: string;
16
27
  }
17
28
  export interface PlatformWorldCheckpoint {
18
29
  version: number;
@@ -21,6 +32,11 @@ export interface PlatformWorldCheckpoint {
21
32
  export interface PlatformWorldSnapshot {
22
33
  revision: string;
23
34
  checkpoint: PlatformWorldCheckpoint | null;
35
+ /** Selected destination timing policy, stamped by hosts that support catch-up. */
36
+ worldTime?: PlatformWorldTimeConfig;
37
+ }
38
+ export interface PlatformWorldTimeConfig {
39
+ advance: "wallClock";
24
40
  }
25
41
  export interface PlatformHandle {
26
42
  send(connId: string, text: string): void;
@@ -28,7 +44,7 @@ export interface PlatformHandle {
28
44
  sendBinary?(connId: string, bytes: Uint8Array | ArrayBuffer): void;
29
45
  broadcastBinary?(bytes: Uint8Array | ArrayBuffer): void;
30
46
  disconnect(connId: string, code: string | number): void;
31
- on(event: "join", callback: (connId: string, player: PlayerIdentity) => unknown): void;
47
+ on(event: "join", callback: (connId: string, player: PlayerIdentity, arrival?: PlatformHubArrival, experimentVariants?: Record<string, string>) => unknown): void;
32
48
  on(event: "leave", callback: (connId: string) => unknown): void;
33
49
  on(event: "message", callback: (connId: string, text: string) => unknown): void;
34
50
  on(event: "binaryMessage", callback: (connId: string, bytes: Uint8Array) => unknown): void;
@@ -37,6 +53,7 @@ export interface PlatformHandle {
37
53
  on(event: "worldCharacterTurn", callback: (request: PlatformWorldCharacterTurn) => unknown): void;
38
54
  on(event: "worldCharacterCommand", callback: (request: PlatformWorldCharacterCommand) => unknown): void;
39
55
  on(event: "worldCharacterRead", callback: (request: PlatformWorldCharacterRead) => unknown): void;
56
+ on(event: "worldCharacterTool", callback: (request: PlatformWorldCharacterTool) => unknown): void;
40
57
  /**
41
58
  * Deck storage. Strings in and strings out, like the rest of this handle, so a
42
59
  * runtime other than the Node shim can implement it without knowing what storage is.
@@ -52,14 +69,60 @@ export interface PlatformHandle {
52
69
  /** Persistent-world checkpoint broker. The host stamps the hidden world binding. */
53
70
  worldCheckpointRead?(): Promise<string>;
54
71
  worldCheckpointCommit?(argsJson: string): Promise<string>;
55
- 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>;
56
74
  worldCharacterCommand?(grantId: string): Promise<string>;
57
75
  worldCharacterRead?(grantId: string): Promise<string>;
58
76
  worldCharacterRuntimeCreate?(argsJson: string): Promise<string>;
59
77
  worldCharacterRuntimeList?(): Promise<string>;
60
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>;
84
+ /**
85
+ * The hub place this session runs, as ghost pinned it from the published
86
+ * castle.json `hub` block. Present only when the host services the hub ops
87
+ * below, so a deck without a hub block never sees any of them.
88
+ */
89
+ readonly hub?: Readonly<PlatformHubPlace>;
90
+ hubResult?(argsJson: string): Promise<string>;
91
+ hubPadUpdate?(argsJson: string): Promise<string>;
92
+ hubResultsRead?(argsJson: string): Promise<string>;
93
+ /** Friends among candidates (hub contract v1.36); older images use storage. */
94
+ hubFriendsAmong?(argsJson: string): Promise<string>;
95
+ /** Absent on a runtime older than wearables (contract v1.4). */
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>;
61
103
  readonly config: PlatformSessionConfig;
62
104
  }
105
+ export interface PlatformHubResultShape {
106
+ outcomes: ("win" | "lose" | "draw")[];
107
+ score: {
108
+ unit: "ms" | "s" | "points";
109
+ better: "higher" | "lower";
110
+ min: number;
111
+ max: number;
112
+ } | null;
113
+ stats: Record<string, "number" | "string">;
114
+ }
115
+ export interface PlatformHubPlace {
116
+ owner?: string;
117
+ place?: string;
118
+ title?: string | null;
119
+ result?: PlatformHubResultShape | null;
120
+ }
121
+ /** Where a player came in, when ghost placed them through a hub door. Ghost's word, not a client's. */
122
+ export interface PlatformHubArrival {
123
+ fromDeckId: string;
124
+ doorId?: string;
125
+ }
63
126
  export interface PlatformWorldCharacterCommand {
64
127
  grantId: string;
65
128
  requestId: string;
@@ -67,7 +130,7 @@ export interface PlatformWorldCharacterCommand {
67
130
  speaker: PlayerIdentity & {
68
131
  playerId: string;
69
132
  };
70
- command: unknown;
133
+ command: MultiplayerWorldCharacterCommand;
71
134
  }
72
135
  export interface PlatformWorldCharacterRead {
73
136
  grantId: string;
@@ -77,6 +140,20 @@ export interface PlatformWorldCharacterRead {
77
140
  };
78
141
  query: unknown;
79
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
+ }
80
157
  export interface PlatformWorldCharacterTurn {
81
158
  grantId: string;
82
159
  requestId: string;
@@ -1,6 +1,7 @@
1
1
  import type { Json } from "../types";
2
2
  import type { CharacterHistory, CharacterOperation } from "../commands";
3
3
  import type { PlatformHandle } from "./platformHandle";
4
+ import { type SaveApi, type SaveBucketApi } from "../save";
4
5
  export interface ServerStorageListOptions {
5
6
  prefix?: string;
6
7
  limit?: number;
@@ -15,13 +16,19 @@ export interface ServerStorageListPage<T extends Json = Json> {
15
16
  /** Null means there is nothing left. A short page alone does not mean that. */
16
17
  cursor: string | null;
17
18
  }
18
- export interface ServerStorageScope {
19
+ export interface ServerStorageScope extends SaveBucketApi {
19
20
  get<T extends Json = Json>(keys: string[]): Promise<Record<string, T>>;
20
21
  set(values: Record<string, Json>): Promise<void>;
21
22
  remove(keys: string[]): Promise<void>;
22
23
  increment(key: string, delta?: number): Promise<number>;
23
24
  list<T extends Json = Json>(options?: ServerStorageListOptions): Promise<ServerStorageListPage<T>>;
24
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
+ }
25
32
  export interface ServerStorageBoardScore {
26
33
  subject: string;
27
34
  score: number;
@@ -95,6 +102,59 @@ export interface DeliverCharacterKnowledgeInput {
95
102
  claimIds: string[];
96
103
  source: CharacterKnowledgeSource;
97
104
  }
105
+ /**
106
+ * A character acting on its own after a command woke it (autonomous character
107
+ * actions, bundles with `characterActions`). `declined` covers choosing
108
+ * nothing, too little confidence for its temperament, and an action no longer
109
+ * eligible when it committed; `failed` is a decision-model error.
110
+ */
111
+ export type CharacterActionNotice = {
112
+ characterId: string;
113
+ /** `id` is the event occurrence, routine, or promise id. */
114
+ trigger: {
115
+ type: "witnessed" | "routine" | "promise_due";
116
+ id: string;
117
+ };
118
+ outcome: "acted" | "declined" | "failed";
119
+ /** When acted. */
120
+ actionId?: string;
121
+ /** When acted with a parameterized action's variant. */
122
+ variantId?: string;
123
+ /** The action's world event, when it has one. */
124
+ eventOccurrenceId?: string;
125
+ /** The decision model's probability for its choice. */
126
+ probability?: number;
127
+ };
128
+ export type CharacterGameStateResult = {
129
+ revision: number;
130
+ state: {
131
+ [key: string]: string | number | boolean;
132
+ };
133
+ promiseOutcomes: Array<{
134
+ id: string;
135
+ status: "fulfilled" | "broken";
136
+ due: number;
137
+ resolvedAtGameTime: number;
138
+ }>;
139
+ /** At most 8, omitted when no character acted, declined, or failed. */
140
+ characterActions?: CharacterActionNotice[];
141
+ };
142
+ export type CharacterGameStateOperation = Omit<CharacterOperation, "result"> & {
143
+ result: CharacterGameStateResult | null;
144
+ };
145
+ export type CharacterKnowledgeDeliveryResult = {
146
+ deliveryId: string;
147
+ recipientCharacterId: string;
148
+ eventOccurrenceId: string;
149
+ claimIds: string[];
150
+ source: CharacterKnowledgeSource;
151
+ deliveredAt: string;
152
+ /** At most 8, omitted when no character acted, declined, or failed. */
153
+ characterActions?: CharacterActionNotice[];
154
+ };
155
+ export type CharacterKnowledgeDeliveryOperation = Omit<CharacterOperation, "result"> & {
156
+ result: CharacterKnowledgeDeliveryResult | null;
157
+ };
98
158
  export interface CompleteCharacterBeatInput {
99
159
  operationId: string;
100
160
  characterId: string;
@@ -189,10 +249,10 @@ export interface ServerPlaythroughCharacters {
189
249
  /** Queues a funded turn whose admitted listeners are fixed at enqueue time. */
190
250
  send(input: SendCharacterInput): Promise<CharacterOperation>;
191
251
  recordEvent(input: RecordCharacterEventInput): Promise<CharacterOperation>;
192
- setGameState(input: SetCharacterGameStateInput): Promise<CharacterOperation>;
252
+ setGameState(input: SetCharacterGameStateInput): Promise<CharacterGameStateOperation>;
193
253
  shareClaim(input: ShareCharacterClaimInput): Promise<CharacterOperation>;
194
254
  inspect(): Promise<Json>;
195
- deliverKnowledge(input: DeliverCharacterKnowledgeInput): Promise<CharacterOperation>;
255
+ deliverKnowledge(input: DeliverCharacterKnowledgeInput): Promise<CharacterKnowledgeDeliveryOperation>;
196
256
  completeBeat(input: CompleteCharacterBeatInput): Promise<CharacterOperation>;
197
257
  evaluateInformation(input: EvaluateCharacterInformationInput): Promise<CharacterInformationEvaluationOperation>;
198
258
  evaluateBeats(input: EvaluateCharacterBeatsInput): Promise<CharacterBeatEvaluationOperation>;
@@ -204,11 +264,18 @@ export interface ServerCharacterHistoryOptions {
204
264
  limit?: number;
205
265
  cursor?: string | null;
206
266
  }
207
- export interface ServerStorage {
267
+ export interface ServerStorage extends SaveApi {
208
268
  /** Shared by everyone playing the deck, and owned by this server. */
209
269
  readonly deck: ServerStorageScope;
210
270
  /** One player's public data. Readable by anyone; writable by them or by this server. */
211
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;
212
279
  board(name: string): ServerStorageBoard;
213
280
  /** Opens one player's experimental, generation-bound game namespace. */
214
281
  playthrough(userId: string): Promise<ServerPlaythrough>;
@@ -1,5 +1,6 @@
1
1
  import { chunked } from "../chunk";
2
2
  import { decodeStorageValue, encodeStorageValue, storageError, } from "../storageJson";
3
+ import { createSaveApi, createSaveBucket, preflightSaveRequest, SaveError, } from "../save";
3
4
  // Deck storage for an author's session server.
4
5
  //
5
6
  // The same store the deck's clients use, reached over the platform channel instead of
@@ -56,8 +57,26 @@ export function createServerStorage(platformHandle) {
56
57
  const result = await platformHandle.storage(op, JSON.stringify(args));
57
58
  return JSON.parse(result);
58
59
  };
60
+ // The host forwards these args to the platform's storage route unchanged,
61
+ // which takes a save's request as `input` and a lookup as `operationId`.
62
+ const saveApi = createSaveApi(async (verb, input) => {
63
+ if (typeof platformHandle.storage !== "function" ||
64
+ platformHandle.storageEnabled === false) {
65
+ throw new SaveError({
66
+ code: "CAULDRON_STORAGE_UNAVAILABLE",
67
+ message: "Storage is not available to this session.",
68
+ operationId: input.operationId,
69
+ retryable: false,
70
+ committed: false,
71
+ });
72
+ }
73
+ const args = verb === "operation" ? input : { input };
74
+ preflightSaveRequest(verb, args, MAX_REQUEST_BYTES);
75
+ return call(verb, args);
76
+ }, true);
59
77
  return {
60
- deck: createScope(call, "DECK", null),
78
+ ...saveApi,
79
+ deck: createScope(call, "DECK", null, saveApi),
61
80
  user(userId) {
62
81
  // A bad id poisons the scope rather than throwing here, so the failure arrives
63
82
  // the same way every other failure does -- as a rejection the author can await.
@@ -65,12 +84,15 @@ export function createServerStorage(platformHandle) {
65
84
  // resolved by the platform to the deck's creator, quietly writing the wrong
66
85
  // player's bucket, so nothing is sent either.
67
86
  if (typeof userId !== "string" || userId.length === 0) {
68
- return createScope(rejectMissingSubject, "USER", null);
87
+ return createScope(rejectMissingSubject, "USER", null, saveApi);
69
88
  }
70
89
  if (utf8Length(userId) > MAX_SUBJECT_BYTES) {
71
- return createScope(rejectOversizeSubject, "USER", null);
90
+ return createScope(rejectOversizeSubject, "USER", userId, saveApi);
72
91
  }
73
- return createScope(call, "USER", userId);
92
+ return createScope(call, "USER", userId, saveApi);
93
+ },
94
+ town(userId) {
95
+ return createTownStorage(call, userId);
74
96
  },
75
97
  board(name) {
76
98
  return createBoard(call, name);
@@ -224,8 +246,52 @@ function createPlaythrough(call, subject, generation) {
224
246
  },
225
247
  });
226
248
  }
227
- function createScope(call, scope, subject) {
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
+ }
292
+ function createScope(call, scope, subject, saveApi) {
228
293
  return {
294
+ ...createSaveBucket(saveApi, scope === "DECK" ? "deck" : "user", subject ?? undefined),
229
295
  async get(keys) {
230
296
  // A bare array, not a wrapper: the platform hands back exactly what the
231
297
  // storage verb returns, and `get` returns its entries directly.