castle-web-sdk 0.4.44 → 0.4.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -0
- package/dist/castle.d.ts +1 -1
- package/dist/commands.d.ts +6 -0
- package/dist/multiplayer.d.ts +3 -1
- package/dist/multiplayer.js +11 -1
- 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
|
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";
|
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;
|
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;
|
|
@@ -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
|
}
|