castle-web-sdk 0.4.45 → 0.4.46

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.
@@ -1,6 +1,9 @@
1
1
  import { isBinaryPayload, toUint8Array, } from "../multiplayerProtocol";
2
2
  import { encodeStorageValue, storageError } from "../storageJson";
3
3
  import { createServerStorage } from "./storage";
4
+ import { createServerHub, hubArrival, recordHiddenUserIds, } from "./hub";
5
+ export { createSaveCoordinator } from "../saveCoordinator";
6
+ export { SaveError } from "../save";
4
7
  // The publish bundler binds the author's default callbacks export once and
5
8
  // exports the returned one-argument function as `__castleBootSession`.
6
9
  export function createCastleSessionBoot(callbacks) {
@@ -10,19 +13,23 @@ export function createCastleSessionBoot(callbacks) {
10
13
  }
11
14
  async function bootSession(platformHandle, callbacks) {
12
15
  const players = new Map();
13
- const session = createAuthorSession(platformHandle, players);
16
+ const arrivals = new Map();
17
+ // playerId -> users a block hides from them; never on the player object.
18
+ const hidden = new Map();
19
+ // playerId -> their A/B arms while connected; never on the player object.
20
+ const variants = new Map();
21
+ const session = createAuthorSession(platformHandle, players, arrivals, hidden, variants);
14
22
  let tickTimer = null;
15
- platformHandle.on("join", async (connId, identity) => {
16
- // The compatibility branch accepts the current Node shim's legacy
17
- // single-argument SessionPlayer event while the public handle contract uses
18
- // the runtime-agnostic (connId, trusted identity) pair.
19
- const player = joinedPlayer(connId, identity);
20
- players.set(player.playerId, player);
21
- await callbacks.onPlayerJoin?.(session, player);
23
+ platformHandle.on("join", async (connId, identity, handleArrival, handleVariants) => {
24
+ const { player, arrival } = admitPlayer({ players, arrivals, hidden, variants }, connId, identity, handleArrival, handleVariants);
25
+ await callbacks.onPlayerJoin?.(session, player, arrival);
22
26
  });
23
27
  platformHandle.on("leave", async (connId) => {
24
28
  const player = players.get(connId);
25
29
  players.delete(connId);
30
+ arrivals.delete(connId);
31
+ hidden.delete(connId);
32
+ variants.delete(connId);
26
33
  if (player) {
27
34
  await callbacks.onPlayerLeave?.(session, player);
28
35
  }
@@ -103,6 +110,7 @@ async function bootSession(platformHandle, callbacks) {
103
110
  let settled = false;
104
111
  const brokerFailure = { rejection: null };
105
112
  const command = Object.freeze({
113
+ type: request.command.type,
106
114
  requestId: request.requestId,
107
115
  operationId: request.operationId,
108
116
  speaker: Object.freeze(joinedPlayer(request.speaker.playerId, request.speaker)),
@@ -202,7 +210,22 @@ async function bootSession(platformHandle, callbacks) {
202
210
  }, 1_000 / platformHandle.config.tickRate);
203
211
  }
204
212
  }
205
- function createAuthorSession(platformHandle, players) {
213
+ function createAuthorSession(platformHandle, players, arrivals, hidden, variants) {
214
+ // Lenient about what it is handed: an unknown player is in no experiment.
215
+ const variantsOf = (player) => variants.get(typeof player === "string"
216
+ ? player
217
+ : (player?.playerId ?? ""));
218
+ // The pinned place counts only where the host services the hub ops.
219
+ const hub = createServerHub(platformHandle, arrivals, (a, b) => {
220
+ if (hidden.size === 0 || a === b)
221
+ return false;
222
+ const userA = players.get(a)?.userId;
223
+ const userB = players.get(b)?.userId;
224
+ if (!userA || !userB)
225
+ return false;
226
+ return Boolean(hidden.get(a)?.has(userB) || hidden.get(b)?.has(userA));
227
+ });
228
+ const world = createServerWorld(platformHandle);
206
229
  return {
207
230
  get players() {
208
231
  return Array.from(players.values());
@@ -210,8 +233,20 @@ function createAuthorSession(platformHandle, players) {
210
233
  sessionId: platformHandle.config.sessionId,
211
234
  deckId: platformHandle.config.deckId,
212
235
  mode: platformHandle.config.mode,
236
+ place: typeof platformHandle.config.place === "string"
237
+ ? platformHandle.config.place
238
+ : hub?.place ?? null,
213
239
  storage: createServerStorage(platformHandle),
214
- world: createServerWorld(platformHandle),
240
+ world,
241
+ characters: world?.characters ?? createPlaceCharacters(platformHandle),
242
+ hub,
243
+ experiment(player, name) {
244
+ const known = variantsOf(player);
245
+ return known && Object.hasOwn(known, name) ? known[name] : null;
246
+ },
247
+ experiments(player) {
248
+ return { ...variantsOf(player) };
249
+ },
215
250
  send(playerId, data) {
216
251
  if (isBinaryPayload(data)) {
217
252
  requireSendBinary(platformHandle);
@@ -264,6 +299,24 @@ const WORLD_JOURNAL_MAX_READ_LIMIT = 128;
264
299
  const WORLD_PARTICIPANTS_MAX_READ_LIMIT = 256;
265
300
  const WORLD_JOURNAL_MAX_SEQUENCE = "9223372036854775807";
266
301
  const SAFE_WORLD_JOURNAL_KIND = /^[A-Za-z0-9_.:-]+$/;
302
+ const WORLD_NOTIFICATIONS_MAX_PARTICIPANTS = 16;
303
+ const WORLD_NOTIFICATIONS_MAX_SLOTS = 4;
304
+ const WORLD_NOTIFICATIONS_MAX_SLOT_CHARACTERS = 40;
305
+ const WORLD_NOTIFICATIONS_MAX_SCHEDULE_MS = 30 * 24 * 60 * 60 * 1_000;
306
+ const WORLD_NOTIFICATIONS_PAST_TOLERANCE_MS = 60 * 1_000;
307
+ const WORLD_CATCH_UP_DEFAULT_MAX_ELAPSED_MS = 7 * 24 * 60 * 60 * 1_000;
308
+ const SAFE_WORLD_NOTIFICATION_KEY = /^[A-Za-z0-9_-]+$/;
309
+ const CANONICAL_WORLD_NOTIFICATION_TIME = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,3}))?Z$/;
310
+ const URL_IN_WORLD_NOTIFICATION_TEXT = /(?:https?:\/\/|www\.|\b[a-z0-9-]+\.(?:app|com|dev|gg|io|net|org|xyz)\b)/i;
311
+ // A hub place's layer has no world of its own, but the host may still broker
312
+ // the place's shared characters into it (`placeCharacters` in the runtime
313
+ // init). The character API is the world one; only the binding differs, and the
314
+ // host stamps that.
315
+ function createPlaceCharacters(platformHandle) {
316
+ if (platformHandle.config.placeCharacters !== true)
317
+ return null;
318
+ return createServerWorldCharacters(platformHandle);
319
+ }
267
320
  function createServerWorld(platformHandle) {
268
321
  if (!platformHandle.config.world)
269
322
  return null;
@@ -272,36 +325,149 @@ function createServerWorld(platformHandle) {
272
325
  throw worldError("The persistent-world checkpoint broker is unavailable.", "read");
273
326
  }
274
327
  const initial = parseWorldSnapshot(platformHandle.config.world, "read");
328
+ const read = async () => {
329
+ const result = await platformHandle.worldCheckpointRead();
330
+ return parseWorldSnapshot(parsePlatformJson(result, "read"), "read");
331
+ };
332
+ const commit = async (input) => {
333
+ const expectedRevision = assertWorldRevision(input?.expectedRevision);
334
+ const operationId = assertWorldOperationId(input?.operationId);
335
+ const checkpoint = assertWorldCheckpoint(input?.checkpoint);
336
+ const raw = await platformHandle.worldCheckpointCommit(JSON.stringify({
337
+ expectedRevision,
338
+ operationId,
339
+ checkpoint,
340
+ }));
341
+ const result = parsePlatformJson(raw, "commit");
342
+ if (typeof result.replayed !== "boolean") {
343
+ throw worldError("The platform returned an invalid commit receipt.", "commit");
344
+ }
345
+ return Object.freeze({
346
+ revision: assertWorldRevision(result.revision),
347
+ replayed: result.replayed,
348
+ operationId,
349
+ });
350
+ };
275
351
  return Object.freeze({
276
352
  snapshot: initial,
277
353
  journal: createServerWorldJournal(platformHandle),
278
354
  participants: createServerWorldParticipants(platformHandle),
355
+ notifications: createServerWorldNotifications(platformHandle),
279
356
  characters: createServerWorldCharacters(platformHandle),
280
- async read() {
281
- const result = await platformHandle.worldCheckpointRead();
282
- return parseWorldSnapshot(parsePlatformJson(result, "read"), "read");
357
+ read,
358
+ commit,
359
+ async catchUp(options) {
360
+ return catchUpWorld(platformHandle.config.world?.worldTime, read, commit, options);
283
361
  },
284
- async commit(input) {
285
- const expectedRevision = assertWorldRevision(input?.expectedRevision);
286
- const operationId = assertWorldOperationId(input?.operationId);
287
- const checkpoint = assertWorldCheckpoint(input?.checkpoint);
288
- const raw = await platformHandle.worldCheckpointCommit(JSON.stringify({
289
- expectedRevision,
290
- operationId,
291
- checkpoint,
292
- }));
293
- const result = parsePlatformJson(raw, "commit");
294
- if (typeof result.replayed !== "boolean") {
295
- throw worldError("The platform returned an invalid commit receipt.", "commit");
362
+ });
363
+ }
364
+ async function catchUpWorld(worldTime, read, commit, options) {
365
+ const operation = "session.world.catchUp";
366
+ if (worldTime?.advance !== "wallClock") {
367
+ throw worldJournalArgumentError('The destination must opt in with worldTime.advance set to "wallClock".', operation);
368
+ }
369
+ assertOnlyKeys(options, ["key", "maxElapsedMs", "apply"], operation, "options");
370
+ const key = options.key === undefined
371
+ ? "advancedThroughMs"
372
+ : assertWorldCatchUpKey(options.key, operation);
373
+ const maxElapsedMs = options.maxElapsedMs === undefined
374
+ ? WORLD_CATCH_UP_DEFAULT_MAX_ELAPSED_MS
375
+ : assertBoundedInteger(options.maxElapsedMs, 0, Number.MAX_SAFE_INTEGER, "maxElapsedMs", operation);
376
+ if (typeof options.apply !== "function") {
377
+ throw worldJournalArgumentError("apply must be a function.", operation);
378
+ }
379
+ const now = Date.now();
380
+ let snapshot = await read();
381
+ for (let attempt = 0; attempt < 2; attempt += 1) {
382
+ const state = worldCatchUpState(snapshot.checkpoint, key, now, maxElapsedMs, operation);
383
+ if (state.markerExists && state.elapsedMs === 0) {
384
+ if (state.markerMs >= now) {
385
+ return Object.freeze({ applied: false, elapsedMs: 0, revision: snapshot.revision });
296
386
  }
387
+ const checkpoint = checkpointWithCatchUpMarker(snapshot.checkpoint, key, state.markerMs, now, operation);
388
+ try {
389
+ const receipt = await commit({
390
+ expectedRevision: snapshot.revision,
391
+ operationId: `catchUp:${snapshot.revision}`,
392
+ checkpoint,
393
+ });
394
+ return Object.freeze({ applied: false, elapsedMs: 0, revision: receipt.revision });
395
+ }
396
+ catch (error) {
397
+ if (attempt !== 0 || !isWorldRevisionConflict(error))
398
+ throw error;
399
+ snapshot = await read();
400
+ continue;
401
+ }
402
+ }
403
+ const returned = await options.apply(Object.freeze({
404
+ elapsedMs: state.elapsedMs,
405
+ now,
406
+ checkpoint: snapshot.checkpoint,
407
+ }));
408
+ const normalized = assertWorldCheckpoint(returned, "commit");
409
+ if (!isRecord(normalized.value)) {
410
+ throw worldJournalArgumentError("apply must return a checkpoint whose value is an object.", operation);
411
+ }
412
+ const checkpoint = checkpointWithCatchUpMarker(normalized, key, state.markerMs, now, operation);
413
+ try {
414
+ const receipt = await commit({
415
+ expectedRevision: snapshot.revision,
416
+ operationId: `catchUp:${snapshot.revision}`,
417
+ checkpoint,
418
+ });
297
419
  return Object.freeze({
298
- revision: assertWorldRevision(result.revision),
299
- replayed: result.replayed,
300
- operationId,
420
+ applied: !receipt.replayed,
421
+ elapsedMs: state.elapsedMs,
422
+ revision: receipt.revision,
301
423
  });
302
- },
424
+ }
425
+ catch (error) {
426
+ if (attempt !== 0 || !isWorldRevisionConflict(error))
427
+ throw error;
428
+ snapshot = await read();
429
+ }
430
+ }
431
+ throw new TypeError(`${operation}: unreachable catch-up state.`);
432
+ }
433
+ function assertWorldCatchUpKey(value, operation) {
434
+ if (typeof value !== "string") {
435
+ throw worldJournalArgumentError("key must be a string.", operation);
436
+ }
437
+ return value;
438
+ }
439
+ function worldCatchUpState(checkpoint, key, now, maxElapsedMs, operation) {
440
+ if (!checkpoint ||
441
+ !isRecord(checkpoint.value) ||
442
+ !Object.prototype.hasOwnProperty.call(checkpoint.value, key)) {
443
+ return { markerExists: false, markerMs: now, elapsedMs: 0 };
444
+ }
445
+ const advancedThroughMs = checkpoint.value[key];
446
+ if (!Number.isSafeInteger(advancedThroughMs) || advancedThroughMs < 0) {
447
+ throw worldJournalArgumentError(`checkpoint.value.${key} must be a non-negative integer timestamp in milliseconds.`, operation);
448
+ }
449
+ const markerMs = advancedThroughMs;
450
+ return {
451
+ markerExists: true,
452
+ markerMs,
453
+ elapsedMs: Math.min(maxElapsedMs, Math.max(0, now - markerMs)),
454
+ };
455
+ }
456
+ function checkpointWithCatchUpMarker(checkpoint, key, existingMarkerMs, now, operation) {
457
+ if (!isRecord(checkpoint.value)) {
458
+ throw worldJournalArgumentError("apply must return a checkpoint whose value is an object.", operation);
459
+ }
460
+ return Object.freeze({
461
+ version: checkpoint.version,
462
+ value: Object.freeze({
463
+ ...checkpoint.value,
464
+ [key]: Math.max(existingMarkerMs, now),
465
+ }),
303
466
  });
304
467
  }
468
+ function isWorldRevisionConflict(error) {
469
+ return isRecord(error) && error.code === "CAULDRON_WORLD_REVISION_CONFLICT";
470
+ }
305
471
  function createServerWorldJournal(platformHandle) {
306
472
  return Object.freeze({
307
473
  async append(input) {
@@ -382,6 +548,167 @@ function createServerWorldParticipants(platformHandle) {
382
548
  },
383
549
  });
384
550
  }
551
+ function createServerWorldNotifications(platformHandle) {
552
+ return Object.freeze({
553
+ async notify(input) {
554
+ const operation = "session.world.notifications.notify";
555
+ assertOnlyKeys(input, ["key", "participantIds", "templateId", "slots", "at", "expiresAt"], operation, "input");
556
+ const now = Date.now();
557
+ const key = assertWorldNotificationSafeKey(input.key, "key", operation);
558
+ const participantIds = normalizeParticipantIds(input.participantIds, WORLD_NOTIFICATIONS_MAX_PARTICIPANTS, "participantIds", operation);
559
+ const templateId = assertWorldNotificationSafeKey(input.templateId, "templateId", operation);
560
+ const slots = input.slots === undefined
561
+ ? undefined
562
+ : normalizeWorldNotificationSlots(input.slots, operation);
563
+ const at = input.at === undefined
564
+ ? undefined
565
+ : assertWorldNotificationTime(input.at, "at", operation);
566
+ const atMs = at?.milliseconds ?? now;
567
+ if (atMs < now - WORLD_NOTIFICATIONS_PAST_TOLERANCE_MS ||
568
+ atMs > now + WORLD_NOTIFICATIONS_MAX_SCHEDULE_MS) {
569
+ throw worldJournalArgumentError("at must be no more than 60 seconds in the past and at most 30 days ahead.", operation);
570
+ }
571
+ // Ghost clamps a tolerated past `at` to its own now, so expiry is judged
572
+ // against that effective time, not the original timestamp.
573
+ const effectiveAtMs = Math.max(atMs, now);
574
+ const expiresAt = input.expiresAt === undefined
575
+ ? undefined
576
+ : assertWorldNotificationTime(input.expiresAt, "expiresAt", operation);
577
+ if (expiresAt &&
578
+ (expiresAt.milliseconds <= effectiveAtMs ||
579
+ expiresAt.milliseconds > effectiveAtMs + WORLD_NOTIFICATIONS_MAX_SCHEDULE_MS)) {
580
+ throw worldJournalArgumentError("expiresAt must be after at and at most 30 days after it.", operation);
581
+ }
582
+ const args = {
583
+ key,
584
+ participantIds,
585
+ templateId,
586
+ ...(slots === undefined ? {} : { slots }),
587
+ ...(at === undefined ? {} : { at: at.value }),
588
+ ...(expiresAt === undefined ? {} : { expiresAt: expiresAt.value }),
589
+ };
590
+ const result = parseWorldNotificationResult(await callWorldNotificationStorage(platformHandle, "worldNotify", args, operation), operation);
591
+ if (result.key !== key)
592
+ throw invalidWorldNotificationResponse(operation);
593
+ return Object.freeze({
594
+ key,
595
+ accepted: parseWorldNotificationCount(result.accepted, operation),
596
+ skipped: parseWorldNotificationCount(result.skipped, operation),
597
+ limited: parseWorldNotificationCount(result.limited, operation),
598
+ });
599
+ },
600
+ async cancel(input) {
601
+ const operation = "session.world.notifications.cancel";
602
+ assertOnlyKeys(input, ["key", "participantIds"], operation, "input");
603
+ const key = assertWorldNotificationSafeKey(input.key, "key", operation);
604
+ const participantIds = input.participantIds === undefined
605
+ ? undefined
606
+ : normalizeParticipantIds(input.participantIds, WORLD_NOTIFICATIONS_MAX_PARTICIPANTS, "participantIds", operation);
607
+ const args = {
608
+ key,
609
+ ...(participantIds === undefined ? {} : { participantIds }),
610
+ };
611
+ const result = parseWorldNotificationResult(await callWorldNotificationStorage(platformHandle, "worldNotifyCancel", args, operation), operation);
612
+ return Object.freeze({
613
+ cancelled: parseWorldNotificationCount(result.cancelled, operation),
614
+ });
615
+ },
616
+ });
617
+ }
618
+ function assertWorldNotificationSafeKey(value, field, operation) {
619
+ if (typeof value !== "string" ||
620
+ worldNotificationCharacters(value) < 1 ||
621
+ worldNotificationCharacters(value) > 64 ||
622
+ !SAFE_WORLD_NOTIFICATION_KEY.test(value)) {
623
+ throw worldJournalArgumentError(`${field} must be a safe key of at most 64 characters.`, operation);
624
+ }
625
+ return value;
626
+ }
627
+ function normalizeWorldNotificationSlots(value, operation) {
628
+ if (!isRecord(value)) {
629
+ throw worldJournalArgumentError("slots must be an object.", operation);
630
+ }
631
+ const entries = Object.entries(value);
632
+ if (entries.length > WORLD_NOTIFICATIONS_MAX_SLOTS) {
633
+ throw worldJournalArgumentError(`slots may contain at most ${WORLD_NOTIFICATIONS_MAX_SLOTS} values.`, operation);
634
+ }
635
+ const normalized = {};
636
+ for (const [key, slotValue] of entries.sort(([left], [right]) => left.localeCompare(right))) {
637
+ assertWorldNotificationSafeKey(key, `slots.${key}`, operation);
638
+ if (typeof slotValue !== "string" ||
639
+ worldNotificationCharacters(slotValue) < 1 ||
640
+ worldNotificationCharacters(slotValue) > WORLD_NOTIFICATIONS_MAX_SLOT_CHARACTERS ||
641
+ slotValue.includes("\0") ||
642
+ hasUnpairedSurrogate(slotValue) ||
643
+ slotValue.includes("\n") ||
644
+ slotValue.includes("\r") ||
645
+ URL_IN_WORLD_NOTIFICATION_TEXT.test(slotValue)) {
646
+ throw worldJournalArgumentError(`slots.${key} must be 1 through ${WORLD_NOTIFICATIONS_MAX_SLOT_CHARACTERS} ` +
647
+ "characters of single-line text without a URL.", operation);
648
+ }
649
+ normalized[key] = slotValue;
650
+ }
651
+ return Object.freeze(normalized);
652
+ }
653
+ function worldNotificationCharacters(value) {
654
+ return Array.from(value).length;
655
+ }
656
+ function assertWorldNotificationTime(value, field, operation) {
657
+ if (typeof value !== "string") {
658
+ throw worldJournalArgumentError(`${field} must be a canonical ISO 8601 time with at most millisecond precision.`, operation);
659
+ }
660
+ const match = CANONICAL_WORLD_NOTIFICATION_TIME.exec(value);
661
+ const milliseconds = Date.parse(value);
662
+ if (!match || !Number.isFinite(milliseconds)) {
663
+ throw worldJournalArgumentError(`${field} must be a canonical ISO 8601 time with at most millisecond precision.`, operation);
664
+ }
665
+ const normalized = new Date(milliseconds).toISOString();
666
+ const normalizedFraction = normalized.slice(20, 23);
667
+ if (normalized.slice(0, 19) !== match[1] ||
668
+ (match[2] === undefined
669
+ ? normalizedFraction !== "000"
670
+ : normalizedFraction !== match[2].padEnd(3, "0"))) {
671
+ throw worldJournalArgumentError(`${field} must be a canonical ISO 8601 time with at most millisecond precision.`, operation);
672
+ }
673
+ return { value, milliseconds };
674
+ }
675
+ async function callWorldNotificationStorage(platformHandle, op, args, operation) {
676
+ if (typeof platformHandle.storage !== "function" || platformHandle.storageEnabled === false) {
677
+ throw worldNotificationsUnavailable(operation);
678
+ }
679
+ try {
680
+ return await platformHandle.storage(op, JSON.stringify(args));
681
+ }
682
+ catch (error) {
683
+ if (isUnknownStorageOperation(error))
684
+ throw worldNotificationsUnavailable(operation);
685
+ throw error;
686
+ }
687
+ }
688
+ function worldNotificationsUnavailable(operation) {
689
+ return storageError("CAULDRON_WORLD_NOTIFICATIONS_UNAVAILABLE", "This Castle host does not support persistent-world notifications.", operation);
690
+ }
691
+ function parseWorldNotificationResult(value, operation) {
692
+ let result;
693
+ try {
694
+ result = JSON.parse(value);
695
+ }
696
+ catch {
697
+ throw invalidWorldNotificationResponse(operation);
698
+ }
699
+ if (!isRecord(result))
700
+ throw invalidWorldNotificationResponse(operation);
701
+ return result;
702
+ }
703
+ function parseWorldNotificationCount(value, operation) {
704
+ if (!Number.isSafeInteger(value) || value < 0) {
705
+ throw invalidWorldNotificationResponse(operation);
706
+ }
707
+ return value;
708
+ }
709
+ function invalidWorldNotificationResponse(operation) {
710
+ return new TypeError(`${operation}: The platform returned an invalid response.`);
711
+ }
385
712
  function normalizeWorldJournalAppendEntry(value, index, operation) {
386
713
  const field = `entries[${index}]`;
387
714
  assertOnlyKeys(value, ["kind", "disclosure", "participantIds", "data"], operation, field);
@@ -973,13 +1300,51 @@ function requireBroadcastBinary(platformHandle) {
973
1300
  function unsupportedBinaryRuntime() {
974
1301
  throw new TypeError("This Castle runtime does not support binary multiplayer messages yet.");
975
1302
  }
1303
+ // The compatibility branch accepts the current Node shim's legacy
1304
+ // single-argument SessionPlayer event while the public handle contract uses the
1305
+ // runtime-agnostic (connId, trusted identity) pair. Either form may carry the hub
1306
+ // arrival after the player and the player's A/B arms after that, and a hub
1307
+ // player's block list on the player itself.
1308
+ function admitPlayer(state, connId, identity, handleArrival, handleVariants) {
1309
+ const player = joinedPlayer(connId, identity);
1310
+ const arrival = hubArrival(typeof connId === "string" ? handleArrival : identity);
1311
+ const known = experimentVariants(typeof connId === "string" ? handleVariants : handleArrival);
1312
+ state.players.set(player.playerId, player);
1313
+ if (known) {
1314
+ state.variants.set(player.playerId, known);
1315
+ }
1316
+ else {
1317
+ state.variants.delete(player.playerId);
1318
+ }
1319
+ recordHiddenUserIds(state.hidden, player.playerId, typeof connId === "string" ? identity : connId);
1320
+ if (arrival) {
1321
+ state.arrivals.set(player.playerId, arrival);
1322
+ }
1323
+ else {
1324
+ state.arrivals.delete(player.playerId);
1325
+ }
1326
+ return { player, arrival };
1327
+ }
1328
+ // Absent on a host that predates experiments, and for a player who is in none.
1329
+ function experimentVariants(value) {
1330
+ if (!value || typeof value !== "object" || Array.isArray(value))
1331
+ return null;
1332
+ const entries = Object.entries(value).filter((entry) => typeof entry[1] === "string");
1333
+ return entries.length > 0 ? Object.freeze(Object.fromEntries(entries)) : null;
1334
+ }
976
1335
  function joinedPlayer(connId, identity) {
977
1336
  if (typeof connId === "string") {
978
- return {
1337
+ const player = {
979
1338
  ...identity,
980
1339
  playerId: connId,
981
1340
  participantId: identity.scopedUserId ?? identity.userId,
982
1341
  };
1342
+ // A hub player's block list stays in the wrapper (`session.hub.hiddenPair`):
1343
+ // decks broadcast player objects.
1344
+ delete player.hiddenUserIds;
1345
+ // A/B arms arrive beside the player; one a host put on it stays off it too.
1346
+ delete player.experimentVariants;
1347
+ return player;
983
1348
  }
984
1349
  const legacy = connId;
985
1350
  return {
package/dist/store.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { StoreAllowance } from "./commands";
2
2
  import type { Json } from "./types";
3
+ import { type SaveApi, type SaveBucketApi } from "./save";
3
4
  export interface StoreListOptions {
4
5
  /** Only keys beginning with this. Omit for every key in the scope. */
5
6
  prefix?: string;
@@ -21,7 +22,7 @@ export interface StoreListPage<T extends Json = Json> {
21
22
  */
22
23
  cursor: string | null;
23
24
  }
24
- export interface StoreScopeApi {
25
+ export interface StoreScopeApi extends SaveBucketApi {
25
26
  /** Reads keys. Keys with no value are absent from the result. */
26
27
  get<T extends Json = Json>(keys: string[]): Promise<Record<string, T>>;
27
28
  /**
@@ -63,7 +64,7 @@ export interface StoreUsage {
63
64
  /** `Store.deck`: one allowance shared by every player of the deck. */
64
65
  shared: StoreAllowance;
65
66
  }
66
- export interface CastleStoreApi {
67
+ export interface CastleStoreApi extends SaveApi {
67
68
  /** Shared by everyone playing the deck. */
68
69
  readonly deck: StoreScopeApi;
69
70
  /** The current player's data, which other players can read. */
package/dist/store.js CHANGED
@@ -2,6 +2,7 @@ import { chunked } from "./chunk";
2
2
  import { decodeStorageValue, encodeStorageValue, storageError } from "./storageJson";
3
3
  import { hostRequest } from "./transport";
4
4
  import { onUnloadFlush } from "./unloadFlush";
5
+ import { createSaveApi, createSaveBucket, } from "./save";
5
6
  // Per-key deck storage for Cauldron decks.
6
7
  //
7
8
  // The older Storage/SharedStorage keep one document per bucket and rewrite the
@@ -212,6 +213,7 @@ function assertKeys(keys, operation) {
212
213
  }
213
214
  function createScope(scope, subject) {
214
215
  return {
216
+ ...createSaveBucket(saveApi, scope, subject ?? undefined),
215
217
  async get(keys) {
216
218
  const { entries } = await hostRequest("cauldronStorage.get", {
217
219
  scope,
@@ -309,7 +311,10 @@ async function usage() {
309
311
  const { usage: allowances } = await hostRequest("cauldronStorage.usage", {});
310
312
  return { player: allowances.player, shared: allowances.shared };
311
313
  }
314
+ // Saves skip the write coalescing above: each is one request, sent at once.
315
+ const saveApi = createSaveApi((verb, input) => hostRequest(`cauldronStorage.${verb}`, input));
312
316
  export const Store = {
317
+ ...saveApi,
313
318
  deck: createScope("deck", null),
314
319
  me: createScope("user", null),
315
320
  user(userId) {
@@ -1,4 +1,4 @@
1
- import { type CommandName, type CommandParams, type CommandResult, type LifecycleEvent, type VoiceEvent } from "./commands";
1
+ import { type CommandName, type CommandParams, type CommandResult, type HubEvent, type LifecycleEvent, type VoiceEvent } from "./commands";
2
2
  type PostChannel = "mobile" | "web";
3
3
  export interface HostStreamOptions {
4
4
  onStream?: (event: unknown) => void;
@@ -21,5 +21,6 @@ export declare function hostNotify(event: LifecycleEvent): void;
21
21
  export declare function postUnloadDone(unloadId: string, state?: string): void;
22
22
  export declare function setUnloadHandler(handler: (unloadId: string) => void): void;
23
23
  export declare function setVoiceEventHandler(handler: ((event: VoiceEvent) => void) | null): void;
24
+ export declare function setHubEventHandler(handler: ((event: HubEvent) => void) | null): void;
24
25
  export declare function installHostListener(): void;
25
26
  export {};
package/dist/transport.js CHANGED
@@ -11,7 +11,7 @@
11
11
  // A streaming command (agent.chat) gets stream envelopes on its requestId before
12
12
  // its response. Each one goes to the caller's onStream and restarts the wait,
13
13
  // and the caller's AbortSignal sends the host a cancel envelope.
14
- import { CASTLE_SDK_PROTOCOL, COMMAND_TIMEOUTS_MS, STREAM_IDLE_TIMEOUT_MS, isResponseEnvelope, isStreamEnvelope, unloadRequestId, voiceEventFrom, } from "./commands";
14
+ import { CASTLE_SDK_PROTOCOL, COMMAND_TIMEOUTS_MS, STREAM_IDLE_TIMEOUT_MS, isResponseEnvelope, isStreamEnvelope, hubEventFrom, unloadRequestId, voiceEventFrom, } from "./commands";
15
15
  import { getCastleEmbed } from "./context";
16
16
  import { CastleError } from "./errors";
17
17
  import { cancelLocalCommand, releaseLocalCommand, sendLocalCommand } from "./runtime";
@@ -35,6 +35,7 @@ const pending = new Map();
35
35
  let listenersInstalled = false;
36
36
  let unloadHandler = null;
37
37
  let voiceEventHandler = null;
38
+ let hubEventHandler = null;
38
39
  export async function hostRequest(command, params, stream = {}) {
39
40
  try {
40
41
  const env = await sendCommand(resolveChannel(), command, params, stream);
@@ -103,6 +104,12 @@ export function setVoiceEventHandler(handler) {
103
104
  if (handler)
104
105
  installHostListener();
105
106
  }
107
+ // Registered by hub.ts while the deck listens for the host's Back control.
108
+ export function setHubEventHandler(handler) {
109
+ hubEventHandler = handler;
110
+ if (handler)
111
+ installHostListener();
112
+ }
106
113
  function resolveChannel() {
107
114
  if (typeof window === "undefined")
108
115
  return "local";
@@ -233,6 +240,11 @@ function receive(message) {
233
240
  voiceEventHandler?.(voiceEvent);
234
241
  return;
235
242
  }
243
+ const hubEvent = hubEventFrom(message);
244
+ if (hubEvent) {
245
+ hubEventHandler?.(hubEvent);
246
+ return;
247
+ }
236
248
  const unloadId = unloadRequestId(message);
237
249
  if (unloadId !== null)
238
250
  unloadHandler?.(unloadId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-sdk",
3
- "version": "0.4.45",
3
+ "version": "0.4.46",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/castle-xyz/castle-experimental-web.git"