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.
@@ -91,17 +91,20 @@ 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
  },
97
100
  async playthrough(userId) {
98
101
  const subject = assertUserId(userId, "session.storage.playthrough");
99
102
  const opened = await call("playthroughOpen", { subject });
100
- return createPlaythrough(call, subject, assertGeneration(opened?.generation));
103
+ return createPlaythrough(call, subject, assertGeneration(opened?.generation), optionalResetGeneration(opened?.resetGeneration));
101
104
  },
102
105
  };
103
106
  }
104
- function createPlaythrough(call, subject, generation) {
107
+ function createPlaythrough(call, subject, generation, resetGeneration) {
105
108
  const characters = Object.freeze({
106
109
  async send(input) {
107
110
  const characterId = assertNarrativeId(input?.characterId, "characterId");
@@ -217,6 +220,7 @@ function createPlaythrough(call, subject, generation) {
217
220
  });
218
221
  return Object.freeze({
219
222
  generation,
223
+ ...(resetGeneration === undefined ? {} : { resetGeneration }),
220
224
  characters,
221
225
  async get(keys) {
222
226
  const entries = await call("playthroughGet", { subject, generation, keys: assertKeys(keys, "session.storage.playthrough.get") });
@@ -239,10 +243,53 @@ function createPlaythrough(call, subject, generation) {
239
243
  generation,
240
244
  operationId: assertOperationId(operationId),
241
245
  });
242
- return createPlaythrough(call, subject, assertGeneration(reset?.generation));
246
+ return createPlaythrough(call, subject, assertGeneration(reset?.generation), resetGeneration);
243
247
  },
244
248
  });
245
249
  }
250
+ /** The town's per-player namespaces a door deck may reach; ghost holds the same list. */
251
+ const TOWN_PLAYER_NAMESPACES = ["tokens", "spent", "own", "hat", "chirp"];
252
+ function createTownStorage(call, subject) {
253
+ const args = { scope: "DECK", subject, place: "town" };
254
+ // Validate in the async verbs, so a bad subject poisons the handle without
255
+ // throwing when the author creates it. Validate every key before any chunk lands.
256
+ const keysFor = (keys, operation) => {
257
+ assertUserId(subject, operation);
258
+ const checked = assertKeys(keys, operation);
259
+ for (const key of checked) {
260
+ const segments = key.split(":");
261
+ if (!TOWN_PLAYER_NAMESPACES.includes(segments[0]) || segments[1] !== subject) {
262
+ throw storageError("CAULDRON_STORAGE_INVALID_ARGUMENT", `Town storage key ${JSON.stringify(key)} must be ${TOWN_PLAYER_NAMESPACES.join("|")}:${subject}[:...].`, operation);
263
+ }
264
+ }
265
+ return checked;
266
+ };
267
+ return {
268
+ async get(keys) {
269
+ const entries = [];
270
+ for (const chunk of chunked(keysFor(keys, "session.storage.town.get"), MAX_KEYS_PER_CALL)) {
271
+ entries.push(...await call("get", { ...args, keys: chunk }));
272
+ }
273
+ return decodeEntries(entries, "session.storage.town.get");
274
+ },
275
+ async set(values) {
276
+ keysFor(Object.keys(values), "session.storage.town.set");
277
+ const entries = encodeEntries(values, "session.storage.town.set");
278
+ for (const chunk of batched(entries, "session.storage.town.set")) {
279
+ await call("set", { ...args, entries: chunk });
280
+ }
281
+ },
282
+ async remove(keys) {
283
+ for (const chunk of chunked(keysFor(keys, "session.storage.town.remove"), MAX_KEYS_PER_CALL)) {
284
+ await call("delete", { ...args, keys: chunk });
285
+ }
286
+ },
287
+ async increment(key, delta = 1) {
288
+ keysFor([key], "session.storage.town.increment");
289
+ return Number(await call("increment", { ...args, key, delta }));
290
+ },
291
+ };
292
+ }
246
293
  function createScope(call, scope, subject, saveApi) {
247
294
  return {
248
295
  ...createSaveBucket(saveApi, scope === "DECK" ? "deck" : "user", subject ?? undefined),
@@ -371,6 +418,13 @@ function assertGeneration(value) {
371
418
  }
372
419
  return value;
373
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
+ }
374
428
  function assertOperationId(value) {
375
429
  if (typeof value !== "string" ||
376
430
  value.length === 0 ||
@@ -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";
@@ -7,9 +7,9 @@ export type { MultiplayerSessionMode, PlatformHandle, PlatformSessionConfig, Pla
7
7
  export { createSaveCoordinator } from "../saveCoordinator";
8
8
  export type { CoordinatedChange, CoordinatedSave, SaveCoordinator, SaveStatus, } from "../saveCoordinator";
9
9
  export { SaveError } from "../save";
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";
10
+ export type { SaveApi, SaveBucketApi, SaveBucketRead, SaveChange, SaveCheck, SaveConditionalCheck, SaveConditionalWriteInput, SaveClaimed, SaveClaimInput, SaveErrorEnvelope, SaveInitial, SaveInitializeInput, SaveOpened, SaveOperation, SaveReadTarget, SaveReceipt, SaveRecentOperationsInput, SaveRecentOperationsPage, 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, 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,41 @@ 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
+ /** Private host-brokered transcript metadata, not player-selected. */
292
+ hubTextArchive?: {
293
+ npcId: string;
294
+ conversationId?: string;
295
+ input: string;
296
+ };
297
+ }
298
+ /** One place-tool use by a character during a turn this place admitted. */
299
+ export interface MultiplayerWorldCharacterToolRequest {
300
+ readonly operationId: string;
301
+ readonly characterId: string;
302
+ readonly playerId: string;
303
+ readonly name: string;
304
+ readonly input: Readonly<Record<string, unknown>>;
305
+ }
276
306
  type CallbackResult = unknown;
277
307
  export interface MultiplayerServerCallbacks {
278
308
  onStart?: (session: MultiplayerServerSession) => CallbackResult;
@@ -284,6 +314,13 @@ export interface MultiplayerServerCallbacks {
284
314
  onWorldCharacterTurn?: (session: MultiplayerServerSession, turn: MultiplayerWorldCharacterTurn) => CallbackResult;
285
315
  onWorldCharacterCommand?: (session: MultiplayerServerSession, request: MultiplayerWorldCharacterCommandRequest) => CallbackResult;
286
316
  onWorldCharacterRead?: (session: MultiplayerServerSession, request: MultiplayerWorldCharacterReadRequest) => CallbackResult;
317
+ /**
318
+ * Answers a place-tool use declared through `turn.send({ tools })`. Return a
319
+ * string or a JSON value (stringified); a result over 1 KiB becomes
320
+ * `{"error":"result too large"}` and a throw `{"error":"tool failed"}`. The host
321
+ * waits 1.5 s, then the character answers without it.
322
+ */
323
+ onWorldCharacterTool?: (request: MultiplayerWorldCharacterToolRequest, session: MultiplayerServerSession) => unknown;
287
324
  onTick?: (session: MultiplayerServerSession) => CallbackResult;
288
325
  onReplaced?: (session: MultiplayerServerSession) => CallbackResult;
289
326
  /**
@@ -1,3 +1,4 @@
1
+ import { CastleError } from "../errors";
1
2
  import { isBinaryPayload, toUint8Array, } from "../multiplayerProtocol";
2
3
  import { encodeStorageValue, storageError } from "../storageJson";
3
4
  import { createServerStorage } from "./storage";
@@ -69,18 +70,15 @@ async function bootSession(platformHandle, callbacks) {
69
70
  players: Object.freeze(request.audience.players.map((player) => Object.freeze(joinedPlayer(player.playerId, player)))),
70
71
  characterIds: Object.freeze([...request.audience.characterIds]),
71
72
  }),
72
- async send() {
73
+ async send(options) {
73
74
  if (settled)
74
75
  throw new TypeError("This shared character request is already settled.");
75
76
  settled = true;
76
- let rawReceipt;
77
- try {
78
- rawReceipt = await platformHandle.worldCharacterSend(request.grantId);
79
- }
80
- catch (error) {
77
+ const rawReceipt = await sendWorldCharacterTurn(platformHandle, request.grantId, options)
78
+ .catch((error) => {
81
79
  brokerFailure.rejection = { error };
82
80
  throw error;
83
- }
81
+ });
84
82
  return parseWorldCharacterAdmission(rawReceipt, request.operationId);
85
83
  },
86
84
  reject(code) {
@@ -103,6 +101,7 @@ async function bootSession(platformHandle, callbacks) {
103
101
  }
104
102
  });
105
103
  }
104
+ listenForWorldCharacterTools(platformHandle, callbacks, session);
106
105
  if (callbacks.onWorldCharacterCommand &&
107
106
  hasWorldCharacterPlatform(platformHandle, "worldCharacterCommand")) {
108
107
  platformHandle.on("worldCharacterCommand", async (raw) => {
@@ -1081,12 +1080,13 @@ function assertRuntimeCharacterCapabilities(value) {
1081
1080
  }
1082
1081
  return Object.freeze({ actionIds: Object.freeze([...normalized].sort()) });
1083
1082
  }
1083
+ const LONE_SURROGATE = /[\uD800-\uDFFF]/u;
1084
1084
  function assertRuntimeCharacterText(value, field, maxCharacters, maxBytes) {
1085
1085
  if (typeof value !== "string" ||
1086
1086
  value.length === 0 ||
1087
1087
  value.length > maxCharacters ||
1088
1088
  value.includes("\0") ||
1089
- /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(value) ||
1089
+ LONE_SURROGATE.test(value) ||
1090
1090
  new TextEncoder().encode(value).byteLength > maxBytes) {
1091
1091
  throw new TypeError(`session.world.characters.create: ${field} must be valid non-empty text of at most ${maxCharacters} characters.`);
1092
1092
  }
@@ -1190,6 +1190,87 @@ function assertPlatformWorldCharacterRead(value) {
1190
1190
  }
1191
1191
  return value;
1192
1192
  }
1193
+ // Tools ride only to a host that can answer them (an older host refuses a send
1194
+ // carrying anything beyond the grant); otherwise this is exactly the tool-less send.
1195
+ function sendWorldCharacterTurn(platformHandle, grantId, options) {
1196
+ const tools = options?.tools;
1197
+ if (options?.hubTextArchive !== undefined) {
1198
+ if (platformHandle.hubTextArchiveEnabled !== true)
1199
+ return Promise.reject(new CastleError({ code: 'CAULDRON_HUB_UNAVAILABLE', message: 'This host cannot archive NPC transcripts.', operation: 'worldCharacter.send' }));
1200
+ return platformHandle.worldCharacterSend(grantId, Array.isArray(tools) && typeof platformHandle.worldCharacterToolResult === "function" ? tools : undefined, options.hubTextArchive);
1201
+ }
1202
+ return Array.isArray(tools) && tools.length > 0 &&
1203
+ typeof platformHandle.worldCharacterToolResult === "function"
1204
+ ? platformHandle.worldCharacterSend(grantId, tools)
1205
+ : platformHandle.worldCharacterSend(grantId);
1206
+ }
1207
+ // Only a handler on a host that answers place tools listens; without one the
1208
+ // host times the lookup out and the character answers without it.
1209
+ function listenForWorldCharacterTools(platformHandle, callbacks, session) {
1210
+ const handler = callbacks.onWorldCharacterTool;
1211
+ const answer = platformHandle.worldCharacterToolResult?.bind(platformHandle);
1212
+ if (!handler || !answer)
1213
+ return;
1214
+ try {
1215
+ platformHandle.on("worldCharacterTool", async (raw) => {
1216
+ const call = platformWorldCharacterTool(raw);
1217
+ if (!call)
1218
+ return;
1219
+ const result = await worldCharacterToolResult(handler, call.request, session);
1220
+ try {
1221
+ await answer(call.requestId, result);
1222
+ }
1223
+ catch {
1224
+ // The host has stopped waiting (or refused); the character answers without it.
1225
+ }
1226
+ });
1227
+ }
1228
+ catch {
1229
+ // Older shims may reject event names they do not recognize.
1230
+ }
1231
+ }
1232
+ const MAX_WORLD_CHARACTER_TOOL_RESULT_BYTES = 1024;
1233
+ const WORLD_CHARACTER_TOOL_TOO_LARGE = '{"error":"result too large"}';
1234
+ const WORLD_CHARACTER_TOOL_FAILED = '{"error":"tool failed"}';
1235
+ function platformWorldCharacterTool(value) {
1236
+ if (!value ||
1237
+ typeof value !== "object" ||
1238
+ typeof value.requestId !== "string" ||
1239
+ typeof value.operationId !== "string" ||
1240
+ typeof value.characterId !== "string" ||
1241
+ typeof value.playerId !== "string" ||
1242
+ typeof value.name !== "string" ||
1243
+ !value.input ||
1244
+ typeof value.input !== "object" ||
1245
+ Array.isArray(value.input)) {
1246
+ return null;
1247
+ }
1248
+ return {
1249
+ requestId: value.requestId,
1250
+ request: Object.freeze({
1251
+ operationId: value.operationId,
1252
+ characterId: value.characterId,
1253
+ playerId: value.playerId,
1254
+ name: value.name,
1255
+ input: deepFreezeJsonObject(value.input),
1256
+ }),
1257
+ };
1258
+ }
1259
+ async function worldCharacterToolResult(handler, request, session) {
1260
+ let result;
1261
+ try {
1262
+ const value = await handler(request, session);
1263
+ result = typeof value === "string" ? value : JSON.stringify(value);
1264
+ }
1265
+ catch {
1266
+ return WORLD_CHARACTER_TOOL_FAILED;
1267
+ }
1268
+ if (typeof result !== "string")
1269
+ return WORLD_CHARACTER_TOOL_FAILED;
1270
+ return new TextEncoder().encode(result).byteLength > MAX_WORLD_CHARACTER_TOOL_RESULT_BYTES
1271
+ ? WORLD_CHARACTER_TOOL_TOO_LARGE
1272
+ : result;
1273
+ }
1193
1274
  function hasWorldCharacterPlatform(platformHandle, operation) {
1194
1275
  return (typeof platformHandle[operation] === "function" &&
1195
1276
  typeof platformHandle.worldCharacterReject === "function");
@@ -1344,10 +1425,11 @@ function joinedPlayer(connId, identity) {
1344
1425
  delete player.hiddenUserIds;
1345
1426
  // A/B arms arrive beside the player; one a host put on it stays off it too.
1346
1427
  delete player.experimentVariants;
1428
+ Object.defineProperty(player, "under13", { value: identity.under13 === true, enumerable: false });
1347
1429
  return player;
1348
1430
  }
1349
1431
  const legacy = connId;
1350
- return {
1432
+ const player = {
1351
1433
  playerId: legacy.playerId,
1352
1434
  userId: legacy.userId,
1353
1435
  username: legacy.username,
@@ -1355,6 +1437,8 @@ function joinedPlayer(connId, identity) {
1355
1437
  participantId: legacy.scopedUserId ?? legacy.userId,
1356
1438
  ...(legacy.scopedUserId ? { scopedUserId: legacy.scopedUserId } : {}),
1357
1439
  };
1440
+ Object.defineProperty(player, "under13", { value: legacy.under13 === true, enumerable: false });
1441
+ return player;
1358
1442
  }
1359
1443
  function parseHandleMessage(text) {
1360
1444
  if (typeof text !== "string")
@@ -0,0 +1,9 @@
1
+ import type { SocialFriend, SocialPresence } from './commands';
2
+ export type { SocialFriend, SocialPresence } from './commands';
3
+ export interface CastleSocialApi {
4
+ /** The signed-in player's mutual Castle follows, at most 20. */
5
+ friends(): Promise<SocialFriend[]>;
6
+ /** Mutual friends playing this deck now. Poll to refresh; v1 has no event. */
7
+ presence(): Promise<SocialPresence[]>;
8
+ }
9
+ export declare const Social: CastleSocialApi;
package/dist/social.js ADDED
@@ -0,0 +1,5 @@
1
+ import { hostRequest } from './transport';
2
+ export const Social = {
3
+ friends: () => hostRequest('social.friends', {}),
4
+ presence: () => hostRequest('social.presence', {}),
5
+ };
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.48",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/castle-xyz/castle-experimental-web.git"