castle-web-sdk 0.4.44 → 0.4.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -151,6 +151,44 @@ Two differences follow from a session server not being a player:
151
151
  stop. An out-of-memory kill or a host failing sends no shutdown at all, so
152
152
  save as things change and treat `onShutdown` as the last chance, not the plan.
153
153
 
154
+ ### Persistent worlds
155
+
156
+ An opted-in persistent-world route has `session.world`; other sessions have
157
+ `session.world === null`. Alongside its checkpoint, the world keeps a journal
158
+ of its latest 512 entries and a last-seen row for each participant:
159
+
160
+ ```js
161
+ await session.world.journal.append({
162
+ operationId: "market-opened-day-12",
163
+ entries: [{
164
+ kind: "market.opened",
165
+ disclosure: "world",
166
+ data: { day: 12 },
167
+ }],
168
+ });
169
+
170
+ const page = await session.world.journal.read({
171
+ afterSeq: savedCursor,
172
+ participantId: player.participantId,
173
+ });
174
+ const { participants } = await session.world.participants.read({
175
+ participantIds: [player.participantId],
176
+ });
177
+ ```
178
+
179
+ `journal.append` accepts up to 16 entries per operation; each optional `data`
180
+ value must be JSON no larger than 2 KiB. Save `nextSeq` from `journal.read` as
181
+ the next cursor, and compare it with `oldestRetainedSeq` to detect a cursor that
182
+ fell behind retention. Entries disclosed to `participants` name participant
183
+ ids for the trusted deck server to project to those players; never broadcast
184
+ those entries to the whole session.
185
+
186
+ Every `MultiplayerServerPlayer` has a stable `participantId` for these calls
187
+ (`scopedUserId` when available, otherwise `userId`). The host records join,
188
+ leave, and shutdown presence automatically, and `participants.read` reflects
189
+ those host writes. The SDK never writes participant presence, and there is no
190
+ public touch method.
191
+
154
192
  ## Store
155
193
 
156
194
  `Store` saves data one key at a time. It is the storage to use for
package/dist/castle.d.ts CHANGED
@@ -14,7 +14,7 @@ export type { LeaderboardData, LeaderboardEntry, LeaderboardOptions, Leaderboard
14
14
  export { Lifecycle } from "./lifecycle";
15
15
  export type { CastleLifecycleApi } from "./lifecycle";
16
16
  export { Multiplayer } from "./multiplayer";
17
- export type { CastleMultiplayerApi, MultiplayerConnection, MultiplayerConnectionState, MultiplayerDestinationJoinResult, MultiplayerDestinationOptions, MultiplayerJoinOptions, MultiplayerRoster, MultiplayerStateMetadata, } from "./multiplayer";
17
+ export type { CastleMultiplayerApi, MultiplayerConnection, MultiplayerConnectionState, MultiplayerDestinationJoinResult, MultiplayerDestinationOptions, MultiplayerJoinHint, MultiplayerJoinOptions, MultiplayerRoster, MultiplayerStateMetadata, } from "./multiplayer";
18
18
  export type { MultiplayerVoice, VoiceJoinStatus, VoiceListener, VoiceMixEntry, VoiceSpatialOptions, VoiceState, VoiceVector, } from "./multiplayerVoice";
19
19
  export type { MultiplayerDestinationAudience, MultiplayerSoloReason } from "./commands";
20
20
  export { MAX_CLIENT_FRAME_BYTES, MAX_PLAYERS_CAP } from "./multiplayerProtocol";
@@ -114,6 +114,10 @@ export interface MultiplayerGetSessionResult {
114
114
  /** Internal local-serve scope for restoring this Play panel after a reload. */
115
115
  devReconnectScope?: string;
116
116
  }
117
+ export interface MultiplayerJoinHint {
118
+ routeId: string;
119
+ slot: string;
120
+ }
117
121
  export interface MultiplayerJoinDestinationParams {
118
122
  routeId: string;
119
123
  slot: string;
@@ -352,6 +356,7 @@ export interface CommandParams {
352
356
  style: HapticStyle;
353
357
  };
354
358
  "multiplayer.getSession": MultiplayerGetSessionParams;
359
+ "multiplayer.getJoinHint": Record<string, never>;
355
360
  "multiplayer.joinDestination": MultiplayerJoinDestinationParams;
356
361
  "voice.getState": {
357
362
  sessionId: string;
@@ -492,6 +497,7 @@ export interface CommandResult {
492
497
  "portal.info": PortalInfoResult;
493
498
  "haptics.play": HapticsResult;
494
499
  "multiplayer.getSession": MultiplayerGetSessionResult;
500
+ "multiplayer.getJoinHint": MultiplayerJoinHint | null;
495
501
  "multiplayer.joinDestination": MultiplayerJoinDestinationResult;
496
502
  "voice.getState": {
497
503
  state: VoiceState;
@@ -1,8 +1,9 @@
1
- import type { MultiplayerDestinationAudience, MultiplayerSoloReason } from "./commands";
1
+ import type { MultiplayerDestinationAudience, MultiplayerJoinHint, MultiplayerSoloReason } from "./commands";
2
2
  import { CastleError } from "./errors";
3
3
  import type { Json } from "./types";
4
4
  import { type MultiplayerPlayer, type MultiplayerCharacterDisclosure, type MultiplayerCharacterTurnStatus } from "./multiplayerProtocol";
5
5
  import { type MultiplayerVoice } from "./multiplayerVoice";
6
+ export type { MultiplayerJoinHint } from "./commands";
6
7
  export type MultiplayerJoinOptions = {
7
8
  mode: "named";
8
9
  key: string;
@@ -153,6 +154,7 @@ export interface MultiplayerConnection {
153
154
  }
154
155
  export interface CastleMultiplayerApi {
155
156
  join(options: MultiplayerJoinOptions): Promise<MultiplayerConnection>;
157
+ joinHint(): Promise<MultiplayerJoinHint | null>;
156
158
  joinDestination(options: MultiplayerDestinationOptions): Promise<MultiplayerDestinationJoinResult>;
157
159
  }
158
160
  export declare const Multiplayer: CastleMultiplayerApi;
@@ -18,7 +18,17 @@ let nextCharacterStatusRequestId = 1;
18
18
  let activeConnection = null;
19
19
  let latestJoinAttempt = 0;
20
20
  let pendingDestinationJoin = null;
21
- export const Multiplayer = { join, joinDestination };
21
+ export const Multiplayer = { join, joinHint, joinDestination };
22
+ async function joinHint() {
23
+ try {
24
+ return await hostRequest("multiplayer.getJoinHint", {});
25
+ }
26
+ catch (error) {
27
+ if (error instanceof CastleError && error.code === "UNKNOWN_COMMAND")
28
+ return null;
29
+ throw error;
30
+ }
31
+ }
22
32
  async function join(options) {
23
33
  const params = joinParams(options);
24
34
  latestJoinAttempt += 1;
@@ -43,6 +43,8 @@ export interface PlatformHandle {
43
43
  *
44
44
  * Optional because a published bundle may run on a host that predates storage.
45
45
  * Rejects with an Error whose `code` is the platform's error code.
46
+ * Persistent-world journal and participant operations share this broker; the
47
+ * host stamps their world binding and participant identity.
46
48
  */
47
49
  storage?(op: string, argsJson: string): Promise<string>;
48
50
  /** False when the host cannot broker storage, so a call can fail at once. */
@@ -5,6 +5,7 @@ export type { MultiplayerSessionMode, PlatformHandle, PlatformSessionConfig, Pla
5
5
  export type { CharacterKnowledgeSource, CompleteCharacterBeatInput, DeliverCharacterKnowledgeInput, RecordCharacterEventInput, SetCharacterGameStateInput, ShareCharacterClaimInput, ServerStorage, ServerStorageBoard, ServerStorageBoardScore, ServerStorageListEntry, ServerStorageListOptions, ServerStorageListPage, ServerPlaythrough, ServerPlaythroughCharacters, ServerCharacterHistoryOptions, ServerStorageScope, } from "./storage";
6
6
  export interface MultiplayerServerPlayer extends PlayerIdentity {
7
7
  playerId: string;
8
+ readonly participantId: string;
8
9
  }
9
10
  export interface MultiplayerBroadcastOptions {
10
11
  except?: string;
@@ -52,8 +53,65 @@ export interface MultiplayerServerWorld {
52
53
  read(): Promise<MultiplayerWorldSnapshot>;
53
54
  /** Commits exactly against expectedRevision; conflicts never partially write. */
54
55
  commit(input: MultiplayerWorldCommitInput): Promise<MultiplayerWorldCommitReceipt>;
56
+ readonly journal: MultiplayerServerWorldJournal;
57
+ readonly participants: MultiplayerServerWorldParticipants;
55
58
  readonly characters: MultiplayerServerWorldCharacters;
56
59
  }
60
+ export interface MultiplayerWorldJournalAppendInput {
61
+ operationId: string;
62
+ entries: readonly MultiplayerWorldJournalAppendEntry[];
63
+ }
64
+ export interface MultiplayerWorldJournalAppendEntry {
65
+ kind: string;
66
+ disclosure: "world" | "participants";
67
+ participantIds?: readonly string[];
68
+ data?: Json;
69
+ }
70
+ export interface MultiplayerWorldJournalAppendReceipt {
71
+ operationId: string;
72
+ replayed: boolean;
73
+ firstSeq: string;
74
+ lastSeq: string;
75
+ }
76
+ export interface MultiplayerWorldJournalReadOptions {
77
+ afterSeq?: string;
78
+ limit?: number;
79
+ participantId?: string;
80
+ }
81
+ export interface MultiplayerWorldJournalEntry {
82
+ seq: string;
83
+ kind: string;
84
+ disclosure: "world" | "participants";
85
+ participantIds: readonly string[];
86
+ data: Json;
87
+ createdAt: string;
88
+ }
89
+ export interface MultiplayerWorldJournalReadResult {
90
+ entries: readonly MultiplayerWorldJournalEntry[];
91
+ nextSeq: string;
92
+ oldestRetainedSeq: string | null;
93
+ }
94
+ export interface MultiplayerServerWorldJournal {
95
+ append(input: MultiplayerWorldJournalAppendInput): Promise<MultiplayerWorldJournalAppendReceipt>;
96
+ read(options?: MultiplayerWorldJournalReadOptions): Promise<MultiplayerWorldJournalReadResult>;
97
+ }
98
+ export interface MultiplayerWorldParticipantsReadOptions {
99
+ participantIds?: readonly string[];
100
+ limit?: number;
101
+ }
102
+ export interface MultiplayerWorldParticipant {
103
+ participantId: string;
104
+ firstSeenAt: string;
105
+ lastSeenAt: string;
106
+ lastLeftAt: string | null;
107
+ visitCount: number;
108
+ }
109
+ export interface MultiplayerWorldParticipantsReadResult {
110
+ participants: readonly MultiplayerWorldParticipant[];
111
+ }
112
+ export interface MultiplayerServerWorldParticipants {
113
+ read(options?: MultiplayerWorldParticipantsReadOptions): Promise<MultiplayerWorldParticipantsReadResult>;
114
+ }
57
115
  export interface MultiplayerServerWorldCharacters {
58
116
  create(input: MultiplayerWorldRuntimeCharacterCreateInput): Promise<MultiplayerWorldRuntimeCharacterCreateReceipt>;
59
117
  list(): Promise<MultiplayerWorldCharacterList>;
@@ -1,5 +1,5 @@
1
1
  import { isBinaryPayload, toUint8Array, } from "../multiplayerProtocol";
2
- import { encodeStorageValue } from "../storageJson";
2
+ import { encodeStorageValue, storageError } from "../storageJson";
3
3
  import { createServerStorage } from "./storage";
4
4
  // The publish bundler binds the author's default callbacks export once and
5
5
  // exports the returned one-argument function as `__castleBootSession`.
@@ -23,8 +23,9 @@ async function bootSession(platformHandle, callbacks) {
23
23
  platformHandle.on("leave", async (connId) => {
24
24
  const player = players.get(connId);
25
25
  players.delete(connId);
26
- if (player)
26
+ if (player) {
27
27
  await callbacks.onPlayerLeave?.(session, player);
28
+ }
28
29
  });
29
30
  platformHandle.on("message", async (connId, text) => {
30
31
  const player = players.get(connId);
@@ -53,12 +54,12 @@ async function bootSession(platformHandle, callbacks) {
53
54
  const turn = Object.freeze({
54
55
  requestId: request.requestId,
55
56
  operationId: request.operationId,
56
- speaker: Object.freeze({ ...request.speaker }),
57
+ speaker: Object.freeze(joinedPlayer(request.speaker.playerId, request.speaker)),
57
58
  characterId: request.characterId,
58
59
  input: request.input,
59
60
  audience: Object.freeze({
60
61
  disclosure: request.audience.disclosure,
61
- players: Object.freeze(request.audience.players.map((player) => Object.freeze({ ...player }))),
62
+ players: Object.freeze(request.audience.players.map((player) => Object.freeze(joinedPlayer(player.playerId, player)))),
62
63
  characterIds: Object.freeze([...request.audience.characterIds]),
63
64
  }),
64
65
  async send() {
@@ -104,7 +105,7 @@ async function bootSession(platformHandle, callbacks) {
104
105
  const command = Object.freeze({
105
106
  requestId: request.requestId,
106
107
  operationId: request.operationId,
107
- speaker: Object.freeze({ ...request.speaker }),
108
+ speaker: Object.freeze(joinedPlayer(request.speaker.playerId, request.speaker)),
108
109
  command: deepFreezeJsonObject(request.command),
109
110
  async apply() {
110
111
  if (settled)
@@ -148,7 +149,7 @@ async function bootSession(platformHandle, callbacks) {
148
149
  const brokerFailure = { rejection: null };
149
150
  const read = Object.freeze({
150
151
  requestId: request.requestId,
151
- speaker: Object.freeze({ ...request.speaker }),
152
+ speaker: Object.freeze(joinedPlayer(request.speaker.playerId, request.speaker)),
152
153
  query: deepFreezeJsonObject(request.query),
153
154
  async read() {
154
155
  if (settled)
@@ -252,6 +253,17 @@ function createAuthorSession(platformHandle, players) {
252
253
  const WORLD_OPERATION_ID_MAX_BYTES = 128;
253
254
  const WORLD_OPERATION_ID_MAX_CHARACTERS = 120;
254
255
  const WORLD_CHECKPOINT_VERSION_MAX = 2_147_483_647;
256
+ const WORLD_JOURNAL_MAX_ENTRIES = 16;
257
+ const WORLD_JOURNAL_MAX_KIND_CHARACTERS = 64;
258
+ const WORLD_JOURNAL_MAX_PARTICIPANT_BYTES = 128;
259
+ const WORLD_JOURNAL_MAX_PARTICIPANTS_PER_ENTRY = 16;
260
+ const WORLD_JOURNAL_MAX_PARTICIPANT_FILTERS = 64;
261
+ const WORLD_JOURNAL_MAX_DATA_BYTES = 2_048;
262
+ const WORLD_JOURNAL_MAX_REQUEST_BYTES = 16 * 1_024;
263
+ const WORLD_JOURNAL_MAX_READ_LIMIT = 128;
264
+ const WORLD_PARTICIPANTS_MAX_READ_LIMIT = 256;
265
+ const WORLD_JOURNAL_MAX_SEQUENCE = "9223372036854775807";
266
+ const SAFE_WORLD_JOURNAL_KIND = /^[A-Za-z0-9_.:-]+$/;
255
267
  function createServerWorld(platformHandle) {
256
268
  if (!platformHandle.config.world)
257
269
  return null;
@@ -262,6 +274,8 @@ function createServerWorld(platformHandle) {
262
274
  const initial = parseWorldSnapshot(platformHandle.config.world, "read");
263
275
  return Object.freeze({
264
276
  snapshot: initial,
277
+ journal: createServerWorldJournal(platformHandle),
278
+ participants: createServerWorldParticipants(platformHandle),
265
279
  characters: createServerWorldCharacters(platformHandle),
266
280
  async read() {
267
281
  const result = await platformHandle.worldCheckpointRead();
@@ -288,6 +302,328 @@ function createServerWorld(platformHandle) {
288
302
  },
289
303
  });
290
304
  }
305
+ function createServerWorldJournal(platformHandle) {
306
+ return Object.freeze({
307
+ async append(input) {
308
+ const operation = "session.world.journal.append";
309
+ assertOnlyKeys(input, ["operationId", "entries"], operation, "input");
310
+ const operationId = assertWorldJournalOperationId(input?.operationId, operation);
311
+ if (!Array.isArray(input?.entries) ||
312
+ input.entries.length < 1 ||
313
+ input.entries.length > WORLD_JOURNAL_MAX_ENTRIES) {
314
+ throw worldJournalArgumentError(`entries must contain from 1 through ${WORLD_JOURNAL_MAX_ENTRIES} entries.`, operation);
315
+ }
316
+ const entries = input.entries.map((entry, index) => normalizeWorldJournalAppendEntry(entry, index, operation));
317
+ const args = { operationId, entries };
318
+ if (utf8Length(JSON.stringify(args)) > WORLD_JOURNAL_MAX_REQUEST_BYTES) {
319
+ throw worldJournalArgumentError(`The serialized request may be at most ${WORLD_JOURNAL_MAX_REQUEST_BYTES} bytes.`, operation);
320
+ }
321
+ const result = parseWorldJournalJson(await callWorldJournalStorage(platformHandle, "worldJournalAppend", args, operation), operation);
322
+ if (!isRecord(result) || result.operationId !== operationId ||
323
+ typeof result.replayed !== "boolean") {
324
+ throw invalidWorldJournalResponse(operation);
325
+ }
326
+ return Object.freeze({
327
+ operationId,
328
+ replayed: result.replayed,
329
+ firstSeq: parseWorldJournalSequence(result.firstSeq, operation, true),
330
+ lastSeq: parseWorldJournalSequence(result.lastSeq, operation, true),
331
+ });
332
+ },
333
+ async read(options = {}) {
334
+ const operation = "session.world.journal.read";
335
+ assertOnlyKeys(options, ["afterSeq", "limit", "participantId"], operation, "options");
336
+ const args = {};
337
+ if (options.afterSeq !== undefined) {
338
+ args.afterSeq = assertWorldJournalSequence(options.afterSeq, operation, "afterSeq");
339
+ }
340
+ if (options.limit !== undefined) {
341
+ args.limit = assertBoundedInteger(options.limit, 1, WORLD_JOURNAL_MAX_READ_LIMIT, "limit", operation);
342
+ }
343
+ if (options.participantId !== undefined) {
344
+ args.participantId = assertWorldParticipantId(options.participantId, "participantId", operation);
345
+ }
346
+ const result = parseWorldJournalJson(await callWorldJournalStorage(platformHandle, "worldJournalRead", args, operation), operation);
347
+ if (!isRecord(result) || !Array.isArray(result.entries) ||
348
+ result.entries.length > WORLD_JOURNAL_MAX_READ_LIMIT) {
349
+ throw invalidWorldJournalResponse(operation);
350
+ }
351
+ const entries = result.entries.map((entry) => parseWorldJournalEntry(entry, operation));
352
+ return Object.freeze({
353
+ entries: Object.freeze(entries),
354
+ nextSeq: parseWorldJournalSequence(result.nextSeq, operation),
355
+ oldestRetainedSeq: result.oldestRetainedSeq === null
356
+ ? null
357
+ : parseWorldJournalSequence(result.oldestRetainedSeq, operation, true),
358
+ });
359
+ },
360
+ });
361
+ }
362
+ function createServerWorldParticipants(platformHandle) {
363
+ return Object.freeze({
364
+ async read(options = {}) {
365
+ const operation = "session.world.participants.read";
366
+ assertOnlyKeys(options, ["participantIds", "limit"], operation, "options");
367
+ const args = {};
368
+ if (options.participantIds !== undefined) {
369
+ args.participantIds = normalizeParticipantIds(options.participantIds, WORLD_JOURNAL_MAX_PARTICIPANT_FILTERS, "participantIds", operation);
370
+ }
371
+ if (options.limit !== undefined) {
372
+ args.limit = assertBoundedInteger(options.limit, 1, WORLD_PARTICIPANTS_MAX_READ_LIMIT, "limit", operation);
373
+ }
374
+ const result = parseWorldJournalJson(await callWorldJournalStorage(platformHandle, "worldParticipantsRead", args, operation), operation);
375
+ if (!isRecord(result) || !Array.isArray(result.participants) ||
376
+ result.participants.length > WORLD_PARTICIPANTS_MAX_READ_LIMIT) {
377
+ throw invalidWorldJournalResponse(operation);
378
+ }
379
+ return Object.freeze({
380
+ participants: Object.freeze(result.participants.map((participant) => parseWorldParticipant(participant, operation))),
381
+ });
382
+ },
383
+ });
384
+ }
385
+ function normalizeWorldJournalAppendEntry(value, index, operation) {
386
+ const field = `entries[${index}]`;
387
+ assertOnlyKeys(value, ["kind", "disclosure", "participantIds", "data"], operation, field);
388
+ const entry = value;
389
+ if (typeof entry.kind !== "string" ||
390
+ entry.kind.length < 1 ||
391
+ entry.kind.length > WORLD_JOURNAL_MAX_KIND_CHARACTERS ||
392
+ !SAFE_WORLD_JOURNAL_KIND.test(entry.kind)) {
393
+ throw worldJournalArgumentError(`${field}.kind must be a safe key of at most ${WORLD_JOURNAL_MAX_KIND_CHARACTERS} characters.`, operation);
394
+ }
395
+ if (entry.disclosure !== "world" && entry.disclosure !== "participants") {
396
+ throw worldJournalArgumentError(`${field}.disclosure must be "world" or "participants".`, operation);
397
+ }
398
+ let participantIds;
399
+ if (entry.disclosure === "participants") {
400
+ participantIds = normalizeParticipantIds(entry.participantIds, WORLD_JOURNAL_MAX_PARTICIPANTS_PER_ENTRY, `${field}.participantIds`, operation);
401
+ }
402
+ else if (Object.prototype.hasOwnProperty.call(entry, "participantIds")) {
403
+ throw worldJournalArgumentError(`${field}.participantIds must be absent when disclosure is "world".`, operation);
404
+ }
405
+ let data;
406
+ if (Object.prototype.hasOwnProperty.call(entry, "data")) {
407
+ data = normalizeWorldJournalData(entry.data, field, operation);
408
+ }
409
+ return Object.freeze({
410
+ kind: entry.kind,
411
+ disclosure: entry.disclosure,
412
+ ...(participantIds === undefined ? {} : { participantIds }),
413
+ ...(data === undefined ? {} : { data }),
414
+ });
415
+ }
416
+ function normalizeWorldJournalData(value, field, operation) {
417
+ let encoded;
418
+ try {
419
+ encoded = encodeStorageValue(value, operation);
420
+ }
421
+ catch {
422
+ throw worldJournalArgumentError(`${field}.data must be JSON.`, operation);
423
+ }
424
+ const normalized = canonicalizeJson(JSON.parse(encoded));
425
+ assertWorldJournalDataText(normalized, `${field}.data`, operation);
426
+ if (utf8Length(JSON.stringify(normalized)) > WORLD_JOURNAL_MAX_DATA_BYTES) {
427
+ throw worldJournalArgumentError(`${field}.data may be at most ${WORLD_JOURNAL_MAX_DATA_BYTES} bytes when serialized.`, operation);
428
+ }
429
+ return normalized;
430
+ }
431
+ function assertWorldJournalDataText(value, field, operation) {
432
+ if (typeof value === "string") {
433
+ if (value.includes("\0") || hasUnpairedSurrogate(value)) {
434
+ throw worldJournalArgumentError(`${field} must contain valid text.`, operation);
435
+ }
436
+ return;
437
+ }
438
+ if (Array.isArray(value)) {
439
+ value.forEach((item, index) => assertWorldJournalDataText(item, `${field}[${index}]`, operation));
440
+ return;
441
+ }
442
+ if (value !== null && typeof value === "object") {
443
+ for (const [key, item] of Object.entries(value)) {
444
+ if (key.includes("\0") || hasUnpairedSurrogate(key)) {
445
+ throw worldJournalArgumentError(`${field} must contain valid text.`, operation);
446
+ }
447
+ assertWorldJournalDataText(item, `${field}.${key}`, operation);
448
+ }
449
+ }
450
+ }
451
+ function canonicalizeJson(value) {
452
+ if (Array.isArray(value))
453
+ return value.map(canonicalizeJson);
454
+ if (value !== null && typeof value === "object") {
455
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalizeJson(value[key])]));
456
+ }
457
+ return value;
458
+ }
459
+ function normalizeParticipantIds(value, max, field, operation) {
460
+ if (!Array.isArray(value) || value.length < 1 || value.length > max) {
461
+ throw worldJournalArgumentError(`${field} must contain from 1 through ${max} ids.`, operation);
462
+ }
463
+ const ids = [...new Set(value.map((id, index) => assertWorldParticipantId(id, `${field}[${index}]`, operation)))].sort();
464
+ if (ids.length > max) {
465
+ throw worldJournalArgumentError(`${field} must contain from 1 through ${max} ids.`, operation);
466
+ }
467
+ return Object.freeze(ids);
468
+ }
469
+ function assertWorldParticipantId(value, field, operation) {
470
+ if (typeof value !== "string" || value.length === 0 ||
471
+ value.includes("\0") || hasUnpairedSurrogate(value) ||
472
+ utf8Length(value) > WORLD_JOURNAL_MAX_PARTICIPANT_BYTES) {
473
+ throw worldJournalArgumentError(`${field} must be valid non-empty text of at most ${WORLD_JOURNAL_MAX_PARTICIPANT_BYTES} bytes.`, operation);
474
+ }
475
+ return value;
476
+ }
477
+ function assertWorldJournalOperationId(value, operation) {
478
+ if (typeof value !== "string" ||
479
+ value.length < 1 ||
480
+ value.length > WORLD_OPERATION_ID_MAX_CHARACTERS ||
481
+ value.includes("\0") ||
482
+ hasUnpairedSurrogate(value) ||
483
+ utf8Length(value) > WORLD_OPERATION_ID_MAX_BYTES) {
484
+ throw worldJournalArgumentError(`operationId must be valid text of at most ${WORLD_OPERATION_ID_MAX_CHARACTERS} characters and ${WORLD_OPERATION_ID_MAX_BYTES} bytes.`, operation);
485
+ }
486
+ return value;
487
+ }
488
+ function assertWorldJournalSequence(value, operation, field, positive = false) {
489
+ if (!isWorldJournalSequence(value, positive)) {
490
+ throw worldJournalArgumentError(`${field} must be a canonical decimal string no greater than ${WORLD_JOURNAL_MAX_SEQUENCE}.`, operation);
491
+ }
492
+ return value;
493
+ }
494
+ function assertBoundedInteger(value, min, max, field, operation) {
495
+ if (!Number.isSafeInteger(value) || value < min || value > max) {
496
+ throw worldJournalArgumentError(`${field} must be an integer from ${min} through ${max}.`, operation);
497
+ }
498
+ return value;
499
+ }
500
+ async function callWorldJournalStorage(platformHandle, op, args, operation) {
501
+ if (typeof platformHandle.storage !== "function" || platformHandle.storageEnabled === false) {
502
+ throw worldJournalUnavailable(operation);
503
+ }
504
+ try {
505
+ return await platformHandle.storage(op, JSON.stringify(args));
506
+ }
507
+ catch (error) {
508
+ if (isUnknownStorageOperation(error))
509
+ throw worldJournalUnavailable(operation);
510
+ throw error;
511
+ }
512
+ }
513
+ function isUnknownStorageOperation(error) {
514
+ if (!isRecord(error) ||
515
+ (error.code !== "CAULDRON_STORAGE_INVALID_ARGUMENT" &&
516
+ error.code !== "CAULDRON_STORAGE_BAD_REQUEST"))
517
+ return false;
518
+ const message = error.message;
519
+ return typeof message === "string" && /unknown (?:storage )?operation/i.test(message);
520
+ }
521
+ function worldJournalUnavailable(operation) {
522
+ return storageError("CAULDRON_WORLD_JOURNAL_UNAVAILABLE", "This Castle host does not support persistent-world journals.", operation);
523
+ }
524
+ function worldJournalArgumentError(message, operation) {
525
+ return storageError("INVALID_ARGUMENT", message, operation);
526
+ }
527
+ function parseWorldJournalJson(value, operation) {
528
+ try {
529
+ return JSON.parse(value);
530
+ }
531
+ catch {
532
+ throw invalidWorldJournalResponse(operation);
533
+ }
534
+ }
535
+ function invalidWorldJournalResponse(operation) {
536
+ return new TypeError(`${operation}: The platform returned an invalid response.`);
537
+ }
538
+ function parseWorldJournalEntry(value, operation) {
539
+ if (!isRecord(value) ||
540
+ (value.disclosure !== "world" && value.disclosure !== "participants") ||
541
+ !Array.isArray(value.participantIds) ||
542
+ typeof value.kind !== "string" ||
543
+ typeof value.createdAt !== "string") {
544
+ throw invalidWorldJournalResponse(operation);
545
+ }
546
+ const participantIds = value.participantIds.map((id) => {
547
+ if (typeof id !== "string")
548
+ throw invalidWorldJournalResponse(operation);
549
+ return id;
550
+ });
551
+ if ((value.disclosure === "world" && participantIds.length !== 0) ||
552
+ (value.disclosure === "participants" && participantIds.length < 1)) {
553
+ throw invalidWorldJournalResponse(operation);
554
+ }
555
+ let data;
556
+ try {
557
+ data = JSON.parse(encodeStorageValue(value.data, operation));
558
+ }
559
+ catch {
560
+ throw invalidWorldJournalResponse(operation);
561
+ }
562
+ return Object.freeze({
563
+ seq: parseWorldJournalSequence(value.seq, operation, true),
564
+ kind: value.kind,
565
+ disclosure: value.disclosure,
566
+ participantIds: Object.freeze(participantIds),
567
+ data,
568
+ createdAt: value.createdAt,
569
+ });
570
+ }
571
+ function parseWorldJournalSequence(value, operation, positive = false) {
572
+ if (!isWorldJournalSequence(value, positive)) {
573
+ throw invalidWorldJournalResponse(operation);
574
+ }
575
+ return value;
576
+ }
577
+ function isWorldJournalSequence(value, positive) {
578
+ if (typeof value !== "string" || !/^(?:0|[1-9][0-9]*)$/.test(value))
579
+ return false;
580
+ if (positive && value === "0")
581
+ return false;
582
+ return value.length < WORLD_JOURNAL_MAX_SEQUENCE.length ||
583
+ (value.length === WORLD_JOURNAL_MAX_SEQUENCE.length &&
584
+ value <= WORLD_JOURNAL_MAX_SEQUENCE);
585
+ }
586
+ function parseWorldParticipant(value, operation) {
587
+ if (!isRecord(value) || typeof value.participantId !== "string" ||
588
+ typeof value.firstSeenAt !== "string" || typeof value.lastSeenAt !== "string" ||
589
+ (value.lastLeftAt !== null && typeof value.lastLeftAt !== "string") ||
590
+ !Number.isSafeInteger(value.visitCount) || value.visitCount < 0) {
591
+ throw invalidWorldJournalResponse(operation);
592
+ }
593
+ return Object.freeze({
594
+ participantId: value.participantId,
595
+ firstSeenAt: value.firstSeenAt,
596
+ lastSeenAt: value.lastSeenAt,
597
+ lastLeftAt: value.lastLeftAt,
598
+ visitCount: value.visitCount,
599
+ });
600
+ }
601
+ function assertOnlyKeys(value, allowed, operation, field) {
602
+ if (!isRecord(value) || Object.keys(value).some((key) => !allowed.includes(key))) {
603
+ throw worldJournalArgumentError(`${field} contains an unsupported field.`, operation);
604
+ }
605
+ }
606
+ function isRecord(value) {
607
+ return value !== null && typeof value === "object" && !Array.isArray(value);
608
+ }
609
+ function hasUnpairedSurrogate(value) {
610
+ for (let index = 0; index < value.length; index += 1) {
611
+ const code = value.charCodeAt(index);
612
+ if (code >= 0xd800 && code <= 0xdbff) {
613
+ const next = value.charCodeAt(index + 1);
614
+ if (!(next >= 0xdc00 && next <= 0xdfff))
615
+ return true;
616
+ index += 1;
617
+ }
618
+ else if (code >= 0xdc00 && code <= 0xdfff) {
619
+ return true;
620
+ }
621
+ }
622
+ return false;
623
+ }
624
+ function utf8Length(value) {
625
+ return new TextEncoder().encode(value).byteLength;
626
+ }
291
627
  function createServerWorldCharacters(platformHandle) {
292
628
  return Object.freeze({
293
629
  async create(input) {
@@ -638,14 +974,20 @@ function unsupportedBinaryRuntime() {
638
974
  throw new TypeError("This Castle runtime does not support binary multiplayer messages yet.");
639
975
  }
640
976
  function joinedPlayer(connId, identity) {
641
- if (typeof connId === "string")
642
- return { ...identity, playerId: connId };
977
+ if (typeof connId === "string") {
978
+ return {
979
+ ...identity,
980
+ playerId: connId,
981
+ participantId: identity.scopedUserId ?? identity.userId,
982
+ };
983
+ }
643
984
  const legacy = connId;
644
985
  return {
645
986
  playerId: legacy.playerId,
646
987
  userId: legacy.userId,
647
988
  username: legacy.username,
648
989
  isAnonymous: legacy.isAnonymous,
990
+ participantId: legacy.scopedUserId ?? legacy.userId,
649
991
  ...(legacy.scopedUserId ? { scopedUserId: legacy.scopedUserId } : {}),
650
992
  };
651
993
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-sdk",
3
- "version": "0.4.44",
3
+ "version": "0.4.45",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/castle-xyz/castle-experimental-web.git"