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 +87 -0
- package/dist/castle.d.ts +3 -1
- package/dist/castle.js +1 -0
- package/dist/commands.d.ts +6 -0
- package/dist/display.d.ts +36 -0
- package/dist/display.js +96 -0
- package/dist/multiplayer.d.ts +3 -1
- package/dist/multiplayer.js +11 -1
- package/dist/runtime.js +35 -17
- package/dist/server/platformHandle.d.ts +2 -0
- package/dist/server/wrapper.d.ts +58 -0
- package/dist/server/wrapper.js +350 -8
- package/package.json +1 -1
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";
|
package/dist/commands.d.ts
CHANGED
|
@@ -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
|
+
};
|
package/dist/display.js
ADDED
|
@@ -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
|
+
}
|
package/dist/multiplayer.d.ts
CHANGED
|
@@ -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;
|
package/dist/multiplayer.js
CHANGED
|
@@ -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:
|
|
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
|
|
235
|
-
//
|
|
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:
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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. */
|
package/dist/server/wrapper.d.ts
CHANGED
|
@@ -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>;
|
package/dist/server/wrapper.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 {
|
|
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
|
}
|