castle-web-sdk 0.4.47 → 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.
@@ -46,13 +46,17 @@ async function join(options) {
46
46
  const request = hub
47
47
  ? { command: "hub", nonce: hub.nonce }
48
48
  : { command: "multiplayer.getSession", params: joinParams(options) };
49
+ const pongTimeoutMs = options.pongTimeoutMs ?? 0;
50
+ if (!Number.isFinite(pongTimeoutMs) || pongTimeoutMs < 0 || pongTimeoutMs > 2_147_483_647) {
51
+ throw new RangeError("pongTimeoutMs must be between 0 and 2147483647 milliseconds");
52
+ }
49
53
  latestJoinAttempt += 1;
50
54
  cancelPendingDestinationJoin();
51
55
  activeConnection?.leave();
52
56
  const connection = new MultiplayerConnectionImpl(request, () => {
53
57
  if (activeConnection === connection)
54
58
  activeConnection = null;
55
- });
59
+ }, null, pongTimeoutMs);
56
60
  activeConnection = connection;
57
61
  await connection.start(hub ?? undefined);
58
62
  // The host moves its place (and every place-scoped command with it) when the
@@ -183,9 +187,12 @@ class MultiplayerConnectionImpl {
183
187
  request;
184
188
  onClosed;
185
189
  destinationAudience;
190
+ pongTimeoutMs;
186
191
  currentState = "acquiring";
187
192
  currentPlayerId = "";
188
193
  currentSessionId = "";
194
+ currentServerBundleSha256 = null;
195
+ currentServerBundleId = null;
189
196
  solo = false;
190
197
  currentSoloReason = null;
191
198
  baseUrl = "";
@@ -194,6 +201,8 @@ class MultiplayerConnectionImpl {
194
201
  socket = null;
195
202
  socketAttempt = 0;
196
203
  keepalive = null;
204
+ pongExtensionUsed = false;
205
+ pongDeadline = null;
197
206
  reAcquisitionUsed = false;
198
207
  recovering = false;
199
208
  hubSettled = false;
@@ -219,10 +228,11 @@ class MultiplayerConnectionImpl {
219
228
  players: () => this.roster?.players ?? [],
220
229
  isSolo: () => this.solo,
221
230
  });
222
- constructor(request, onClosed, destinationAudience = null) {
231
+ constructor(request, onClosed, destinationAudience = null, pongTimeoutMs = 0) {
223
232
  this.request = request;
224
233
  this.onClosed = onClosed;
225
234
  this.destinationAudience = destinationAudience;
235
+ this.pongTimeoutMs = pongTimeoutMs;
226
236
  }
227
237
  get state() {
228
238
  return this.currentState;
@@ -233,6 +243,12 @@ class MultiplayerConnectionImpl {
233
243
  get sessionId() {
234
244
  return this.currentSessionId;
235
245
  }
246
+ get serverBundleSha256() {
247
+ return this.currentState === "connected" && !this.solo ? this.currentServerBundleSha256 : null;
248
+ }
249
+ get serverBundleId() {
250
+ return this.currentState === "connected" && !this.solo ? this.currentServerBundleId : null;
251
+ }
236
252
  get isSolo() {
237
253
  return this.solo;
238
254
  }
@@ -553,6 +569,10 @@ class MultiplayerConnectionImpl {
553
569
  acceptHello(frame) {
554
570
  this.currentPlayerId = frame.playerId;
555
571
  this.currentSessionId = frame.sessionId;
572
+ // Diagnostics never decide whether a hello is accepted. Missing or malformed
573
+ // metadata clears the previous host's identity on every reconnect.
574
+ this.currentServerBundleSha256 = typeof frame.serverBundleSha256 === "string" && /^[0-9a-f]{64}$/.test(frame.serverBundleSha256) ? frame.serverBundleSha256 : null;
575
+ this.currentServerBundleId = typeof frame.serverBundleId === "string" && /^[A-Za-z0-9_-]{10}$/.test(frame.serverBundleId) ? frame.serverBundleId : null;
556
576
  this.reconnectToken = frame.reconnectToken;
557
577
  this.sharedCharactersSupported = frame.capabilities?.sharedCharactersV1 === true;
558
578
  if (this.devReconnectStorageKey)
@@ -587,6 +607,8 @@ class MultiplayerConnectionImpl {
587
607
  this.acceptHello(frame);
588
608
  break;
589
609
  case "pong":
610
+ this.pongExtensionUsed = false;
611
+ this.clearPongDeadline();
590
612
  break;
591
613
  }
592
614
  }
@@ -676,10 +698,14 @@ class MultiplayerConnectionImpl {
676
698
  }
677
699
  startKeepalive() {
678
700
  this.stopKeepalive();
701
+ this.pongExtensionUsed = false;
679
702
  this.keepalive = setInterval(() => {
680
703
  if (this.socket?.readyState === WebSocket.OPEN) {
704
+ if (this.pongDeadline !== null)
705
+ return;
681
706
  try {
682
707
  this.socket.send('{"t":"ping"}');
708
+ this.expectPong(this.socket);
683
709
  }
684
710
  catch {
685
711
  closeSocket(this.socket, 1000, "keepalive failed");
@@ -688,11 +714,46 @@ class MultiplayerConnectionImpl {
688
714
  }, KEEPALIVE_INTERVAL_MS);
689
715
  }
690
716
  stopKeepalive() {
717
+ this.clearPongDeadline();
691
718
  if (this.keepalive === null)
692
719
  return;
693
720
  clearInterval(this.keepalive);
694
721
  this.keepalive = null;
695
722
  }
723
+ clearPongDeadline() {
724
+ if (this.pongDeadline === null)
725
+ return;
726
+ clearTimeout(this.pongDeadline);
727
+ this.pongDeadline = null;
728
+ }
729
+ expectPong(socket) {
730
+ if (!this.pongTimeoutMs)
731
+ return;
732
+ const due = Date.now() + this.pongTimeoutMs;
733
+ this.pongDeadline = setTimeout(() => {
734
+ this.pongDeadline = null;
735
+ if (this.socket !== socket || this.currentState !== "connected")
736
+ return;
737
+ // A suspended page can run an overdue timer before queued socket events.
738
+ // Verify liveness with a fresh ping instead of condemning that socket.
739
+ if (!this.pongExtensionUsed && Date.now() - due > 1_000 && socket.readyState === WebSocket.OPEN) {
740
+ this.pongExtensionUsed = true;
741
+ try {
742
+ socket.send('{"t":"ping"}');
743
+ this.expectPong(socket);
744
+ return;
745
+ }
746
+ catch { /* Fall through to recovery if the fresh probe cannot send. */ }
747
+ }
748
+ // A half-open transport may never emit close. Fence its queued callbacks
749
+ // and enter recovery directly rather than waiting for that close event.
750
+ this.socketAttempt += 1;
751
+ this.socket = null;
752
+ this.stopKeepalive();
753
+ closeSocket(socket, 1000, "pong timeout");
754
+ this.beginRecovery();
755
+ }, this.pongTimeoutMs);
756
+ }
696
757
  transition(state, metadata) {
697
758
  if (this.currentState === "closed" || this.currentState === state)
698
759
  return;
@@ -14,6 +14,8 @@ export interface SessionPlayer extends PlayerIdentity {
14
14
  playerId: string;
15
15
  }
16
16
  export interface ClientHelloFrame {
17
+ serverBundleSha256?: string;
18
+ serverBundleId?: string;
17
19
  t: "hello";
18
20
  playerId: string;
19
21
  reconnectToken: string;
@@ -0,0 +1,23 @@
1
+ import type { PartyMember, PartyMessage, PartyMessagesResult, PartySummary } from './commands';
2
+ export type { PartyMember, PartyMessage, PartyMessagesResult, PartyStatus, PartySummary, } from './commands';
3
+ export interface CastlePartyApi {
4
+ /** The party this play was launched with, or null when it has none. */
5
+ current(): Promise<PartySummary | null>;
6
+ /** Create a party containing these users and the signed-in player. */
7
+ create(userIds: string[]): Promise<PartySummary>;
8
+ invite(partyId: string, userIds: string[]): Promise<{
9
+ ok: true;
10
+ }>;
11
+ respond(partyId: string, accept: boolean): Promise<{
12
+ ok: true;
13
+ }>;
14
+ members(partyId: string): Promise<PartyMember[]>;
15
+ leave(partyId: string): Promise<{
16
+ ok: true;
17
+ }>;
18
+ /** Newest first. Pass `cursor` to load older history. */
19
+ messages(partyId: string, cursor?: string): Promise<PartyMessagesResult>;
20
+ /** Send plain text; the host trims it to Castle's 1,000-character limit. */
21
+ say(partyId: string, text: string): Promise<PartyMessage>;
22
+ }
23
+ export declare const Party: CastlePartyApi;
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
  /**
@@ -241,12 +243,14 @@ function encodeChange(change, server) {
241
243
  ...(remove === undefined ? {} : { remove }),
242
244
  };
243
245
  }
244
- function encodeCheck(check, server) {
246
+ function encodeCheck(check, server, conditional = false) {
245
247
  const target = validateSaveTarget(check.target, server);
248
+ if (conditional && check.expectedRevision === undefined)
249
+ invalid("Every conditional check needs an expected revision.");
246
250
  if (check.expectedRevision === undefined && check.writer === undefined) {
247
251
  invalid("A check needs an expected revision or a writer.");
248
252
  }
249
- if (check.expectedRevision !== undefined) {
253
+ if (check.expectedRevision !== undefined && !(conditional && check.expectedRevision === null)) {
250
254
  checkText(check.expectedRevision, "expected revision");
251
255
  }
252
256
  if (check.writer !== undefined)
@@ -259,7 +263,7 @@ function encodeCheck(check, server) {
259
263
  ...(check.writer === undefined ? {} : { writer: check.writer }),
260
264
  };
261
265
  }
262
- function encodeTransaction(input, server) {
266
+ function encodeTransaction(input, server, conditional = false) {
263
267
  const changes = checkList(input.changes, "changes").map((change) => encodeChange(change, server));
264
268
  const keys = changes.map((change) => saveTargetKey(change.target));
265
269
  if (new Set(keys).size !== keys.length) {
@@ -271,7 +275,7 @@ function encodeTransaction(input, server) {
271
275
  ...(input.checks === undefined
272
276
  ? {}
273
277
  : {
274
- checks: checkList(input.checks, "checks").map((check) => encodeCheck(check, server)),
278
+ checks: checkList(input.checks, "checks").map((check) => encodeCheck(check, server, conditional)),
275
279
  }),
276
280
  changes,
277
281
  };
@@ -283,12 +287,26 @@ export function encodeSaveInput(verb, input, server = false) {
283
287
  }
284
288
  if (verb === "snapshot")
285
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
+ }
286
298
  checkText(input.operationId, "operation id", MAX_KEY_BYTES);
287
299
  if (verb === "operation")
288
300
  return { operationId: input.operationId };
289
- 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
+ }
290
308
  return {
291
- ...encodeTransaction(input, server),
309
+ ...encodeTransaction(input, server, verb === "conditionalWrite"),
292
310
  };
293
311
  }
294
312
  if (input.expectedAccess !== undefined) {
@@ -423,6 +441,34 @@ export function createSaveApi(call, server = false) {
423
441
  }
424
442
  }
425
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
+ },
426
472
  async snapshot(input) {
427
473
  return decodeSnapshot(await send("snapshot", { ...input }), input.targets);
428
474
  },
@@ -184,8 +184,27 @@ export interface MultiplayerHubResultsRead {
184
184
  * and for a deck that declares no hub block.
185
185
  */
186
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>;
187
204
  /** The owner user id, only in an owner door instance. */
188
205
  readonly owner?: string;
206
+ /** Host-pinned inclusive reconnect grace for this hub place, in milliseconds. */
207
+ readonly reconnectWindowMs?: number;
189
208
  readonly place: string | null;
190
209
  readonly title: string | null;
191
210
  readonly result: MultiplayerHubResultShape | null;
@@ -236,9 +255,13 @@ export interface MultiplayerServerHub {
236
255
  /**
237
256
  * The connected player's mutual friends and follows in either direction among
238
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.
239
259
  * These relations belong to this player only; never send them to another client.
240
260
  */
241
- relationsAmong(playerId: string, userIds: readonly string[]): Promise<{
261
+ relationsAmong(playerId: string, userIds: readonly string[], options?: {
262
+ strict: true;
263
+ }): Promise<{
264
+ validated?: true;
242
265
  friends: string[];
243
266
  follows: string[];
244
267
  followedBy: string[];
@@ -46,6 +46,11 @@ function unavailable(op, what) {
46
46
  operation: `session.hub.${op}`,
47
47
  });
48
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
+ }
49
54
  export function createServerHub(platformHandle, arrivals, hiddenPair = () => false) {
50
55
  const pinned = platformHandle.hub;
51
56
  if (!pinned ||
@@ -72,17 +77,35 @@ export function createServerHub(platformHandle, arrivals, hiddenPair = () => fal
72
77
  }
73
78
  return parsed;
74
79
  };
75
- 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
+ },
76
88
  place: typeof pinned.place === "string" ? pinned.place : null,
77
89
  title: typeof pinned.title === "string" ? pinned.title : null,
78
90
  result: pinned.result ?? null,
79
91
  ...(typeof pinned.owner === "string" && pinned.owner.length > 0 ? { owner: pinned.owner } : {}),
92
+ ...reconnectMetadata(pinned),
80
93
  arrival(playerId) {
81
94
  return arrivals.get(playerId) ?? null;
82
95
  },
83
96
  hiddenPair(playerIdA, playerIdB) {
84
97
  return hiddenPair(playerIdA, playerIdB);
85
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
+ },
86
109
  async friendsAmong(playerId, userIds) {
87
110
  const json = JSON.stringify({ playerId, userIds: [...userIds] });
88
111
  const raw = typeof platformHandle.hubFriendsAmong === "function"
@@ -91,7 +114,7 @@ export function createServerHub(platformHandle, arrivals, hiddenPair = () => fal
91
114
  const answer = JSON.parse(raw ?? "null");
92
115
  return Array.isArray(answer?.userIds) ? answer.userIds : [];
93
116
  },
94
- async relationsAmong(playerId, userIds) {
117
+ async relationsAmong(playerId, userIds, options = undefined) {
95
118
  const asked = new Set(userIds);
96
119
  const json = JSON.stringify({ playerId, userIds: [...userIds] });
97
120
  let raw;
@@ -102,10 +125,14 @@ export function createServerHub(platformHandle, arrivals, hiddenPair = () => fal
102
125
  raw = await platformHandle.storage?.("hubFriendsAmong", json);
103
126
  }
104
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
+ }
105
131
  const list = (value) => Array.isArray(value)
106
132
  ? value.filter((id) => typeof id === "string" && asked.has(id))
107
133
  : [];
108
134
  return {
135
+ ...(options?.strict === true ? { validated: true } : {}),
109
136
  friends: list(answer?.userIds),
110
137
  follows: list(answer?.follows),
111
138
  followedBy: list(answer?.followedBy),
@@ -191,5 +218,10 @@ export function createServerHub(platformHandle, arrivals, hiddenPair = () => fal
191
218
  players: (read.players ?? {}),
192
219
  };
193
220
  },
194
- });
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);
195
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;
@@ -66,11 +68,16 @@ export interface PlatformHandle {
66
68
  storage?(op: string, argsJson: string): Promise<string>;
67
69
  /** False when the host cannot broker storage, so a call can fail at once. */
68
70
  readonly storageEnabled?: boolean;
71
+ readonly hubTextArchiveEnabled?: boolean;
69
72
  /** Persistent-world checkpoint broker. The host stamps the hidden world binding. */
70
73
  worldCheckpointRead?(): Promise<string>;
71
74
  worldCheckpointCommit?(argsJson: string): Promise<string>;
72
75
  /** `tools` is the turn's place tools, passed only when non-empty. */
73
- worldCharacterSend?(grantId: string, tools?: readonly PlatformPlaceTool[]): Promise<string>;
76
+ worldCharacterSend?(grantId: string, tools?: readonly PlatformPlaceTool[], hubTextArchive?: {
77
+ npcId: string;
78
+ conversationId?: string;
79
+ input: string;
80
+ }): Promise<string>;
74
81
  worldCharacterCommand?(grantId: string): Promise<string>;
75
82
  worldCharacterRead?(grantId: string): Promise<string>;
76
83
  worldCharacterRuntimeCreate?(argsJson: string): Promise<string>;
@@ -114,6 +121,7 @@ export interface PlatformHubResultShape {
114
121
  }
115
122
  export interface PlatformHubPlace {
116
123
  owner?: string;
124
+ reconnectWindowMs?: number;
117
125
  place?: string;
118
126
  title?: string | null;
119
127
  result?: PlatformHubResultShape | null;
@@ -51,6 +51,8 @@ export interface ServerStorageBoard {
51
51
  */
52
52
  export interface ServerPlaythrough {
53
53
  readonly generation: number;
54
+ /** Present only after an administrator reset this player's deck progress. */
55
+ readonly resetGeneration?: number;
54
56
  /** Explicit narrative changes for this same player and generation. */
55
57
  readonly characters: ServerPlaythroughCharacters;
56
58
  get<T extends Json = Json>(keys: string[]): Promise<Record<string, T>>;
@@ -100,11 +100,11 @@ export function createServerStorage(platformHandle) {
100
100
  async playthrough(userId) {
101
101
  const subject = assertUserId(userId, "session.storage.playthrough");
102
102
  const opened = await call("playthroughOpen", { subject });
103
- return createPlaythrough(call, subject, assertGeneration(opened?.generation));
103
+ return createPlaythrough(call, subject, assertGeneration(opened?.generation), optionalResetGeneration(opened?.resetGeneration));
104
104
  },
105
105
  };
106
106
  }
107
- function createPlaythrough(call, subject, generation) {
107
+ function createPlaythrough(call, subject, generation, resetGeneration) {
108
108
  const characters = Object.freeze({
109
109
  async send(input) {
110
110
  const characterId = assertNarrativeId(input?.characterId, "characterId");
@@ -220,6 +220,7 @@ function createPlaythrough(call, subject, generation) {
220
220
  });
221
221
  return Object.freeze({
222
222
  generation,
223
+ ...(resetGeneration === undefined ? {} : { resetGeneration }),
223
224
  characters,
224
225
  async get(keys) {
225
226
  const entries = await call("playthroughGet", { subject, generation, keys: assertKeys(keys, "session.storage.playthrough.get") });
@@ -242,7 +243,7 @@ function createPlaythrough(call, subject, generation) {
242
243
  generation,
243
244
  operationId: assertOperationId(operationId),
244
245
  });
245
- return createPlaythrough(call, subject, assertGeneration(reset?.generation));
246
+ return createPlaythrough(call, subject, assertGeneration(reset?.generation), resetGeneration);
246
247
  },
247
248
  });
248
249
  }
@@ -417,6 +418,13 @@ function assertGeneration(value) {
417
418
  }
418
419
  return value;
419
420
  }
421
+ function optionalResetGeneration(value) {
422
+ if (value === undefined || value === null)
423
+ return undefined;
424
+ if (Number.isSafeInteger(value) && value >= 1 && value <= MAX_GENERATION)
425
+ return value;
426
+ throw storageError("CAULDRON_STORAGE_INVALID_RESPONSE", "The platform returned an invalid progress reset generation.", "session.storage.playthrough");
427
+ }
420
428
  function assertOperationId(value) {
421
429
  if (typeof value !== "string" ||
422
430
  value.length === 0 ||