castle-web-sdk 0.4.46 → 0.4.48

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/dist/party.js ADDED
@@ -0,0 +1,91 @@
1
+ import { CastleError } from './errors';
2
+ import { hostRequest } from './transport';
3
+ export const Party = {
4
+ current,
5
+ create,
6
+ invite,
7
+ respond,
8
+ members,
9
+ leave,
10
+ messages,
11
+ say,
12
+ };
13
+ const ID_MAX = 128;
14
+ const CURSOR_MAX = 512;
15
+ const PARTY_USERS_MAX = 32;
16
+ function id(value, field, operation) {
17
+ return boundedText(value, field, operation, ID_MAX);
18
+ }
19
+ function boundedText(value, field, operation, max) {
20
+ if (typeof value === 'string' &&
21
+ value.length > 0 &&
22
+ value.length <= max &&
23
+ !value.includes('\0')) {
24
+ return value;
25
+ }
26
+ throw new CastleError({
27
+ code: 'INVALID_ARGUMENT',
28
+ message: `${operation} requires a valid ${field}.`,
29
+ operation,
30
+ });
31
+ }
32
+ function ids(value, operation) {
33
+ if (!Array.isArray(value) || value.length > PARTY_USERS_MAX) {
34
+ throw new CastleError({
35
+ code: 'INVALID_ARGUMENT',
36
+ message: `${operation} requires at most ${PARTY_USERS_MAX} user ids.`,
37
+ operation,
38
+ });
39
+ }
40
+ return value.map((value) => id(value, 'userId', operation));
41
+ }
42
+ async function current() {
43
+ return hostRequest('party.current', {});
44
+ }
45
+ async function create(userIds) {
46
+ return hostRequest('party.create', { userIds: ids(userIds, 'Party.create') });
47
+ }
48
+ async function invite(partyId, userIds) {
49
+ return hostRequest('party.invite', {
50
+ partyId: id(partyId, 'partyId', 'Party.invite'),
51
+ userIds: ids(userIds, 'Party.invite'),
52
+ });
53
+ }
54
+ async function respond(partyId, accept) {
55
+ if (typeof accept !== 'boolean') {
56
+ throw new CastleError({
57
+ code: 'INVALID_ARGUMENT',
58
+ message: 'Party.respond requires accept to be a boolean.',
59
+ operation: 'Party.respond',
60
+ });
61
+ }
62
+ return hostRequest('party.respond', {
63
+ partyId: id(partyId, 'partyId', 'Party.respond'),
64
+ accept,
65
+ });
66
+ }
67
+ async function members(partyId) {
68
+ return hostRequest('party.members', { partyId: id(partyId, 'partyId', 'Party.members') });
69
+ }
70
+ async function leave(partyId) {
71
+ return hostRequest('party.leave', { partyId: id(partyId, 'partyId', 'Party.leave') });
72
+ }
73
+ async function messages(partyId, cursor) {
74
+ const params = {
75
+ partyId: id(partyId, 'partyId', 'Party.messages'),
76
+ };
77
+ if (cursor !== undefined) {
78
+ params.cursor = boundedText(cursor, 'cursor', 'Party.messages', CURSOR_MAX);
79
+ }
80
+ return hostRequest('party.messages', params);
81
+ }
82
+ async function say(partyId, text) {
83
+ if (typeof text !== 'string' || text.length === 0) {
84
+ throw new CastleError({
85
+ code: 'INVALID_ARGUMENT',
86
+ message: 'Party.say requires text.',
87
+ operation: 'Party.say',
88
+ });
89
+ }
90
+ return hostRequest('party.say', { partyId: id(partyId, 'partyId', 'Party.say'), text });
91
+ }
package/dist/save.d.ts CHANGED
@@ -63,6 +63,13 @@ export interface SaveTransactionInput {
63
63
  checks?: SaveCheck[];
64
64
  changes: SaveChange[];
65
65
  }
66
+ export interface SaveConditionalCheck extends Omit<SaveCheck, "expectedRevision"> {
67
+ /** Null checks absence without creating the target. */
68
+ expectedRevision: SaveRevision | null;
69
+ }
70
+ export interface SaveConditionalWriteInput extends Omit<SaveTransactionInput, "checks"> {
71
+ checks?: SaveConditionalCheck[];
72
+ }
66
73
  export interface SaveInitial {
67
74
  target: SaveTarget;
68
75
  rows: SaveRows;
@@ -95,8 +102,24 @@ export type SaveOperation = {
95
102
  } | {
96
103
  state: "unknown";
97
104
  };
98
- export type SaveVerb = "snapshot" | "transaction" | "claim" | "initialize" | "operation";
105
+ export interface SaveRecentOperationsInput {
106
+ limit?: number;
107
+ cursor?: string;
108
+ }
109
+ export interface SaveRecentOperationsPage {
110
+ operations: {
111
+ operationId: string;
112
+ committedAt: string;
113
+ receipt: SaveReceipt;
114
+ }[];
115
+ cursor: string | null;
116
+ }
117
+ export type SaveVerb = "snapshot" | "transaction" | "claim" | "initialize" | "operation" | "conditionalWrite" | "recentOperations";
99
118
  export interface SaveApi {
119
+ /** Gated atomic revision check and write, up to eight targets and two players. */
120
+ conditionalWrite(input: SaveConditionalWriteInput): Promise<SaveReceipt>;
121
+ /** Gated caller-scoped receipt discovery after losing local operation IDs. */
122
+ recentOperations(input?: SaveRecentOperationsInput): Promise<SaveRecentOperationsPage>;
100
123
  snapshot(input: {
101
124
  targets: SaveReadTarget[];
102
125
  }): Promise<SaveSnapshot>;
@@ -156,6 +179,7 @@ export interface SaveErrorEnvelope {
156
179
  receipt?: SaveReceipt;
157
180
  retryAfterMs?: number;
158
181
  target?: SaveTarget;
182
+ current?: SaveSnapshot;
159
183
  [detail: string]: unknown;
160
184
  }
161
185
  export declare class SaveError extends CastleError {
@@ -165,6 +189,7 @@ export declare class SaveError extends CastleError {
165
189
  readonly receipt?: SaveReceipt;
166
190
  readonly retryAfterMs?: number;
167
191
  readonly target?: SaveTarget;
192
+ readonly current?: SaveSnapshot;
168
193
  constructor(envelope: SaveErrorEnvelope);
169
194
  }
170
195
  /**
package/dist/save.js CHANGED
@@ -7,6 +7,7 @@ export class SaveError extends CastleError {
7
7
  receipt;
8
8
  retryAfterMs;
9
9
  target;
10
+ current;
10
11
  constructor(envelope) {
11
12
  super({
12
13
  code: envelope.code,
@@ -20,6 +21,7 @@ export class SaveError extends CastleError {
20
21
  this.receipt = envelope.receipt;
21
22
  this.retryAfterMs = envelope.retryAfterMs;
22
23
  this.target = envelope.target;
24
+ this.current = envelope.current;
23
25
  }
24
26
  }
25
27
  /**
@@ -60,7 +62,8 @@ const MAX_SUBJECT_BYTES = 256;
60
62
  const MAX_VALUE_BYTES = 256 * 1024;
61
63
  const encoder = new TextEncoder();
62
64
  const byteLength = (text) => encoder.encode(text).length;
63
- const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
65
+ // Unicode mode consumes valid pairs together; avoid lookbehind for Safari < 16.4.
66
+ const LONE_SURROGATE = /[\uD800-\uDFFF]/u;
64
67
  function invalid(message) {
65
68
  throw new SaveError({
66
69
  code: "CAULDRON_STORAGE_INVALID_ARGUMENT",
@@ -240,12 +243,14 @@ function encodeChange(change, server) {
240
243
  ...(remove === undefined ? {} : { remove }),
241
244
  };
242
245
  }
243
- function encodeCheck(check, server) {
246
+ function encodeCheck(check, server, conditional = false) {
244
247
  const target = validateSaveTarget(check.target, server);
248
+ if (conditional && check.expectedRevision === undefined)
249
+ invalid("Every conditional check needs an expected revision.");
245
250
  if (check.expectedRevision === undefined && check.writer === undefined) {
246
251
  invalid("A check needs an expected revision or a writer.");
247
252
  }
248
- if (check.expectedRevision !== undefined) {
253
+ if (check.expectedRevision !== undefined && !(conditional && check.expectedRevision === null)) {
249
254
  checkText(check.expectedRevision, "expected revision");
250
255
  }
251
256
  if (check.writer !== undefined)
@@ -258,7 +263,7 @@ function encodeCheck(check, server) {
258
263
  ...(check.writer === undefined ? {} : { writer: check.writer }),
259
264
  };
260
265
  }
261
- function encodeTransaction(input, server) {
266
+ function encodeTransaction(input, server, conditional = false) {
262
267
  const changes = checkList(input.changes, "changes").map((change) => encodeChange(change, server));
263
268
  const keys = changes.map((change) => saveTargetKey(change.target));
264
269
  if (new Set(keys).size !== keys.length) {
@@ -270,7 +275,7 @@ function encodeTransaction(input, server) {
270
275
  ...(input.checks === undefined
271
276
  ? {}
272
277
  : {
273
- checks: checkList(input.checks, "checks").map((check) => encodeCheck(check, server)),
278
+ checks: checkList(input.checks, "checks").map((check) => encodeCheck(check, server, conditional)),
274
279
  }),
275
280
  changes,
276
281
  };
@@ -282,12 +287,26 @@ export function encodeSaveInput(verb, input, server = false) {
282
287
  }
283
288
  if (verb === "snapshot")
284
289
  return { targets: encodeReads(input.targets, server) };
290
+ if (verb === "recentOperations") {
291
+ const limit = input.limit ?? 16;
292
+ if (!Number.isInteger(limit) || limit < 1 || limit > 32)
293
+ invalid("Recovery limit must be 1 to 32.");
294
+ if (input.cursor !== undefined)
295
+ checkText(input.cursor, "recovery cursor", 2048);
296
+ return { limit, ...(input.cursor === undefined ? {} : { cursor: input.cursor }) };
297
+ }
285
298
  checkText(input.operationId, "operation id", MAX_KEY_BYTES);
286
299
  if (verb === "operation")
287
300
  return { operationId: input.operationId };
288
- if (verb === "transaction") {
301
+ if (verb === "transaction" || verb === "conditionalWrite") {
302
+ if (verb === "conditionalWrite") {
303
+ const changes = input.changes;
304
+ const checks = input.checks ?? [];
305
+ if (!Array.isArray(changes) || !changes.length || changes.length + checks.length > 8)
306
+ invalid("Conditional writes require 1 to 8 targets.");
307
+ }
289
308
  return {
290
- ...encodeTransaction(input, server),
309
+ ...encodeTransaction(input, server, verb === "conditionalWrite"),
291
310
  };
292
311
  }
293
312
  if (input.expectedAccess !== undefined) {
@@ -422,6 +441,34 @@ export function createSaveApi(call, server = false) {
422
441
  }
423
442
  }
424
443
  return {
444
+ async conditionalWrite(input) {
445
+ const result = await send("conditionalWrite", { ...input });
446
+ return decodeReceipt(result, input.operationId, input.changes.map((change) => change.target));
447
+ },
448
+ async recentOperations(input = {}) {
449
+ const page = (await send("recentOperations", {
450
+ ...input,
451
+ }));
452
+ if (!page ||
453
+ !Array.isArray(page.operations) ||
454
+ page.operations.length > (input.limit ?? 16) ||
455
+ (page.cursor !== null && typeof page.cursor !== "string"))
456
+ invalidResponse();
457
+ return {
458
+ operations: page.operations.map((operation) => {
459
+ if (!operation ||
460
+ typeof operation.operationId !== "string" ||
461
+ typeof operation.committedAt !== "string")
462
+ invalidResponse();
463
+ return {
464
+ operationId: operation.operationId,
465
+ committedAt: operation.committedAt,
466
+ receipt: decodeReceipt(operation.receipt, operation.operationId, []),
467
+ };
468
+ }),
469
+ cursor: page.cursor,
470
+ };
471
+ },
425
472
  async snapshot(input) {
426
473
  return decodeSnapshot(await send("snapshot", { ...input }), input.targets);
427
474
  },
@@ -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,27 @@ export interface MultiplayerHubResultsRead {
134
184
  * and for a deck that declares no hub block.
135
185
  */
136
186
  export interface MultiplayerServerHub {
187
+ /** Durably appends NPC words to a private platform archive. No read operation. */
188
+ archiveText(playerId: string, entry: {
189
+ npcId: string;
190
+ characterId?: string;
191
+ conversationId?: string;
192
+ operationId: string;
193
+ input?: string;
194
+ reply?: string;
195
+ }): Promise<void>;
196
+ /** Numeric hub telemetry; unavailable/refused delivery returns false, never blocks play. */
197
+ track(playerId: string, event: {
198
+ kind: number;
199
+ subject?: number;
200
+ value?: number;
201
+ result?: number;
202
+ flags?: number;
203
+ }): Promise<boolean>;
204
+ /** The owner user id, only in an owner door instance. */
205
+ readonly owner?: string;
206
+ /** Host-pinned inclusive reconnect grace for this hub place, in milliseconds. */
207
+ readonly reconnectWindowMs?: number;
137
208
  readonly place: string | null;
138
209
  readonly title: string | null;
139
210
  readonly result: MultiplayerHubResultShape | null;
@@ -156,6 +227,45 @@ export interface MultiplayerServerHub {
156
227
  * without it).
157
228
  */
158
229
  wear(playerId: string, wearable: MultiplayerHubWearable | null): Promise<MultiplayerHubWearReceipt>;
230
+ /**
231
+ * Gives a signed-in player one of this deck's declared earned items (castle.json
232
+ * `hub.unlocks`), by its id. Castle takes it only from a curated hub place, for a
233
+ * declared id; a first wearable unlock is also shown. Home items are never worn.
234
+ * Rejects with a `CAULDRON_HUB_*` code
235
+ * (`CAULDRON_HUB_UNAVAILABLE` on a runtime without it).
236
+ */
237
+ unlock(playerId: string, itemId: string): Promise<MultiplayerHubUnlockReceipt>;
238
+ /**
239
+ * Chooses which owned earned item a signed-in player shows, or none with null.
240
+ * Castle takes it only from the town; home items are refused.
241
+ * Rejects with a `CAULDRON_HUB_*` code
242
+ * (`CAULDRON_HUB_UNAVAILABLE` on a runtime without it).
243
+ */
244
+ wearEarned(playerId: string, earned: {
245
+ deckId: string;
246
+ id: string;
247
+ } | null): Promise<MultiplayerHubWearEarnedReceipt>;
248
+ /**
249
+ * The earned items players here own, by user id. Rejects with a `CAULDRON_HUB_*`
250
+ * code (`CAULDRON_HUB_UNAVAILABLE` on a runtime without it).
251
+ */
252
+ readUnlocks(userIds: readonly string[]): Promise<MultiplayerHubUnlocksRead>;
253
+ /** The connected player's friends among these candidates; platform refusals reject. */
254
+ friendsAmong(playerId: string, userIds: readonly string[]): Promise<string[]>;
255
+ /**
256
+ * The connected player's mutual friends and follows in either direction among
257
+ * these candidates, in one host read. Missing lists are empty; refusals reject.
258
+ * Optional strict mode rejects malformed raw lists and marks the result validated.
259
+ * These relations belong to this player only; never send them to another client.
260
+ */
261
+ relationsAmong(playerId: string, userIds: readonly string[], options?: {
262
+ strict: true;
263
+ }): Promise<{
264
+ validated?: true;
265
+ friends: string[];
266
+ follows: string[];
267
+ followedBy: string[];
268
+ }>;
159
269
  /**
160
270
  * Whether a block hides these two players from each other: either one blocked
161
271
  * the other, in either direction. Symmetric. The block lists themselves stay
@@ -39,6 +39,18 @@ 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
+ }
49
+ function reconnectMetadata(pinned) {
50
+ const value = pinned.reconnectWindowMs;
51
+ return pinned.result === null && typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 180_000
52
+ ? { reconnectWindowMs: value } : {};
53
+ }
42
54
  export function createServerHub(platformHandle, arrivals, hiddenPair = () => false) {
43
55
  const pinned = platformHandle.hub;
44
56
  if (!pinned ||
@@ -65,16 +77,67 @@ export function createServerHub(platformHandle, arrivals, hiddenPair = () => fal
65
77
  }
66
78
  return parsed;
67
79
  };
68
- return Object.freeze({
80
+ const hub = {
81
+ async archiveText(playerId, entry) {
82
+ if (platformHandle.hubTextArchiveEnabled !== true || typeof platformHandle.storage !== "function")
83
+ throw unavailable("archiveText", "archive NPC transcripts");
84
+ const raw = await platformHandle.storage("hubTextArchive", JSON.stringify({ ...entry, playerId }));
85
+ if (JSON.parse(raw)?.recorded !== true)
86
+ throw unavailable("archiveText", "archive NPC transcripts");
87
+ },
69
88
  place: typeof pinned.place === "string" ? pinned.place : null,
70
89
  title: typeof pinned.title === "string" ? pinned.title : null,
71
90
  result: pinned.result ?? null,
91
+ ...(typeof pinned.owner === "string" && pinned.owner.length > 0 ? { owner: pinned.owner } : {}),
92
+ ...reconnectMetadata(pinned),
72
93
  arrival(playerId) {
73
94
  return arrivals.get(playerId) ?? null;
74
95
  },
75
96
  hiddenPair(playerIdA, playerIdB) {
76
97
  return hiddenPair(playerIdA, playerIdB);
77
98
  },
99
+ async track(playerId, event) {
100
+ try {
101
+ const json = JSON.stringify({ ...event, playerId });
102
+ const raw = await platformHandle.storage?.("hubTrack", json);
103
+ return JSON.parse(raw ?? "null")?.recorded === true;
104
+ }
105
+ catch {
106
+ return false;
107
+ }
108
+ },
109
+ async friendsAmong(playerId, userIds) {
110
+ const json = JSON.stringify({ playerId, userIds: [...userIds] });
111
+ const raw = typeof platformHandle.hubFriendsAmong === "function"
112
+ ? await platformHandle.hubFriendsAmong(json)
113
+ : await platformHandle.storage?.("hubFriendsAmong", json);
114
+ const answer = JSON.parse(raw ?? "null");
115
+ return Array.isArray(answer?.userIds) ? answer.userIds : [];
116
+ },
117
+ async relationsAmong(playerId, userIds, options = undefined) {
118
+ const asked = new Set(userIds);
119
+ const json = JSON.stringify({ playerId, userIds: [...userIds] });
120
+ let raw;
121
+ if (typeof platformHandle.hubFriendsAmong === "function") {
122
+ raw = await platformHandle.hubFriendsAmong(json);
123
+ }
124
+ else {
125
+ raw = await platformHandle.storage?.("hubFriendsAmong", json);
126
+ }
127
+ const answer = JSON.parse(raw ?? "null");
128
+ if (options?.strict === true && (!answer || ["userIds", "follows", "followedBy"].some((key) => !Array.isArray(answer[key]) || answer[key].length > 100 || answer[key].some((id) => typeof id !== "string" || !/^\d{1,20}$/.test(id) || !asked.has(id))))) {
129
+ throw new CastleError({ code: "CAULDRON_HUB_INVALID_RESPONSE", message: "The platform returned an invalid friends response.", operation: "session.hub.relationsAmong" });
130
+ }
131
+ const list = (value) => Array.isArray(value)
132
+ ? value.filter((id) => typeof id === "string" && asked.has(id))
133
+ : [];
134
+ return {
135
+ ...(options?.strict === true ? { validated: true } : {}),
136
+ friends: list(answer?.userIds),
137
+ follows: list(answer?.follows),
138
+ followedBy: list(answer?.followedBy),
139
+ };
140
+ },
78
141
  async reportResult(playerId, result) {
79
142
  const { place, outcome, score, stats } = result ?? {};
80
143
  return (await call("hubResult", {
@@ -121,5 +184,44 @@ export function createServerHub(platformHandle, arrivals, hiddenPair = () => fal
121
184
  wearable: (receipt.wearable ?? null),
122
185
  };
123
186
  },
124
- });
187
+ async unlock(playerId, itemId) {
188
+ if (typeof platformHandle.hubUnlock !== "function") {
189
+ throw unavailable("hubUnlock", "give earned items");
190
+ }
191
+ const receipt = await call("hubUnlock", { playerId, itemId });
192
+ return {
193
+ unlocked: true,
194
+ isNew: receipt.isNew === true,
195
+ earned: (receipt.earned ?? null),
196
+ };
197
+ },
198
+ async wearEarned(playerId, earned) {
199
+ if (typeof platformHandle.hubWearEarned !== "function") {
200
+ throw unavailable("hubWearEarned", "choose earned items");
201
+ }
202
+ const receipt = await call("hubWearEarned", {
203
+ playerId,
204
+ earned: earned === null || earned === undefined
205
+ ? null
206
+ : { deckId: earned.deckId, id: earned.id },
207
+ });
208
+ return {
209
+ earned: (receipt.earned ?? null),
210
+ };
211
+ },
212
+ async readUnlocks(userIds) {
213
+ if (typeof platformHandle.hubUnlocksRead !== "function") {
214
+ throw unavailable("hubUnlocksRead", "read earned items");
215
+ }
216
+ const read = await call("hubUnlocksRead", { userIds: [...(userIds ?? [])] });
217
+ return {
218
+ players: (read.players ?? {}),
219
+ };
220
+ },
221
+ };
222
+ // Preserve the enumerable API shape and diagnostic serialization of existing
223
+ // places. Opted-in kits call this method explicitly; it adds no wire field.
224
+ Object.defineProperty(hub, "track", { enumerable: false });
225
+ Object.defineProperty(hub, "archiveText", { enumerable: false });
226
+ return Object.freeze(hub);
125
227
  }
@@ -6,6 +6,8 @@ export interface PlayerIdentity {
6
6
  username: string;
7
7
  isAnonymous: boolean;
8
8
  scopedUserId?: string;
9
+ /** Trusted hub age boundary; omitted by older hosts. Never client supplied. */
10
+ under13?: boolean;
9
11
  }
10
12
  export interface PlatformSessionConfig {
11
13
  readonly sessionId: string;
@@ -53,6 +55,7 @@ export interface PlatformHandle {
53
55
  on(event: "worldCharacterTurn", callback: (request: PlatformWorldCharacterTurn) => unknown): void;
54
56
  on(event: "worldCharacterCommand", callback: (request: PlatformWorldCharacterCommand) => unknown): void;
55
57
  on(event: "worldCharacterRead", callback: (request: PlatformWorldCharacterRead) => unknown): void;
58
+ on(event: "worldCharacterTool", callback: (request: PlatformWorldCharacterTool) => unknown): void;
56
59
  /**
57
60
  * Deck storage. Strings in and strings out, like the rest of this handle, so a
58
61
  * runtime other than the Node shim can implement it without knowing what storage is.
@@ -65,15 +68,26 @@ export interface PlatformHandle {
65
68
  storage?(op: string, argsJson: string): Promise<string>;
66
69
  /** False when the host cannot broker storage, so a call can fail at once. */
67
70
  readonly storageEnabled?: boolean;
71
+ readonly hubTextArchiveEnabled?: boolean;
68
72
  /** Persistent-world checkpoint broker. The host stamps the hidden world binding. */
69
73
  worldCheckpointRead?(): Promise<string>;
70
74
  worldCheckpointCommit?(argsJson: string): Promise<string>;
71
- worldCharacterSend?(grantId: string): Promise<string>;
75
+ /** `tools` is the turn's place tools, passed only when non-empty. */
76
+ worldCharacterSend?(grantId: string, tools?: readonly PlatformPlaceTool[], hubTextArchive?: {
77
+ npcId: string;
78
+ conversationId?: string;
79
+ input: string;
80
+ }): Promise<string>;
72
81
  worldCharacterCommand?(grantId: string): Promise<string>;
73
82
  worldCharacterRead?(grantId: string): Promise<string>;
74
83
  worldCharacterRuntimeCreate?(argsJson: string): Promise<string>;
75
84
  worldCharacterRuntimeList?(): Promise<string>;
76
85
  worldCharacterReject?(grantId: string, code?: string): void;
86
+ /**
87
+ * Answers a `worldCharacterTool` event with at most 1 KiB of UTF-8. Present only
88
+ * when the host serves place tools, which is also what lets a send declare them.
89
+ */
90
+ worldCharacterToolResult?(requestId: string, result: string): Promise<string>;
77
91
  /**
78
92
  * The hub place this session runs, as ghost pinned it from the published
79
93
  * castle.json `hub` block. Present only when the host services the hub ops
@@ -83,8 +97,16 @@ export interface PlatformHandle {
83
97
  hubResult?(argsJson: string): Promise<string>;
84
98
  hubPadUpdate?(argsJson: string): Promise<string>;
85
99
  hubResultsRead?(argsJson: string): Promise<string>;
100
+ /** Friends among candidates (hub contract v1.36); older images use storage. */
101
+ hubFriendsAmong?(argsJson: string): Promise<string>;
86
102
  /** Absent on a runtime older than wearables (contract v1.4). */
87
103
  hubWear?(argsJson: string): Promise<string>;
104
+ /** Absent on a runtime older than earned items (contract v1.53). */
105
+ hubUnlock?(argsJson: string): Promise<string>;
106
+ /** Absent on a runtime older than earned items (contract v1.53). */
107
+ hubWearEarned?(argsJson: string): Promise<string>;
108
+ /** Absent on a runtime older than earned items (contract v1.53). */
109
+ hubUnlocksRead?(argsJson: string): Promise<string>;
88
110
  readonly config: PlatformSessionConfig;
89
111
  }
90
112
  export interface PlatformHubResultShape {
@@ -98,6 +120,8 @@ export interface PlatformHubResultShape {
98
120
  stats: Record<string, "number" | "string">;
99
121
  }
100
122
  export interface PlatformHubPlace {
123
+ owner?: string;
124
+ reconnectWindowMs?: number;
101
125
  place?: string;
102
126
  title?: string | null;
103
127
  result?: PlatformHubResultShape | null;
@@ -124,6 +148,20 @@ export interface PlatformWorldCharacterRead {
124
148
  };
125
149
  query: unknown;
126
150
  }
151
+ /** A lookup a place declares for one character turn. */
152
+ export interface PlatformPlaceTool {
153
+ name: string;
154
+ description: string;
155
+ input_schema: Record<string, unknown>;
156
+ }
157
+ export interface PlatformWorldCharacterTool {
158
+ requestId: string;
159
+ operationId: string;
160
+ characterId: string;
161
+ playerId: string;
162
+ name: string;
163
+ input: Record<string, unknown>;
164
+ }
127
165
  export interface PlatformWorldCharacterTurn {
128
166
  grantId: string;
129
167
  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;
@@ -45,6 +51,8 @@ export interface ServerStorageBoard {
45
51
  */
46
52
  export interface ServerPlaythrough {
47
53
  readonly generation: number;
54
+ /** Present only after an administrator reset this player's deck progress. */
55
+ readonly resetGeneration?: number;
48
56
  /** Explicit narrative changes for this same player and generation. */
49
57
  readonly characters: ServerPlaythroughCharacters;
50
58
  get<T extends Json = Json>(keys: string[]): Promise<Record<string, T>>;
@@ -263,6 +271,13 @@ export interface ServerStorage extends SaveApi {
263
271
  readonly deck: ServerStorageScope;
264
272
  /** One player's public data. Readable by anyone; writable by them or by this server. */
265
273
  user(userId: string): ServerStorageScope;
274
+ /**
275
+ * The door deck's source town's deck-scope rows, for one player in this session.
276
+ * Only a door deck's server may call it; a town gets CAULDRON_HUB_NO_TOWN.
277
+ * In an owner house, only the owner's rows are accessible. Quota counts against
278
+ * the town. Each key must be tokens|spent|own|hat|chirp, then userId, as its first two segments.
279
+ */
280
+ town(userId: string): ServerTownStorage;
266
281
  board(name: string): ServerStorageBoard;
267
282
  /** Opens one player's experimental, generation-bound game namespace. */
268
283
  playthrough(userId: string): Promise<ServerPlaythrough>;