castle-web-sdk 0.4.43 → 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
@@ -1008,6 +1046,55 @@ Host-specific layout flags such as `CastleEmbed.feed` are deprecated.
1008
1046
  New code should let Castle hosts provide the frame and let the SDK infer
1009
1047
  whether it is running standalone or embedded.
1010
1048
 
1049
+ ### Filling the screen: `<meta name="castle-display">`
1050
+
1051
+ By default a deck is a 5:7 portrait card. A deck that can lay itself out at
1052
+ other shapes opts out of the card in its own `index.html`:
1053
+
1054
+ ```html
1055
+ <meta name="castle-display" content="layout=fill">
1056
+ <!-- or narrow the shapes it accepts (width / height, as 16/9, 16:9 or 1.78) -->
1057
+ <meta name="castle-display" content="layout=fill, min-aspect=9/16, max-aspect=16/9">
1058
+ ```
1059
+
1060
+ A `fill` deck gets the host's whole frame, edge to edge with no rounded
1061
+ corners, whenever that frame's aspect is inside its range (default 9/21 to
1062
+ 21/9); outside it, the deck gets the largest in-range box, centered. Castle's
1063
+ web player gives a fill deck a bigger stage and a fullscreen button. Hosts
1064
+ that don't know the tag still give it a 5:7 frame, so a fill deck should look
1065
+ right at 5:7 as well.
1066
+
1067
+ Lay out against the stage: the element `initCard()` returns, or
1068
+ `--castle-card-w`/`--castle-card-h` in CSS, with a resize listener. Not the
1069
+ window: the stage is smaller than the window when the host frame is outside
1070
+ the deck's range, and inside `castle-web serve`'s own page. `getDisplay()` returns the parsed declaration and `fitStage(w, h,
1071
+ display)` the box the SDK would pick.
1072
+
1073
+ Size HUD, buttons and text in the card unit, not in `vw` or `cqw`:
1074
+
1075
+ ```css
1076
+ .score { font-size: calc(5 * var(--castle-unit)); top: calc(3 * var(--castle-unit)); }
1077
+ ```
1078
+
1079
+ `--castle-unit` is 1% of the width of the largest 5:7 card that fits in the
1080
+ stage. On a card, or a stage taller than a card, it is 1% of the stage width
1081
+ (the same as `1vw`); on a wide stage it stops growing, so a HUD keeps its card
1082
+ size while the playfield gets the extra room. Anything sized in `vw` grows with
1083
+ the stage instead, and a 16:9 monitor makes it huge. Canvas code gets the same
1084
+ number from `cardUnit(width, height)` (px). The SDK sets the exact value for
1085
+ its own stage; in edit mode, and in a kit's scene editor that draws the stage
1086
+ smaller than the window, it falls back to the window's.
1087
+
1088
+ A 3D camera tuned for the card keeps its framing with `fovForAspect(fov,
1089
+ aspect)`: it returns `fov` at 5:7 and wider (a wide stage shows more to the
1090
+ sides) and widens it on a stage narrower than 5:7, so a tall phone still sees
1091
+ the card's full width.
1092
+
1093
+ ```js
1094
+ camera.fov = fovForAspect(50, width / height);
1095
+ camera.updateProjectionMatrix();
1096
+ ```
1097
+
1011
1098
  ### `CARD_RATIO`
1012
1099
 
1013
1100
  The card aspect ratio (`5 / 7`). Use this if you need to size something
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";
@@ -29,6 +29,8 @@ export { Portal } from "./portal";
29
29
  export type { CastlePortalApi, PortalOpenResult, PortalOpenStatus, PortalPrefetchResult, PortalPrefetchStatus, } from "./portal";
30
30
  export { CARD_RATIO, deleteFile, fileUrl, initCard, onBeforeRestart, onFilesChanged, onSaveReloadState, openFile, renameFile, requestReload, setup, takeReloadState, writeFileOnce, } from "./runtime";
31
31
  export type { FileChange, FilesChangedEvent } from "./runtime";
32
+ export { cardUnit, fitStage, fovForAspect, getDisplay } from "./display";
33
+ export type { DeckDisplay, DeckLayout } from "./display";
32
34
  export { flushSaves, hasPendingSave, onSaveState, writeFile, } from "./saveQueue";
33
35
  export type { SaveState } from "./saveQueue";
34
36
  export { SharedStorage, Storage } from "./storage";
package/dist/castle.js CHANGED
@@ -15,6 +15,7 @@ export { Pass } from "./passes";
15
15
  export { Snapshot } from "./snapshots";
16
16
  export { Portal } from "./portal";
17
17
  export { CARD_RATIO, deleteFile, fileUrl, initCard, onBeforeRestart, onFilesChanged, onSaveReloadState, openFile, renameFile, requestReload, setup, takeReloadState, writeFileOnce, } from "./runtime";
18
+ export { cardUnit, fitStage, fovForAspect, getDisplay } from "./display";
18
19
  export { flushSaves, hasPendingSave, onSaveState, writeFile, } from "./saveQueue";
19
20
  export { SharedStorage, Storage } from "./storage";
20
21
  export { Store } from "./store";
@@ -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;
@@ -0,0 +1,36 @@
1
+ export type DeckLayout = "card" | "fill";
2
+ export interface DeckDisplay {
3
+ layout: DeckLayout;
4
+ /** Narrowest width / height the deck accepts. */
5
+ minAspect: number;
6
+ /** Widest width / height the deck accepts. */
7
+ maxAspect: number;
8
+ }
9
+ export declare const CARD_ASPECT: number;
10
+ /** Default fill range: a tall 21:9 phone held upright to a 21:9 monitor. */
11
+ export declare const FILL_MIN_ASPECT: number;
12
+ export declare const FILL_MAX_ASPECT: number;
13
+ export declare const CARD_DISPLAY: DeckDisplay;
14
+ export declare function parseDisplayContent(content: string | null | undefined): DeckDisplay;
15
+ /** The display this deck declared in its index.html (a card when it declared none). */
16
+ export declare function getDisplay(): DeckDisplay;
17
+ /**
18
+ * The card unit, in px, for a stage of `width` x `height`: 1% of the width of
19
+ * the largest 5:7 card that fits in it. On a card it is 1% of the card's width;
20
+ * on a wider stage it stops growing, so HUD and text sized in it stay the size
21
+ * they are on a card while the playfield gets the extra room. CSS gets the same
22
+ * value as `var(--castle-unit)`.
23
+ */
24
+ export declare function cardUnit(width: number, height: number): number;
25
+ /**
26
+ * A vertical field of view (degrees) for a camera tuned at 5:7. On a stage
27
+ * narrower than 5:7 (a tall phone) it widens so the card's horizontal extent
28
+ * stays in view; at 5:7 and wider it returns `fovDeg` unchanged, so a wide
29
+ * stage just shows more to the sides. Capped at 120 degrees.
30
+ */
31
+ export declare function fovForAspect(fovDeg: number, aspect: number): number;
32
+ /** The largest box inside `width` x `height` whose aspect is in the display's range. */
33
+ export declare function fitStage(width: number, height: number, display: DeckDisplay): {
34
+ w: number;
35
+ h: number;
36
+ };
@@ -0,0 +1,96 @@
1
+ // How much of the host's frame a deck may use.
2
+ //
3
+ // A deck declares it in its own index.html, the way a page declares its
4
+ // viewport:
5
+ //
6
+ // <meta name="castle-display" content="layout=fill">
7
+ // <meta name="castle-display" content="layout=fill, min-aspect=9/16, max-aspect=16/9">
8
+ //
9
+ // With no tag the deck is a `card`: the classic 5:7 portrait card, exactly as
10
+ // before. A `fill` deck takes whatever box the host gives it, as long as the
11
+ // box's aspect (width / height) is inside [minAspect, maxAspect]; outside that
12
+ // range it gets the largest box in range, centered. A card is just the range
13
+ // [5/7, 5/7].
14
+ //
15
+ // Hosts read the same tag out of the bundle to size the frame before the deck
16
+ // loads (castle-www common/deckDisplay.ts mirrors this parser). A host that
17
+ // knows nothing about it still gives the deck a 5:7 frame, so a fill deck
18
+ // should render well at 5:7 too; one whose range leaves 5/7 out (landscape
19
+ // only) is letterboxed inside such a frame.
20
+ export const CARD_ASPECT = 5 / 7;
21
+ /** Default fill range: a tall 21:9 phone held upright to a 21:9 monitor. */
22
+ export const FILL_MIN_ASPECT = 9 / 21;
23
+ export const FILL_MAX_ASPECT = 21 / 9;
24
+ export const CARD_DISPLAY = {
25
+ layout: "card",
26
+ minAspect: CARD_ASPECT,
27
+ maxAspect: CARD_ASPECT,
28
+ };
29
+ // "16/9", "16:9" or "1.78"; anything else (or a non-positive value) is null.
30
+ function parseAspect(value) {
31
+ const ratio = value.match(/^\s*(\d+(?:\.\d+)?)\s*[/:]\s*(\d+(?:\.\d+)?)\s*$/);
32
+ const n = ratio ? Number(ratio[1]) / Number(ratio[2]) : Number(value);
33
+ return Number.isFinite(n) && n > 0 ? n : null;
34
+ }
35
+ export function parseDisplayContent(content) {
36
+ if (!content)
37
+ return CARD_DISPLAY;
38
+ const fields = {};
39
+ for (const part of content.split(/[,;]/)) {
40
+ const eq = part.indexOf("=");
41
+ if (eq < 0)
42
+ continue;
43
+ fields[part.slice(0, eq).trim().toLowerCase()] = part.slice(eq + 1).trim();
44
+ }
45
+ if (fields.layout?.toLowerCase() !== "fill")
46
+ return CARD_DISPLAY;
47
+ let minAspect = parseAspect(fields["min-aspect"] ?? "") ?? FILL_MIN_ASPECT;
48
+ let maxAspect = parseAspect(fields["max-aspect"] ?? "") ?? FILL_MAX_ASPECT;
49
+ if (minAspect > maxAspect)
50
+ [minAspect, maxAspect] = [maxAspect, minAspect];
51
+ return { layout: "fill", minAspect, maxAspect };
52
+ }
53
+ let cached = null;
54
+ /** The display this deck declared in its index.html (a card when it declared none). */
55
+ export function getDisplay() {
56
+ if (cached)
57
+ return cached;
58
+ if (typeof document === "undefined")
59
+ return CARD_DISPLAY;
60
+ const meta = document.querySelector('meta[name="castle-display"]');
61
+ cached = parseDisplayContent(meta?.content);
62
+ return cached;
63
+ }
64
+ /**
65
+ * The card unit, in px, for a stage of `width` x `height`: 1% of the width of
66
+ * the largest 5:7 card that fits in it. On a card it is 1% of the card's width;
67
+ * on a wider stage it stops growing, so HUD and text sized in it stay the size
68
+ * they are on a card while the playfield gets the extra room. CSS gets the same
69
+ * value as `var(--castle-unit)`.
70
+ */
71
+ export function cardUnit(width, height) {
72
+ return Math.min(Math.max(0, width), Math.max(0, height) * CARD_ASPECT) / 100;
73
+ }
74
+ /**
75
+ * A vertical field of view (degrees) for a camera tuned at 5:7. On a stage
76
+ * narrower than 5:7 (a tall phone) it widens so the card's horizontal extent
77
+ * stays in view; at 5:7 and wider it returns `fovDeg` unchanged, so a wide
78
+ * stage just shows more to the sides. Capped at 120 degrees.
79
+ */
80
+ export function fovForAspect(fovDeg, aspect) {
81
+ if (!(aspect > 0) || aspect >= CARD_ASPECT)
82
+ return fovDeg;
83
+ const half = Math.atan((Math.tan((fovDeg * Math.PI) / 360) * CARD_ASPECT) / aspect);
84
+ return Math.min(120, (half * 360) / Math.PI);
85
+ }
86
+ /** The largest box inside `width` x `height` whose aspect is in the display's range. */
87
+ export function fitStage(width, height, display) {
88
+ const aw = Math.max(1, width);
89
+ const ah = Math.max(1, height);
90
+ const aspect = aw / ah;
91
+ if (aspect > display.maxAspect)
92
+ return { w: ah * display.maxAspect, h: ah };
93
+ if (aspect < display.minAspect)
94
+ return { w: aw, h: aw / display.minAspect };
95
+ return { w: aw, h: ah };
96
+ }
@@ -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;
package/dist/runtime.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { CASTLE_SDK_PROTOCOL, } from "./commands";
2
2
  import { getCastleEmbed, isEdit } from "./context";
3
+ import { cardUnit, fitStage, getDisplay } from "./display";
3
4
  import { appliedNotice, applyTunedParams, } from "./paramsRegistry";
4
5
  export const CARD_RATIO = 5 / 7;
5
6
  // Host -> deck capture request, and the deck's answer. Kept in sync with the
@@ -31,6 +32,7 @@ export function setup() {
31
32
  initHostCapture();
32
33
  initPlaySelection();
33
34
  initEditSelection();
35
+ initCardUnitFallback();
34
36
  initPlayCard();
35
37
  logPanelLoad();
36
38
  }
@@ -120,7 +122,7 @@ export function initCard() {
120
122
  body { display: flex; align-items: center; justify-content: center; }
121
123
  #castle-card {
122
124
  position: relative;
123
- border-radius: 4% / calc(4% * (5 / 7));
125
+ border-radius: ${cardRadius()};
124
126
  overflow: hidden;
125
127
  background: #121213;
126
128
  }
@@ -129,10 +131,12 @@ export function initCard() {
129
131
  const card = document.createElement("div");
130
132
  card.id = "castle-card";
131
133
  document.body.appendChild(card);
134
+ initCardUnitFallback();
132
135
  function resize() {
133
136
  const { w, h } = computeCardSize();
134
137
  card.style.width = w + "px";
135
138
  card.style.height = h + "px";
139
+ setCardUnit(w, h);
136
140
  }
137
141
  resize();
138
142
  window.addEventListener("resize", resize);
@@ -231,8 +235,10 @@ function initEditSelection() {
231
235
  selection.removeAllRanges();
232
236
  });
233
237
  }
234
- // Constrains whatever the deck renders into #root to a 5:7 card in play mode.
235
- // Hosts own max size and padding; the SDK only preserves the card aspect ratio.
238
+ // Constrains whatever the deck renders into #root to the deck's stage in play
239
+ // mode: a 5:7 card, or for a `fill` deck (see display.ts) the host's whole frame
240
+ // within the deck's aspect range. Hosts own max size and padding; the SDK only
241
+ // preserves the aspect.
236
242
  function initPlayCard() {
237
243
  if (isEdit())
238
244
  return;
@@ -247,7 +253,7 @@ function initPlayCard() {
247
253
  transform: translate(-50%, -50%) !important;
248
254
  width: var(--castle-card-w, 100vw) !important;
249
255
  height: var(--castle-card-h, 100vh) !important;
250
- border-radius: 4% / calc(4% * (5 / 7)) !important;
256
+ border-radius: ${cardRadius()} !important;
251
257
  overflow: hidden !important;
252
258
  }
253
259
  `;
@@ -276,25 +282,37 @@ function initPlayCard() {
276
282
  const { w, h } = computeCardSize();
277
283
  document.documentElement.style.setProperty("--castle-card-w", w + "px");
278
284
  document.documentElement.style.setProperty("--castle-card-h", h + "px");
285
+ setCardUnit(w, h);
279
286
  }
280
287
  resize();
281
288
  window.addEventListener("resize", resize);
282
289
  }
290
+ // `--castle-unit`: 1% of the width of the largest 5:7 card inside the stage
291
+ // (display.ts cardUnit). A deck sizes HUD and text in it so they keep card
292
+ // proportions on a wide fill stage. Play mode sets the exact px value on every
293
+ // resize; the stylesheet fallback covers edit mode and the moment before the
294
+ // first resize, and follows the window -- which is not the stage where a kit's
295
+ // scene editor draws the deck smaller inside the editor.
296
+ function setCardUnit(w, h) {
297
+ document.documentElement.style.setProperty("--castle-unit", cardUnit(w, h) + "px");
298
+ }
299
+ let cardUnitFallbackInstalled = false;
300
+ function initCardUnitFallback() {
301
+ if (cardUnitFallbackInstalled)
302
+ return;
303
+ cardUnitFallbackInstalled = true;
304
+ const style = document.createElement("style");
305
+ style.textContent = `:root { --castle-unit: min(1vw, 1vh * 5 / 7); }`;
306
+ document.head.appendChild(style);
307
+ }
283
308
  function computeCardSize() {
284
309
  const pad = shouldUseStandaloneChrome() ? 20 : 0;
285
- const aw = Math.max(1, window.innerWidth - pad * 2);
286
- const ah = Math.max(1, window.innerHeight - pad * 2);
287
- let w;
288
- let h;
289
- if (aw / ah < CARD_RATIO) {
290
- w = aw;
291
- h = w / CARD_RATIO;
292
- }
293
- else {
294
- h = ah;
295
- w = h * CARD_RATIO;
296
- }
297
- return { w, h };
310
+ return fitStage(window.innerWidth - pad * 2, window.innerHeight - pad * 2, getDisplay());
311
+ }
312
+ // A card keeps its rounded corners; a fill deck runs edge to edge and the host
313
+ // decides how its frame looks.
314
+ function cardRadius() {
315
+ return getDisplay().layout === "fill" ? "0" : "4% / calc(4% * (5 / 7))";
298
316
  }
299
317
  function shouldUseStandaloneChrome() {
300
318
  if (window.parent !== window)
@@ -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.43",
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"