castle-web-sdk 0.4.45 → 0.4.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  }
@@ -62,18 +69,15 @@ async function bootSession(platformHandle, callbacks) {
62
69
  players: Object.freeze(request.audience.players.map((player) => Object.freeze(joinedPlayer(player.playerId, player)))),
63
70
  characterIds: Object.freeze([...request.audience.characterIds]),
64
71
  }),
65
- async send() {
72
+ async send(options) {
66
73
  if (settled)
67
74
  throw new TypeError("This shared character request is already settled.");
68
75
  settled = true;
69
- let rawReceipt;
70
- try {
71
- rawReceipt = await platformHandle.worldCharacterSend(request.grantId);
72
- }
73
- catch (error) {
76
+ const rawReceipt = await sendWorldCharacterTurn(platformHandle, request.grantId, options)
77
+ .catch((error) => {
74
78
  brokerFailure.rejection = { error };
75
79
  throw error;
76
- }
80
+ });
77
81
  return parseWorldCharacterAdmission(rawReceipt, request.operationId);
78
82
  },
79
83
  reject(code) {
@@ -96,6 +100,7 @@ async function bootSession(platformHandle, callbacks) {
96
100
  }
97
101
  });
98
102
  }
103
+ listenForWorldCharacterTools(platformHandle, callbacks, session);
99
104
  if (callbacks.onWorldCharacterCommand &&
100
105
  hasWorldCharacterPlatform(platformHandle, "worldCharacterCommand")) {
101
106
  platformHandle.on("worldCharacterCommand", async (raw) => {
@@ -103,6 +108,7 @@ async function bootSession(platformHandle, callbacks) {
103
108
  let settled = false;
104
109
  const brokerFailure = { rejection: null };
105
110
  const command = Object.freeze({
111
+ type: request.command.type,
106
112
  requestId: request.requestId,
107
113
  operationId: request.operationId,
108
114
  speaker: Object.freeze(joinedPlayer(request.speaker.playerId, request.speaker)),
@@ -202,7 +208,22 @@ async function bootSession(platformHandle, callbacks) {
202
208
  }, 1_000 / platformHandle.config.tickRate);
203
209
  }
204
210
  }
205
- function createAuthorSession(platformHandle, players) {
211
+ function createAuthorSession(platformHandle, players, arrivals, hidden, variants) {
212
+ // Lenient about what it is handed: an unknown player is in no experiment.
213
+ const variantsOf = (player) => variants.get(typeof player === "string"
214
+ ? player
215
+ : (player?.playerId ?? ""));
216
+ // The pinned place counts only where the host services the hub ops.
217
+ const hub = createServerHub(platformHandle, arrivals, (a, b) => {
218
+ if (hidden.size === 0 || a === b)
219
+ return false;
220
+ const userA = players.get(a)?.userId;
221
+ const userB = players.get(b)?.userId;
222
+ if (!userA || !userB)
223
+ return false;
224
+ return Boolean(hidden.get(a)?.has(userB) || hidden.get(b)?.has(userA));
225
+ });
226
+ const world = createServerWorld(platformHandle);
206
227
  return {
207
228
  get players() {
208
229
  return Array.from(players.values());
@@ -210,8 +231,20 @@ function createAuthorSession(platformHandle, players) {
210
231
  sessionId: platformHandle.config.sessionId,
211
232
  deckId: platformHandle.config.deckId,
212
233
  mode: platformHandle.config.mode,
234
+ place: typeof platformHandle.config.place === "string"
235
+ ? platformHandle.config.place
236
+ : hub?.place ?? null,
213
237
  storage: createServerStorage(platformHandle),
214
- world: createServerWorld(platformHandle),
238
+ world,
239
+ characters: world?.characters ?? createPlaceCharacters(platformHandle),
240
+ hub,
241
+ experiment(player, name) {
242
+ const known = variantsOf(player);
243
+ return known && Object.hasOwn(known, name) ? known[name] : null;
244
+ },
245
+ experiments(player) {
246
+ return { ...variantsOf(player) };
247
+ },
215
248
  send(playerId, data) {
216
249
  if (isBinaryPayload(data)) {
217
250
  requireSendBinary(platformHandle);
@@ -264,6 +297,24 @@ const WORLD_JOURNAL_MAX_READ_LIMIT = 128;
264
297
  const WORLD_PARTICIPANTS_MAX_READ_LIMIT = 256;
265
298
  const WORLD_JOURNAL_MAX_SEQUENCE = "9223372036854775807";
266
299
  const SAFE_WORLD_JOURNAL_KIND = /^[A-Za-z0-9_.:-]+$/;
300
+ const WORLD_NOTIFICATIONS_MAX_PARTICIPANTS = 16;
301
+ const WORLD_NOTIFICATIONS_MAX_SLOTS = 4;
302
+ const WORLD_NOTIFICATIONS_MAX_SLOT_CHARACTERS = 40;
303
+ const WORLD_NOTIFICATIONS_MAX_SCHEDULE_MS = 30 * 24 * 60 * 60 * 1_000;
304
+ const WORLD_NOTIFICATIONS_PAST_TOLERANCE_MS = 60 * 1_000;
305
+ const WORLD_CATCH_UP_DEFAULT_MAX_ELAPSED_MS = 7 * 24 * 60 * 60 * 1_000;
306
+ const SAFE_WORLD_NOTIFICATION_KEY = /^[A-Za-z0-9_-]+$/;
307
+ const CANONICAL_WORLD_NOTIFICATION_TIME = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,3}))?Z$/;
308
+ const URL_IN_WORLD_NOTIFICATION_TEXT = /(?:https?:\/\/|www\.|\b[a-z0-9-]+\.(?:app|com|dev|gg|io|net|org|xyz)\b)/i;
309
+ // A hub place's layer has no world of its own, but the host may still broker
310
+ // the place's shared characters into it (`placeCharacters` in the runtime
311
+ // init). The character API is the world one; only the binding differs, and the
312
+ // host stamps that.
313
+ function createPlaceCharacters(platformHandle) {
314
+ if (platformHandle.config.placeCharacters !== true)
315
+ return null;
316
+ return createServerWorldCharacters(platformHandle);
317
+ }
267
318
  function createServerWorld(platformHandle) {
268
319
  if (!platformHandle.config.world)
269
320
  return null;
@@ -272,36 +323,149 @@ function createServerWorld(platformHandle) {
272
323
  throw worldError("The persistent-world checkpoint broker is unavailable.", "read");
273
324
  }
274
325
  const initial = parseWorldSnapshot(platformHandle.config.world, "read");
326
+ const read = async () => {
327
+ const result = await platformHandle.worldCheckpointRead();
328
+ return parseWorldSnapshot(parsePlatformJson(result, "read"), "read");
329
+ };
330
+ const commit = async (input) => {
331
+ const expectedRevision = assertWorldRevision(input?.expectedRevision);
332
+ const operationId = assertWorldOperationId(input?.operationId);
333
+ const checkpoint = assertWorldCheckpoint(input?.checkpoint);
334
+ const raw = await platformHandle.worldCheckpointCommit(JSON.stringify({
335
+ expectedRevision,
336
+ operationId,
337
+ checkpoint,
338
+ }));
339
+ const result = parsePlatformJson(raw, "commit");
340
+ if (typeof result.replayed !== "boolean") {
341
+ throw worldError("The platform returned an invalid commit receipt.", "commit");
342
+ }
343
+ return Object.freeze({
344
+ revision: assertWorldRevision(result.revision),
345
+ replayed: result.replayed,
346
+ operationId,
347
+ });
348
+ };
275
349
  return Object.freeze({
276
350
  snapshot: initial,
277
351
  journal: createServerWorldJournal(platformHandle),
278
352
  participants: createServerWorldParticipants(platformHandle),
353
+ notifications: createServerWorldNotifications(platformHandle),
279
354
  characters: createServerWorldCharacters(platformHandle),
280
- async read() {
281
- const result = await platformHandle.worldCheckpointRead();
282
- return parseWorldSnapshot(parsePlatformJson(result, "read"), "read");
355
+ read,
356
+ commit,
357
+ async catchUp(options) {
358
+ return catchUpWorld(platformHandle.config.world?.worldTime, read, commit, options);
283
359
  },
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");
360
+ });
361
+ }
362
+ async function catchUpWorld(worldTime, read, commit, options) {
363
+ const operation = "session.world.catchUp";
364
+ if (worldTime?.advance !== "wallClock") {
365
+ throw worldJournalArgumentError('The destination must opt in with worldTime.advance set to "wallClock".', operation);
366
+ }
367
+ assertOnlyKeys(options, ["key", "maxElapsedMs", "apply"], operation, "options");
368
+ const key = options.key === undefined
369
+ ? "advancedThroughMs"
370
+ : assertWorldCatchUpKey(options.key, operation);
371
+ const maxElapsedMs = options.maxElapsedMs === undefined
372
+ ? WORLD_CATCH_UP_DEFAULT_MAX_ELAPSED_MS
373
+ : assertBoundedInteger(options.maxElapsedMs, 0, Number.MAX_SAFE_INTEGER, "maxElapsedMs", operation);
374
+ if (typeof options.apply !== "function") {
375
+ throw worldJournalArgumentError("apply must be a function.", operation);
376
+ }
377
+ const now = Date.now();
378
+ let snapshot = await read();
379
+ for (let attempt = 0; attempt < 2; attempt += 1) {
380
+ const state = worldCatchUpState(snapshot.checkpoint, key, now, maxElapsedMs, operation);
381
+ if (state.markerExists && state.elapsedMs === 0) {
382
+ if (state.markerMs >= now) {
383
+ return Object.freeze({ applied: false, elapsedMs: 0, revision: snapshot.revision });
384
+ }
385
+ const checkpoint = checkpointWithCatchUpMarker(snapshot.checkpoint, key, state.markerMs, now, operation);
386
+ try {
387
+ const receipt = await commit({
388
+ expectedRevision: snapshot.revision,
389
+ operationId: `catchUp:${snapshot.revision}`,
390
+ checkpoint,
391
+ });
392
+ return Object.freeze({ applied: false, elapsedMs: 0, revision: receipt.revision });
393
+ }
394
+ catch (error) {
395
+ if (attempt !== 0 || !isWorldRevisionConflict(error))
396
+ throw error;
397
+ snapshot = await read();
398
+ continue;
296
399
  }
400
+ }
401
+ const returned = await options.apply(Object.freeze({
402
+ elapsedMs: state.elapsedMs,
403
+ now,
404
+ checkpoint: snapshot.checkpoint,
405
+ }));
406
+ const normalized = assertWorldCheckpoint(returned, "commit");
407
+ if (!isRecord(normalized.value)) {
408
+ throw worldJournalArgumentError("apply must return a checkpoint whose value is an object.", operation);
409
+ }
410
+ const checkpoint = checkpointWithCatchUpMarker(normalized, key, state.markerMs, now, operation);
411
+ try {
412
+ const receipt = await commit({
413
+ expectedRevision: snapshot.revision,
414
+ operationId: `catchUp:${snapshot.revision}`,
415
+ checkpoint,
416
+ });
297
417
  return Object.freeze({
298
- revision: assertWorldRevision(result.revision),
299
- replayed: result.replayed,
300
- operationId,
418
+ applied: !receipt.replayed,
419
+ elapsedMs: state.elapsedMs,
420
+ revision: receipt.revision,
301
421
  });
302
- },
422
+ }
423
+ catch (error) {
424
+ if (attempt !== 0 || !isWorldRevisionConflict(error))
425
+ throw error;
426
+ snapshot = await read();
427
+ }
428
+ }
429
+ throw new TypeError(`${operation}: unreachable catch-up state.`);
430
+ }
431
+ function assertWorldCatchUpKey(value, operation) {
432
+ if (typeof value !== "string") {
433
+ throw worldJournalArgumentError("key must be a string.", operation);
434
+ }
435
+ return value;
436
+ }
437
+ function worldCatchUpState(checkpoint, key, now, maxElapsedMs, operation) {
438
+ if (!checkpoint ||
439
+ !isRecord(checkpoint.value) ||
440
+ !Object.prototype.hasOwnProperty.call(checkpoint.value, key)) {
441
+ return { markerExists: false, markerMs: now, elapsedMs: 0 };
442
+ }
443
+ const advancedThroughMs = checkpoint.value[key];
444
+ if (!Number.isSafeInteger(advancedThroughMs) || advancedThroughMs < 0) {
445
+ throw worldJournalArgumentError(`checkpoint.value.${key} must be a non-negative integer timestamp in milliseconds.`, operation);
446
+ }
447
+ const markerMs = advancedThroughMs;
448
+ return {
449
+ markerExists: true,
450
+ markerMs,
451
+ elapsedMs: Math.min(maxElapsedMs, Math.max(0, now - markerMs)),
452
+ };
453
+ }
454
+ function checkpointWithCatchUpMarker(checkpoint, key, existingMarkerMs, now, operation) {
455
+ if (!isRecord(checkpoint.value)) {
456
+ throw worldJournalArgumentError("apply must return a checkpoint whose value is an object.", operation);
457
+ }
458
+ return Object.freeze({
459
+ version: checkpoint.version,
460
+ value: Object.freeze({
461
+ ...checkpoint.value,
462
+ [key]: Math.max(existingMarkerMs, now),
463
+ }),
303
464
  });
304
465
  }
466
+ function isWorldRevisionConflict(error) {
467
+ return isRecord(error) && error.code === "CAULDRON_WORLD_REVISION_CONFLICT";
468
+ }
305
469
  function createServerWorldJournal(platformHandle) {
306
470
  return Object.freeze({
307
471
  async append(input) {
@@ -382,6 +546,167 @@ function createServerWorldParticipants(platformHandle) {
382
546
  },
383
547
  });
384
548
  }
549
+ function createServerWorldNotifications(platformHandle) {
550
+ return Object.freeze({
551
+ async notify(input) {
552
+ const operation = "session.world.notifications.notify";
553
+ assertOnlyKeys(input, ["key", "participantIds", "templateId", "slots", "at", "expiresAt"], operation, "input");
554
+ const now = Date.now();
555
+ const key = assertWorldNotificationSafeKey(input.key, "key", operation);
556
+ const participantIds = normalizeParticipantIds(input.participantIds, WORLD_NOTIFICATIONS_MAX_PARTICIPANTS, "participantIds", operation);
557
+ const templateId = assertWorldNotificationSafeKey(input.templateId, "templateId", operation);
558
+ const slots = input.slots === undefined
559
+ ? undefined
560
+ : normalizeWorldNotificationSlots(input.slots, operation);
561
+ const at = input.at === undefined
562
+ ? undefined
563
+ : assertWorldNotificationTime(input.at, "at", operation);
564
+ const atMs = at?.milliseconds ?? now;
565
+ if (atMs < now - WORLD_NOTIFICATIONS_PAST_TOLERANCE_MS ||
566
+ atMs > now + WORLD_NOTIFICATIONS_MAX_SCHEDULE_MS) {
567
+ throw worldJournalArgumentError("at must be no more than 60 seconds in the past and at most 30 days ahead.", operation);
568
+ }
569
+ // Ghost clamps a tolerated past `at` to its own now, so expiry is judged
570
+ // against that effective time, not the original timestamp.
571
+ const effectiveAtMs = Math.max(atMs, now);
572
+ const expiresAt = input.expiresAt === undefined
573
+ ? undefined
574
+ : assertWorldNotificationTime(input.expiresAt, "expiresAt", operation);
575
+ if (expiresAt &&
576
+ (expiresAt.milliseconds <= effectiveAtMs ||
577
+ expiresAt.milliseconds > effectiveAtMs + WORLD_NOTIFICATIONS_MAX_SCHEDULE_MS)) {
578
+ throw worldJournalArgumentError("expiresAt must be after at and at most 30 days after it.", operation);
579
+ }
580
+ const args = {
581
+ key,
582
+ participantIds,
583
+ templateId,
584
+ ...(slots === undefined ? {} : { slots }),
585
+ ...(at === undefined ? {} : { at: at.value }),
586
+ ...(expiresAt === undefined ? {} : { expiresAt: expiresAt.value }),
587
+ };
588
+ const result = parseWorldNotificationResult(await callWorldNotificationStorage(platformHandle, "worldNotify", args, operation), operation);
589
+ if (result.key !== key)
590
+ throw invalidWorldNotificationResponse(operation);
591
+ return Object.freeze({
592
+ key,
593
+ accepted: parseWorldNotificationCount(result.accepted, operation),
594
+ skipped: parseWorldNotificationCount(result.skipped, operation),
595
+ limited: parseWorldNotificationCount(result.limited, operation),
596
+ });
597
+ },
598
+ async cancel(input) {
599
+ const operation = "session.world.notifications.cancel";
600
+ assertOnlyKeys(input, ["key", "participantIds"], operation, "input");
601
+ const key = assertWorldNotificationSafeKey(input.key, "key", operation);
602
+ const participantIds = input.participantIds === undefined
603
+ ? undefined
604
+ : normalizeParticipantIds(input.participantIds, WORLD_NOTIFICATIONS_MAX_PARTICIPANTS, "participantIds", operation);
605
+ const args = {
606
+ key,
607
+ ...(participantIds === undefined ? {} : { participantIds }),
608
+ };
609
+ const result = parseWorldNotificationResult(await callWorldNotificationStorage(platformHandle, "worldNotifyCancel", args, operation), operation);
610
+ return Object.freeze({
611
+ cancelled: parseWorldNotificationCount(result.cancelled, operation),
612
+ });
613
+ },
614
+ });
615
+ }
616
+ function assertWorldNotificationSafeKey(value, field, operation) {
617
+ if (typeof value !== "string" ||
618
+ worldNotificationCharacters(value) < 1 ||
619
+ worldNotificationCharacters(value) > 64 ||
620
+ !SAFE_WORLD_NOTIFICATION_KEY.test(value)) {
621
+ throw worldJournalArgumentError(`${field} must be a safe key of at most 64 characters.`, operation);
622
+ }
623
+ return value;
624
+ }
625
+ function normalizeWorldNotificationSlots(value, operation) {
626
+ if (!isRecord(value)) {
627
+ throw worldJournalArgumentError("slots must be an object.", operation);
628
+ }
629
+ const entries = Object.entries(value);
630
+ if (entries.length > WORLD_NOTIFICATIONS_MAX_SLOTS) {
631
+ throw worldJournalArgumentError(`slots may contain at most ${WORLD_NOTIFICATIONS_MAX_SLOTS} values.`, operation);
632
+ }
633
+ const normalized = {};
634
+ for (const [key, slotValue] of entries.sort(([left], [right]) => left.localeCompare(right))) {
635
+ assertWorldNotificationSafeKey(key, `slots.${key}`, operation);
636
+ if (typeof slotValue !== "string" ||
637
+ worldNotificationCharacters(slotValue) < 1 ||
638
+ worldNotificationCharacters(slotValue) > WORLD_NOTIFICATIONS_MAX_SLOT_CHARACTERS ||
639
+ slotValue.includes("\0") ||
640
+ hasUnpairedSurrogate(slotValue) ||
641
+ slotValue.includes("\n") ||
642
+ slotValue.includes("\r") ||
643
+ URL_IN_WORLD_NOTIFICATION_TEXT.test(slotValue)) {
644
+ throw worldJournalArgumentError(`slots.${key} must be 1 through ${WORLD_NOTIFICATIONS_MAX_SLOT_CHARACTERS} ` +
645
+ "characters of single-line text without a URL.", operation);
646
+ }
647
+ normalized[key] = slotValue;
648
+ }
649
+ return Object.freeze(normalized);
650
+ }
651
+ function worldNotificationCharacters(value) {
652
+ return Array.from(value).length;
653
+ }
654
+ function assertWorldNotificationTime(value, field, operation) {
655
+ if (typeof value !== "string") {
656
+ throw worldJournalArgumentError(`${field} must be a canonical ISO 8601 time with at most millisecond precision.`, operation);
657
+ }
658
+ const match = CANONICAL_WORLD_NOTIFICATION_TIME.exec(value);
659
+ const milliseconds = Date.parse(value);
660
+ if (!match || !Number.isFinite(milliseconds)) {
661
+ throw worldJournalArgumentError(`${field} must be a canonical ISO 8601 time with at most millisecond precision.`, operation);
662
+ }
663
+ const normalized = new Date(milliseconds).toISOString();
664
+ const normalizedFraction = normalized.slice(20, 23);
665
+ if (normalized.slice(0, 19) !== match[1] ||
666
+ (match[2] === undefined
667
+ ? normalizedFraction !== "000"
668
+ : normalizedFraction !== match[2].padEnd(3, "0"))) {
669
+ throw worldJournalArgumentError(`${field} must be a canonical ISO 8601 time with at most millisecond precision.`, operation);
670
+ }
671
+ return { value, milliseconds };
672
+ }
673
+ async function callWorldNotificationStorage(platformHandle, op, args, operation) {
674
+ if (typeof platformHandle.storage !== "function" || platformHandle.storageEnabled === false) {
675
+ throw worldNotificationsUnavailable(operation);
676
+ }
677
+ try {
678
+ return await platformHandle.storage(op, JSON.stringify(args));
679
+ }
680
+ catch (error) {
681
+ if (isUnknownStorageOperation(error))
682
+ throw worldNotificationsUnavailable(operation);
683
+ throw error;
684
+ }
685
+ }
686
+ function worldNotificationsUnavailable(operation) {
687
+ return storageError("CAULDRON_WORLD_NOTIFICATIONS_UNAVAILABLE", "This Castle host does not support persistent-world notifications.", operation);
688
+ }
689
+ function parseWorldNotificationResult(value, operation) {
690
+ let result;
691
+ try {
692
+ result = JSON.parse(value);
693
+ }
694
+ catch {
695
+ throw invalidWorldNotificationResponse(operation);
696
+ }
697
+ if (!isRecord(result))
698
+ throw invalidWorldNotificationResponse(operation);
699
+ return result;
700
+ }
701
+ function parseWorldNotificationCount(value, operation) {
702
+ if (!Number.isSafeInteger(value) || value < 0) {
703
+ throw invalidWorldNotificationResponse(operation);
704
+ }
705
+ return value;
706
+ }
707
+ function invalidWorldNotificationResponse(operation) {
708
+ return new TypeError(`${operation}: The platform returned an invalid response.`);
709
+ }
385
710
  function normalizeWorldJournalAppendEntry(value, index, operation) {
386
711
  const field = `entries[${index}]`;
387
712
  assertOnlyKeys(value, ["kind", "disclosure", "participantIds", "data"], operation, field);
@@ -754,12 +1079,13 @@ function assertRuntimeCharacterCapabilities(value) {
754
1079
  }
755
1080
  return Object.freeze({ actionIds: Object.freeze([...normalized].sort()) });
756
1081
  }
1082
+ const LONE_SURROGATE = /[\uD800-\uDFFF]/u;
757
1083
  function assertRuntimeCharacterText(value, field, maxCharacters, maxBytes) {
758
1084
  if (typeof value !== "string" ||
759
1085
  value.length === 0 ||
760
1086
  value.length > maxCharacters ||
761
1087
  value.includes("\0") ||
762
- /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(value) ||
1088
+ LONE_SURROGATE.test(value) ||
763
1089
  new TextEncoder().encode(value).byteLength > maxBytes) {
764
1090
  throw new TypeError(`session.world.characters.create: ${field} must be valid non-empty text of at most ${maxCharacters} characters.`);
765
1091
  }
@@ -863,6 +1189,82 @@ function assertPlatformWorldCharacterRead(value) {
863
1189
  }
864
1190
  return value;
865
1191
  }
1192
+ // Tools ride only to a host that can answer them (an older host refuses a send
1193
+ // carrying anything beyond the grant); otherwise this is exactly the tool-less send.
1194
+ function sendWorldCharacterTurn(platformHandle, grantId, options) {
1195
+ const tools = options?.tools;
1196
+ return Array.isArray(tools) && tools.length > 0 &&
1197
+ typeof platformHandle.worldCharacterToolResult === "function"
1198
+ ? platformHandle.worldCharacterSend(grantId, tools)
1199
+ : platformHandle.worldCharacterSend(grantId);
1200
+ }
1201
+ // Only a handler on a host that answers place tools listens; without one the
1202
+ // host times the lookup out and the character answers without it.
1203
+ function listenForWorldCharacterTools(platformHandle, callbacks, session) {
1204
+ const handler = callbacks.onWorldCharacterTool;
1205
+ const answer = platformHandle.worldCharacterToolResult?.bind(platformHandle);
1206
+ if (!handler || !answer)
1207
+ return;
1208
+ try {
1209
+ platformHandle.on("worldCharacterTool", async (raw) => {
1210
+ const call = platformWorldCharacterTool(raw);
1211
+ if (!call)
1212
+ return;
1213
+ const result = await worldCharacterToolResult(handler, call.request, session);
1214
+ try {
1215
+ await answer(call.requestId, result);
1216
+ }
1217
+ catch {
1218
+ // The host has stopped waiting (or refused); the character answers without it.
1219
+ }
1220
+ });
1221
+ }
1222
+ catch {
1223
+ // Older shims may reject event names they do not recognize.
1224
+ }
1225
+ }
1226
+ const MAX_WORLD_CHARACTER_TOOL_RESULT_BYTES = 1024;
1227
+ const WORLD_CHARACTER_TOOL_TOO_LARGE = '{"error":"result too large"}';
1228
+ const WORLD_CHARACTER_TOOL_FAILED = '{"error":"tool failed"}';
1229
+ function platformWorldCharacterTool(value) {
1230
+ if (!value ||
1231
+ typeof value !== "object" ||
1232
+ typeof value.requestId !== "string" ||
1233
+ typeof value.operationId !== "string" ||
1234
+ typeof value.characterId !== "string" ||
1235
+ typeof value.playerId !== "string" ||
1236
+ typeof value.name !== "string" ||
1237
+ !value.input ||
1238
+ typeof value.input !== "object" ||
1239
+ Array.isArray(value.input)) {
1240
+ return null;
1241
+ }
1242
+ return {
1243
+ requestId: value.requestId,
1244
+ request: Object.freeze({
1245
+ operationId: value.operationId,
1246
+ characterId: value.characterId,
1247
+ playerId: value.playerId,
1248
+ name: value.name,
1249
+ input: deepFreezeJsonObject(value.input),
1250
+ }),
1251
+ };
1252
+ }
1253
+ async function worldCharacterToolResult(handler, request, session) {
1254
+ let result;
1255
+ try {
1256
+ const value = await handler(request, session);
1257
+ result = typeof value === "string" ? value : JSON.stringify(value);
1258
+ }
1259
+ catch {
1260
+ return WORLD_CHARACTER_TOOL_FAILED;
1261
+ }
1262
+ if (typeof result !== "string")
1263
+ return WORLD_CHARACTER_TOOL_FAILED;
1264
+ return new TextEncoder().encode(result).byteLength > MAX_WORLD_CHARACTER_TOOL_RESULT_BYTES
1265
+ ? WORLD_CHARACTER_TOOL_TOO_LARGE
1266
+ : result;
1267
+ }
866
1268
  function hasWorldCharacterPlatform(platformHandle, operation) {
867
1269
  return (typeof platformHandle[operation] === "function" &&
868
1270
  typeof platformHandle.worldCharacterReject === "function");
@@ -973,13 +1375,51 @@ function requireBroadcastBinary(platformHandle) {
973
1375
  function unsupportedBinaryRuntime() {
974
1376
  throw new TypeError("This Castle runtime does not support binary multiplayer messages yet.");
975
1377
  }
1378
+ // The compatibility branch accepts the current Node shim's legacy
1379
+ // single-argument SessionPlayer event while the public handle contract uses the
1380
+ // runtime-agnostic (connId, trusted identity) pair. Either form may carry the hub
1381
+ // arrival after the player and the player's A/B arms after that, and a hub
1382
+ // player's block list on the player itself.
1383
+ function admitPlayer(state, connId, identity, handleArrival, handleVariants) {
1384
+ const player = joinedPlayer(connId, identity);
1385
+ const arrival = hubArrival(typeof connId === "string" ? handleArrival : identity);
1386
+ const known = experimentVariants(typeof connId === "string" ? handleVariants : handleArrival);
1387
+ state.players.set(player.playerId, player);
1388
+ if (known) {
1389
+ state.variants.set(player.playerId, known);
1390
+ }
1391
+ else {
1392
+ state.variants.delete(player.playerId);
1393
+ }
1394
+ recordHiddenUserIds(state.hidden, player.playerId, typeof connId === "string" ? identity : connId);
1395
+ if (arrival) {
1396
+ state.arrivals.set(player.playerId, arrival);
1397
+ }
1398
+ else {
1399
+ state.arrivals.delete(player.playerId);
1400
+ }
1401
+ return { player, arrival };
1402
+ }
1403
+ // Absent on a host that predates experiments, and for a player who is in none.
1404
+ function experimentVariants(value) {
1405
+ if (!value || typeof value !== "object" || Array.isArray(value))
1406
+ return null;
1407
+ const entries = Object.entries(value).filter((entry) => typeof entry[1] === "string");
1408
+ return entries.length > 0 ? Object.freeze(Object.fromEntries(entries)) : null;
1409
+ }
976
1410
  function joinedPlayer(connId, identity) {
977
1411
  if (typeof connId === "string") {
978
- return {
1412
+ const player = {
979
1413
  ...identity,
980
1414
  playerId: connId,
981
1415
  participantId: identity.scopedUserId ?? identity.userId,
982
1416
  };
1417
+ // A hub player's block list stays in the wrapper (`session.hub.hiddenPair`):
1418
+ // decks broadcast player objects.
1419
+ delete player.hiddenUserIds;
1420
+ // A/B arms arrive beside the player; one a host put on it stays off it too.
1421
+ delete player.experimentVariants;
1422
+ return player;
983
1423
  }
984
1424
  const legacy = connId;
985
1425
  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. */