castle-web-sdk 0.4.44 → 0.4.46
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 +487 -53
- package/dist/agent.js +4 -4
- package/dist/avatar.d.ts +12 -0
- package/dist/avatar.js +23 -0
- package/dist/castle.d.ts +10 -1
- package/dist/castle.js +5 -0
- package/dist/commands.d.ts +229 -0
- package/dist/commands.js +16 -0
- package/dist/context.d.ts +6 -0
- package/dist/experiments.d.ts +15 -0
- package/dist/experiments.js +83 -0
- package/dist/hub.d.ts +85 -0
- package/dist/hub.js +107 -0
- package/dist/multiplayer.d.ts +88 -6
- package/dist/multiplayer.js +219 -8
- package/dist/save.d.ts +197 -0
- package/dist/save.js +525 -0
- package/dist/saveCoordinator.d.ts +39 -0
- package/dist/saveCoordinator.js +252 -0
- package/dist/server/hub.d.ts +169 -0
- package/dist/server/hub.js +125 -0
- package/dist/server/platformHandle.d.ts +51 -2
- package/dist/server/storage.d.ts +58 -4
- package/dist/server/storage.js +25 -5
- package/dist/server/wrapper.d.ts +147 -6
- package/dist/server/wrapper.js +741 -34
- package/dist/store.d.ts +3 -2
- package/dist/store.js +5 -0
- package/dist/transport.d.ts +2 -1
- package/dist/transport.js +13 -1
- package/package.json +1 -1
package/dist/server/wrapper.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
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
|
+
import { createServerHub, hubArrival, recordHiddenUserIds, } from "./hub";
|
|
5
|
+
export { createSaveCoordinator } from "../saveCoordinator";
|
|
6
|
+
export { SaveError } from "../save";
|
|
4
7
|
// The publish bundler binds the author's default callbacks export once and
|
|
5
8
|
// exports the returned one-argument function as `__castleBootSession`.
|
|
6
9
|
export function createCastleSessionBoot(callbacks) {
|
|
@@ -10,21 +13,26 @@ export function createCastleSessionBoot(callbacks) {
|
|
|
10
13
|
}
|
|
11
14
|
async function bootSession(platformHandle, callbacks) {
|
|
12
15
|
const players = new Map();
|
|
13
|
-
const
|
|
16
|
+
const arrivals = new Map();
|
|
17
|
+
// playerId -> users a block hides from them; never on the player object.
|
|
18
|
+
const hidden = new Map();
|
|
19
|
+
// playerId -> their A/B arms while connected; never on the player object.
|
|
20
|
+
const variants = new Map();
|
|
21
|
+
const session = createAuthorSession(platformHandle, players, arrivals, hidden, variants);
|
|
14
22
|
let tickTimer = null;
|
|
15
|
-
platformHandle.on("join", async (connId, identity) => {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
// the runtime-agnostic (connId, trusted identity) pair.
|
|
19
|
-
const player = joinedPlayer(connId, identity);
|
|
20
|
-
players.set(player.playerId, player);
|
|
21
|
-
await callbacks.onPlayerJoin?.(session, player);
|
|
23
|
+
platformHandle.on("join", async (connId, identity, handleArrival, handleVariants) => {
|
|
24
|
+
const { player, arrival } = admitPlayer({ players, arrivals, hidden, variants }, connId, identity, handleArrival, handleVariants);
|
|
25
|
+
await callbacks.onPlayerJoin?.(session, player, arrival);
|
|
22
26
|
});
|
|
23
27
|
platformHandle.on("leave", async (connId) => {
|
|
24
28
|
const player = players.get(connId);
|
|
25
29
|
players.delete(connId);
|
|
26
|
-
|
|
30
|
+
arrivals.delete(connId);
|
|
31
|
+
hidden.delete(connId);
|
|
32
|
+
variants.delete(connId);
|
|
33
|
+
if (player) {
|
|
27
34
|
await callbacks.onPlayerLeave?.(session, player);
|
|
35
|
+
}
|
|
28
36
|
});
|
|
29
37
|
platformHandle.on("message", async (connId, text) => {
|
|
30
38
|
const player = players.get(connId);
|
|
@@ -53,12 +61,12 @@ async function bootSession(platformHandle, callbacks) {
|
|
|
53
61
|
const turn = Object.freeze({
|
|
54
62
|
requestId: request.requestId,
|
|
55
63
|
operationId: request.operationId,
|
|
56
|
-
speaker: Object.freeze(
|
|
64
|
+
speaker: Object.freeze(joinedPlayer(request.speaker.playerId, request.speaker)),
|
|
57
65
|
characterId: request.characterId,
|
|
58
66
|
input: request.input,
|
|
59
67
|
audience: Object.freeze({
|
|
60
68
|
disclosure: request.audience.disclosure,
|
|
61
|
-
players: Object.freeze(request.audience.players.map((player) => Object.freeze(
|
|
69
|
+
players: Object.freeze(request.audience.players.map((player) => Object.freeze(joinedPlayer(player.playerId, player)))),
|
|
62
70
|
characterIds: Object.freeze([...request.audience.characterIds]),
|
|
63
71
|
}),
|
|
64
72
|
async send() {
|
|
@@ -102,9 +110,10 @@ async function bootSession(platformHandle, callbacks) {
|
|
|
102
110
|
let settled = false;
|
|
103
111
|
const brokerFailure = { rejection: null };
|
|
104
112
|
const command = Object.freeze({
|
|
113
|
+
type: request.command.type,
|
|
105
114
|
requestId: request.requestId,
|
|
106
115
|
operationId: request.operationId,
|
|
107
|
-
speaker: Object.freeze(
|
|
116
|
+
speaker: Object.freeze(joinedPlayer(request.speaker.playerId, request.speaker)),
|
|
108
117
|
command: deepFreezeJsonObject(request.command),
|
|
109
118
|
async apply() {
|
|
110
119
|
if (settled)
|
|
@@ -148,7 +157,7 @@ async function bootSession(platformHandle, callbacks) {
|
|
|
148
157
|
const brokerFailure = { rejection: null };
|
|
149
158
|
const read = Object.freeze({
|
|
150
159
|
requestId: request.requestId,
|
|
151
|
-
speaker: Object.freeze(
|
|
160
|
+
speaker: Object.freeze(joinedPlayer(request.speaker.playerId, request.speaker)),
|
|
152
161
|
query: deepFreezeJsonObject(request.query),
|
|
153
162
|
async read() {
|
|
154
163
|
if (settled)
|
|
@@ -201,7 +210,22 @@ async function bootSession(platformHandle, callbacks) {
|
|
|
201
210
|
}, 1_000 / platformHandle.config.tickRate);
|
|
202
211
|
}
|
|
203
212
|
}
|
|
204
|
-
function createAuthorSession(platformHandle, players) {
|
|
213
|
+
function createAuthorSession(platformHandle, players, arrivals, hidden, variants) {
|
|
214
|
+
// Lenient about what it is handed: an unknown player is in no experiment.
|
|
215
|
+
const variantsOf = (player) => variants.get(typeof player === "string"
|
|
216
|
+
? player
|
|
217
|
+
: (player?.playerId ?? ""));
|
|
218
|
+
// The pinned place counts only where the host services the hub ops.
|
|
219
|
+
const hub = createServerHub(platformHandle, arrivals, (a, b) => {
|
|
220
|
+
if (hidden.size === 0 || a === b)
|
|
221
|
+
return false;
|
|
222
|
+
const userA = players.get(a)?.userId;
|
|
223
|
+
const userB = players.get(b)?.userId;
|
|
224
|
+
if (!userA || !userB)
|
|
225
|
+
return false;
|
|
226
|
+
return Boolean(hidden.get(a)?.has(userB) || hidden.get(b)?.has(userA));
|
|
227
|
+
});
|
|
228
|
+
const world = createServerWorld(platformHandle);
|
|
205
229
|
return {
|
|
206
230
|
get players() {
|
|
207
231
|
return Array.from(players.values());
|
|
@@ -209,8 +233,20 @@ function createAuthorSession(platformHandle, players) {
|
|
|
209
233
|
sessionId: platformHandle.config.sessionId,
|
|
210
234
|
deckId: platformHandle.config.deckId,
|
|
211
235
|
mode: platformHandle.config.mode,
|
|
236
|
+
place: typeof platformHandle.config.place === "string"
|
|
237
|
+
? platformHandle.config.place
|
|
238
|
+
: hub?.place ?? null,
|
|
212
239
|
storage: createServerStorage(platformHandle),
|
|
213
|
-
world
|
|
240
|
+
world,
|
|
241
|
+
characters: world?.characters ?? createPlaceCharacters(platformHandle),
|
|
242
|
+
hub,
|
|
243
|
+
experiment(player, name) {
|
|
244
|
+
const known = variantsOf(player);
|
|
245
|
+
return known && Object.hasOwn(known, name) ? known[name] : null;
|
|
246
|
+
},
|
|
247
|
+
experiments(player) {
|
|
248
|
+
return { ...variantsOf(player) };
|
|
249
|
+
},
|
|
214
250
|
send(playerId, data) {
|
|
215
251
|
if (isBinaryPayload(data)) {
|
|
216
252
|
requireSendBinary(platformHandle);
|
|
@@ -252,6 +288,35 @@ function createAuthorSession(platformHandle, players) {
|
|
|
252
288
|
const WORLD_OPERATION_ID_MAX_BYTES = 128;
|
|
253
289
|
const WORLD_OPERATION_ID_MAX_CHARACTERS = 120;
|
|
254
290
|
const WORLD_CHECKPOINT_VERSION_MAX = 2_147_483_647;
|
|
291
|
+
const WORLD_JOURNAL_MAX_ENTRIES = 16;
|
|
292
|
+
const WORLD_JOURNAL_MAX_KIND_CHARACTERS = 64;
|
|
293
|
+
const WORLD_JOURNAL_MAX_PARTICIPANT_BYTES = 128;
|
|
294
|
+
const WORLD_JOURNAL_MAX_PARTICIPANTS_PER_ENTRY = 16;
|
|
295
|
+
const WORLD_JOURNAL_MAX_PARTICIPANT_FILTERS = 64;
|
|
296
|
+
const WORLD_JOURNAL_MAX_DATA_BYTES = 2_048;
|
|
297
|
+
const WORLD_JOURNAL_MAX_REQUEST_BYTES = 16 * 1_024;
|
|
298
|
+
const WORLD_JOURNAL_MAX_READ_LIMIT = 128;
|
|
299
|
+
const WORLD_PARTICIPANTS_MAX_READ_LIMIT = 256;
|
|
300
|
+
const WORLD_JOURNAL_MAX_SEQUENCE = "9223372036854775807";
|
|
301
|
+
const SAFE_WORLD_JOURNAL_KIND = /^[A-Za-z0-9_.:-]+$/;
|
|
302
|
+
const WORLD_NOTIFICATIONS_MAX_PARTICIPANTS = 16;
|
|
303
|
+
const WORLD_NOTIFICATIONS_MAX_SLOTS = 4;
|
|
304
|
+
const WORLD_NOTIFICATIONS_MAX_SLOT_CHARACTERS = 40;
|
|
305
|
+
const WORLD_NOTIFICATIONS_MAX_SCHEDULE_MS = 30 * 24 * 60 * 60 * 1_000;
|
|
306
|
+
const WORLD_NOTIFICATIONS_PAST_TOLERANCE_MS = 60 * 1_000;
|
|
307
|
+
const WORLD_CATCH_UP_DEFAULT_MAX_ELAPSED_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
308
|
+
const SAFE_WORLD_NOTIFICATION_KEY = /^[A-Za-z0-9_-]+$/;
|
|
309
|
+
const CANONICAL_WORLD_NOTIFICATION_TIME = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,3}))?Z$/;
|
|
310
|
+
const URL_IN_WORLD_NOTIFICATION_TEXT = /(?:https?:\/\/|www\.|\b[a-z0-9-]+\.(?:app|com|dev|gg|io|net|org|xyz)\b)/i;
|
|
311
|
+
// A hub place's layer has no world of its own, but the host may still broker
|
|
312
|
+
// the place's shared characters into it (`placeCharacters` in the runtime
|
|
313
|
+
// init). The character API is the world one; only the binding differs, and the
|
|
314
|
+
// host stamps that.
|
|
315
|
+
function createPlaceCharacters(platformHandle) {
|
|
316
|
+
if (platformHandle.config.placeCharacters !== true)
|
|
317
|
+
return null;
|
|
318
|
+
return createServerWorldCharacters(platformHandle);
|
|
319
|
+
}
|
|
255
320
|
function createServerWorld(platformHandle) {
|
|
256
321
|
if (!platformHandle.config.world)
|
|
257
322
|
return null;
|
|
@@ -260,34 +325,632 @@ function createServerWorld(platformHandle) {
|
|
|
260
325
|
throw worldError("The persistent-world checkpoint broker is unavailable.", "read");
|
|
261
326
|
}
|
|
262
327
|
const initial = parseWorldSnapshot(platformHandle.config.world, "read");
|
|
328
|
+
const read = async () => {
|
|
329
|
+
const result = await platformHandle.worldCheckpointRead();
|
|
330
|
+
return parseWorldSnapshot(parsePlatformJson(result, "read"), "read");
|
|
331
|
+
};
|
|
332
|
+
const commit = async (input) => {
|
|
333
|
+
const expectedRevision = assertWorldRevision(input?.expectedRevision);
|
|
334
|
+
const operationId = assertWorldOperationId(input?.operationId);
|
|
335
|
+
const checkpoint = assertWorldCheckpoint(input?.checkpoint);
|
|
336
|
+
const raw = await platformHandle.worldCheckpointCommit(JSON.stringify({
|
|
337
|
+
expectedRevision,
|
|
338
|
+
operationId,
|
|
339
|
+
checkpoint,
|
|
340
|
+
}));
|
|
341
|
+
const result = parsePlatformJson(raw, "commit");
|
|
342
|
+
if (typeof result.replayed !== "boolean") {
|
|
343
|
+
throw worldError("The platform returned an invalid commit receipt.", "commit");
|
|
344
|
+
}
|
|
345
|
+
return Object.freeze({
|
|
346
|
+
revision: assertWorldRevision(result.revision),
|
|
347
|
+
replayed: result.replayed,
|
|
348
|
+
operationId,
|
|
349
|
+
});
|
|
350
|
+
};
|
|
263
351
|
return Object.freeze({
|
|
264
352
|
snapshot: initial,
|
|
353
|
+
journal: createServerWorldJournal(platformHandle),
|
|
354
|
+
participants: createServerWorldParticipants(platformHandle),
|
|
355
|
+
notifications: createServerWorldNotifications(platformHandle),
|
|
265
356
|
characters: createServerWorldCharacters(platformHandle),
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
357
|
+
read,
|
|
358
|
+
commit,
|
|
359
|
+
async catchUp(options) {
|
|
360
|
+
return catchUpWorld(platformHandle.config.world?.worldTime, read, commit, options);
|
|
269
361
|
},
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
async function catchUpWorld(worldTime, read, commit, options) {
|
|
365
|
+
const operation = "session.world.catchUp";
|
|
366
|
+
if (worldTime?.advance !== "wallClock") {
|
|
367
|
+
throw worldJournalArgumentError('The destination must opt in with worldTime.advance set to "wallClock".', operation);
|
|
368
|
+
}
|
|
369
|
+
assertOnlyKeys(options, ["key", "maxElapsedMs", "apply"], operation, "options");
|
|
370
|
+
const key = options.key === undefined
|
|
371
|
+
? "advancedThroughMs"
|
|
372
|
+
: assertWorldCatchUpKey(options.key, operation);
|
|
373
|
+
const maxElapsedMs = options.maxElapsedMs === undefined
|
|
374
|
+
? WORLD_CATCH_UP_DEFAULT_MAX_ELAPSED_MS
|
|
375
|
+
: assertBoundedInteger(options.maxElapsedMs, 0, Number.MAX_SAFE_INTEGER, "maxElapsedMs", operation);
|
|
376
|
+
if (typeof options.apply !== "function") {
|
|
377
|
+
throw worldJournalArgumentError("apply must be a function.", operation);
|
|
378
|
+
}
|
|
379
|
+
const now = Date.now();
|
|
380
|
+
let snapshot = await read();
|
|
381
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
382
|
+
const state = worldCatchUpState(snapshot.checkpoint, key, now, maxElapsedMs, operation);
|
|
383
|
+
if (state.markerExists && state.elapsedMs === 0) {
|
|
384
|
+
if (state.markerMs >= now) {
|
|
385
|
+
return Object.freeze({ applied: false, elapsedMs: 0, revision: snapshot.revision });
|
|
386
|
+
}
|
|
387
|
+
const checkpoint = checkpointWithCatchUpMarker(snapshot.checkpoint, key, state.markerMs, now, operation);
|
|
388
|
+
try {
|
|
389
|
+
const receipt = await commit({
|
|
390
|
+
expectedRevision: snapshot.revision,
|
|
391
|
+
operationId: `catchUp:${snapshot.revision}`,
|
|
392
|
+
checkpoint,
|
|
393
|
+
});
|
|
394
|
+
return Object.freeze({ applied: false, elapsedMs: 0, revision: receipt.revision });
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
if (attempt !== 0 || !isWorldRevisionConflict(error))
|
|
398
|
+
throw error;
|
|
399
|
+
snapshot = await read();
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const returned = await options.apply(Object.freeze({
|
|
404
|
+
elapsedMs: state.elapsedMs,
|
|
405
|
+
now,
|
|
406
|
+
checkpoint: snapshot.checkpoint,
|
|
407
|
+
}));
|
|
408
|
+
const normalized = assertWorldCheckpoint(returned, "commit");
|
|
409
|
+
if (!isRecord(normalized.value)) {
|
|
410
|
+
throw worldJournalArgumentError("apply must return a checkpoint whose value is an object.", operation);
|
|
411
|
+
}
|
|
412
|
+
const checkpoint = checkpointWithCatchUpMarker(normalized, key, state.markerMs, now, operation);
|
|
413
|
+
try {
|
|
414
|
+
const receipt = await commit({
|
|
415
|
+
expectedRevision: snapshot.revision,
|
|
416
|
+
operationId: `catchUp:${snapshot.revision}`,
|
|
277
417
|
checkpoint,
|
|
278
|
-
})
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
418
|
+
});
|
|
419
|
+
return Object.freeze({
|
|
420
|
+
applied: !receipt.replayed,
|
|
421
|
+
elapsedMs: state.elapsedMs,
|
|
422
|
+
revision: receipt.revision,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
catch (error) {
|
|
426
|
+
if (attempt !== 0 || !isWorldRevisionConflict(error))
|
|
427
|
+
throw error;
|
|
428
|
+
snapshot = await read();
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
throw new TypeError(`${operation}: unreachable catch-up state.`);
|
|
432
|
+
}
|
|
433
|
+
function assertWorldCatchUpKey(value, operation) {
|
|
434
|
+
if (typeof value !== "string") {
|
|
435
|
+
throw worldJournalArgumentError("key must be a string.", operation);
|
|
436
|
+
}
|
|
437
|
+
return value;
|
|
438
|
+
}
|
|
439
|
+
function worldCatchUpState(checkpoint, key, now, maxElapsedMs, operation) {
|
|
440
|
+
if (!checkpoint ||
|
|
441
|
+
!isRecord(checkpoint.value) ||
|
|
442
|
+
!Object.prototype.hasOwnProperty.call(checkpoint.value, key)) {
|
|
443
|
+
return { markerExists: false, markerMs: now, elapsedMs: 0 };
|
|
444
|
+
}
|
|
445
|
+
const advancedThroughMs = checkpoint.value[key];
|
|
446
|
+
if (!Number.isSafeInteger(advancedThroughMs) || advancedThroughMs < 0) {
|
|
447
|
+
throw worldJournalArgumentError(`checkpoint.value.${key} must be a non-negative integer timestamp in milliseconds.`, operation);
|
|
448
|
+
}
|
|
449
|
+
const markerMs = advancedThroughMs;
|
|
450
|
+
return {
|
|
451
|
+
markerExists: true,
|
|
452
|
+
markerMs,
|
|
453
|
+
elapsedMs: Math.min(maxElapsedMs, Math.max(0, now - markerMs)),
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
function checkpointWithCatchUpMarker(checkpoint, key, existingMarkerMs, now, operation) {
|
|
457
|
+
if (!isRecord(checkpoint.value)) {
|
|
458
|
+
throw worldJournalArgumentError("apply must return a checkpoint whose value is an object.", operation);
|
|
459
|
+
}
|
|
460
|
+
return Object.freeze({
|
|
461
|
+
version: checkpoint.version,
|
|
462
|
+
value: Object.freeze({
|
|
463
|
+
...checkpoint.value,
|
|
464
|
+
[key]: Math.max(existingMarkerMs, now),
|
|
465
|
+
}),
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
function isWorldRevisionConflict(error) {
|
|
469
|
+
return isRecord(error) && error.code === "CAULDRON_WORLD_REVISION_CONFLICT";
|
|
470
|
+
}
|
|
471
|
+
function createServerWorldJournal(platformHandle) {
|
|
472
|
+
return Object.freeze({
|
|
473
|
+
async append(input) {
|
|
474
|
+
const operation = "session.world.journal.append";
|
|
475
|
+
assertOnlyKeys(input, ["operationId", "entries"], operation, "input");
|
|
476
|
+
const operationId = assertWorldJournalOperationId(input?.operationId, operation);
|
|
477
|
+
if (!Array.isArray(input?.entries) ||
|
|
478
|
+
input.entries.length < 1 ||
|
|
479
|
+
input.entries.length > WORLD_JOURNAL_MAX_ENTRIES) {
|
|
480
|
+
throw worldJournalArgumentError(`entries must contain from 1 through ${WORLD_JOURNAL_MAX_ENTRIES} entries.`, operation);
|
|
481
|
+
}
|
|
482
|
+
const entries = input.entries.map((entry, index) => normalizeWorldJournalAppendEntry(entry, index, operation));
|
|
483
|
+
const args = { operationId, entries };
|
|
484
|
+
if (utf8Length(JSON.stringify(args)) > WORLD_JOURNAL_MAX_REQUEST_BYTES) {
|
|
485
|
+
throw worldJournalArgumentError(`The serialized request may be at most ${WORLD_JOURNAL_MAX_REQUEST_BYTES} bytes.`, operation);
|
|
486
|
+
}
|
|
487
|
+
const result = parseWorldJournalJson(await callWorldJournalStorage(platformHandle, "worldJournalAppend", args, operation), operation);
|
|
488
|
+
if (!isRecord(result) || result.operationId !== operationId ||
|
|
489
|
+
typeof result.replayed !== "boolean") {
|
|
490
|
+
throw invalidWorldJournalResponse(operation);
|
|
282
491
|
}
|
|
283
492
|
return Object.freeze({
|
|
284
|
-
revision: assertWorldRevision(result.revision),
|
|
285
|
-
replayed: result.replayed,
|
|
286
493
|
operationId,
|
|
494
|
+
replayed: result.replayed,
|
|
495
|
+
firstSeq: parseWorldJournalSequence(result.firstSeq, operation, true),
|
|
496
|
+
lastSeq: parseWorldJournalSequence(result.lastSeq, operation, true),
|
|
497
|
+
});
|
|
498
|
+
},
|
|
499
|
+
async read(options = {}) {
|
|
500
|
+
const operation = "session.world.journal.read";
|
|
501
|
+
assertOnlyKeys(options, ["afterSeq", "limit", "participantId"], operation, "options");
|
|
502
|
+
const args = {};
|
|
503
|
+
if (options.afterSeq !== undefined) {
|
|
504
|
+
args.afterSeq = assertWorldJournalSequence(options.afterSeq, operation, "afterSeq");
|
|
505
|
+
}
|
|
506
|
+
if (options.limit !== undefined) {
|
|
507
|
+
args.limit = assertBoundedInteger(options.limit, 1, WORLD_JOURNAL_MAX_READ_LIMIT, "limit", operation);
|
|
508
|
+
}
|
|
509
|
+
if (options.participantId !== undefined) {
|
|
510
|
+
args.participantId = assertWorldParticipantId(options.participantId, "participantId", operation);
|
|
511
|
+
}
|
|
512
|
+
const result = parseWorldJournalJson(await callWorldJournalStorage(platformHandle, "worldJournalRead", args, operation), operation);
|
|
513
|
+
if (!isRecord(result) || !Array.isArray(result.entries) ||
|
|
514
|
+
result.entries.length > WORLD_JOURNAL_MAX_READ_LIMIT) {
|
|
515
|
+
throw invalidWorldJournalResponse(operation);
|
|
516
|
+
}
|
|
517
|
+
const entries = result.entries.map((entry) => parseWorldJournalEntry(entry, operation));
|
|
518
|
+
return Object.freeze({
|
|
519
|
+
entries: Object.freeze(entries),
|
|
520
|
+
nextSeq: parseWorldJournalSequence(result.nextSeq, operation),
|
|
521
|
+
oldestRetainedSeq: result.oldestRetainedSeq === null
|
|
522
|
+
? null
|
|
523
|
+
: parseWorldJournalSequence(result.oldestRetainedSeq, operation, true),
|
|
524
|
+
});
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
function createServerWorldParticipants(platformHandle) {
|
|
529
|
+
return Object.freeze({
|
|
530
|
+
async read(options = {}) {
|
|
531
|
+
const operation = "session.world.participants.read";
|
|
532
|
+
assertOnlyKeys(options, ["participantIds", "limit"], operation, "options");
|
|
533
|
+
const args = {};
|
|
534
|
+
if (options.participantIds !== undefined) {
|
|
535
|
+
args.participantIds = normalizeParticipantIds(options.participantIds, WORLD_JOURNAL_MAX_PARTICIPANT_FILTERS, "participantIds", operation);
|
|
536
|
+
}
|
|
537
|
+
if (options.limit !== undefined) {
|
|
538
|
+
args.limit = assertBoundedInteger(options.limit, 1, WORLD_PARTICIPANTS_MAX_READ_LIMIT, "limit", operation);
|
|
539
|
+
}
|
|
540
|
+
const result = parseWorldJournalJson(await callWorldJournalStorage(platformHandle, "worldParticipantsRead", args, operation), operation);
|
|
541
|
+
if (!isRecord(result) || !Array.isArray(result.participants) ||
|
|
542
|
+
result.participants.length > WORLD_PARTICIPANTS_MAX_READ_LIMIT) {
|
|
543
|
+
throw invalidWorldJournalResponse(operation);
|
|
544
|
+
}
|
|
545
|
+
return Object.freeze({
|
|
546
|
+
participants: Object.freeze(result.participants.map((participant) => parseWorldParticipant(participant, operation))),
|
|
287
547
|
});
|
|
288
548
|
},
|
|
289
549
|
});
|
|
290
550
|
}
|
|
551
|
+
function createServerWorldNotifications(platformHandle) {
|
|
552
|
+
return Object.freeze({
|
|
553
|
+
async notify(input) {
|
|
554
|
+
const operation = "session.world.notifications.notify";
|
|
555
|
+
assertOnlyKeys(input, ["key", "participantIds", "templateId", "slots", "at", "expiresAt"], operation, "input");
|
|
556
|
+
const now = Date.now();
|
|
557
|
+
const key = assertWorldNotificationSafeKey(input.key, "key", operation);
|
|
558
|
+
const participantIds = normalizeParticipantIds(input.participantIds, WORLD_NOTIFICATIONS_MAX_PARTICIPANTS, "participantIds", operation);
|
|
559
|
+
const templateId = assertWorldNotificationSafeKey(input.templateId, "templateId", operation);
|
|
560
|
+
const slots = input.slots === undefined
|
|
561
|
+
? undefined
|
|
562
|
+
: normalizeWorldNotificationSlots(input.slots, operation);
|
|
563
|
+
const at = input.at === undefined
|
|
564
|
+
? undefined
|
|
565
|
+
: assertWorldNotificationTime(input.at, "at", operation);
|
|
566
|
+
const atMs = at?.milliseconds ?? now;
|
|
567
|
+
if (atMs < now - WORLD_NOTIFICATIONS_PAST_TOLERANCE_MS ||
|
|
568
|
+
atMs > now + WORLD_NOTIFICATIONS_MAX_SCHEDULE_MS) {
|
|
569
|
+
throw worldJournalArgumentError("at must be no more than 60 seconds in the past and at most 30 days ahead.", operation);
|
|
570
|
+
}
|
|
571
|
+
// Ghost clamps a tolerated past `at` to its own now, so expiry is judged
|
|
572
|
+
// against that effective time, not the original timestamp.
|
|
573
|
+
const effectiveAtMs = Math.max(atMs, now);
|
|
574
|
+
const expiresAt = input.expiresAt === undefined
|
|
575
|
+
? undefined
|
|
576
|
+
: assertWorldNotificationTime(input.expiresAt, "expiresAt", operation);
|
|
577
|
+
if (expiresAt &&
|
|
578
|
+
(expiresAt.milliseconds <= effectiveAtMs ||
|
|
579
|
+
expiresAt.milliseconds > effectiveAtMs + WORLD_NOTIFICATIONS_MAX_SCHEDULE_MS)) {
|
|
580
|
+
throw worldJournalArgumentError("expiresAt must be after at and at most 30 days after it.", operation);
|
|
581
|
+
}
|
|
582
|
+
const args = {
|
|
583
|
+
key,
|
|
584
|
+
participantIds,
|
|
585
|
+
templateId,
|
|
586
|
+
...(slots === undefined ? {} : { slots }),
|
|
587
|
+
...(at === undefined ? {} : { at: at.value }),
|
|
588
|
+
...(expiresAt === undefined ? {} : { expiresAt: expiresAt.value }),
|
|
589
|
+
};
|
|
590
|
+
const result = parseWorldNotificationResult(await callWorldNotificationStorage(platformHandle, "worldNotify", args, operation), operation);
|
|
591
|
+
if (result.key !== key)
|
|
592
|
+
throw invalidWorldNotificationResponse(operation);
|
|
593
|
+
return Object.freeze({
|
|
594
|
+
key,
|
|
595
|
+
accepted: parseWorldNotificationCount(result.accepted, operation),
|
|
596
|
+
skipped: parseWorldNotificationCount(result.skipped, operation),
|
|
597
|
+
limited: parseWorldNotificationCount(result.limited, operation),
|
|
598
|
+
});
|
|
599
|
+
},
|
|
600
|
+
async cancel(input) {
|
|
601
|
+
const operation = "session.world.notifications.cancel";
|
|
602
|
+
assertOnlyKeys(input, ["key", "participantIds"], operation, "input");
|
|
603
|
+
const key = assertWorldNotificationSafeKey(input.key, "key", operation);
|
|
604
|
+
const participantIds = input.participantIds === undefined
|
|
605
|
+
? undefined
|
|
606
|
+
: normalizeParticipantIds(input.participantIds, WORLD_NOTIFICATIONS_MAX_PARTICIPANTS, "participantIds", operation);
|
|
607
|
+
const args = {
|
|
608
|
+
key,
|
|
609
|
+
...(participantIds === undefined ? {} : { participantIds }),
|
|
610
|
+
};
|
|
611
|
+
const result = parseWorldNotificationResult(await callWorldNotificationStorage(platformHandle, "worldNotifyCancel", args, operation), operation);
|
|
612
|
+
return Object.freeze({
|
|
613
|
+
cancelled: parseWorldNotificationCount(result.cancelled, operation),
|
|
614
|
+
});
|
|
615
|
+
},
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
function assertWorldNotificationSafeKey(value, field, operation) {
|
|
619
|
+
if (typeof value !== "string" ||
|
|
620
|
+
worldNotificationCharacters(value) < 1 ||
|
|
621
|
+
worldNotificationCharacters(value) > 64 ||
|
|
622
|
+
!SAFE_WORLD_NOTIFICATION_KEY.test(value)) {
|
|
623
|
+
throw worldJournalArgumentError(`${field} must be a safe key of at most 64 characters.`, operation);
|
|
624
|
+
}
|
|
625
|
+
return value;
|
|
626
|
+
}
|
|
627
|
+
function normalizeWorldNotificationSlots(value, operation) {
|
|
628
|
+
if (!isRecord(value)) {
|
|
629
|
+
throw worldJournalArgumentError("slots must be an object.", operation);
|
|
630
|
+
}
|
|
631
|
+
const entries = Object.entries(value);
|
|
632
|
+
if (entries.length > WORLD_NOTIFICATIONS_MAX_SLOTS) {
|
|
633
|
+
throw worldJournalArgumentError(`slots may contain at most ${WORLD_NOTIFICATIONS_MAX_SLOTS} values.`, operation);
|
|
634
|
+
}
|
|
635
|
+
const normalized = {};
|
|
636
|
+
for (const [key, slotValue] of entries.sort(([left], [right]) => left.localeCompare(right))) {
|
|
637
|
+
assertWorldNotificationSafeKey(key, `slots.${key}`, operation);
|
|
638
|
+
if (typeof slotValue !== "string" ||
|
|
639
|
+
worldNotificationCharacters(slotValue) < 1 ||
|
|
640
|
+
worldNotificationCharacters(slotValue) > WORLD_NOTIFICATIONS_MAX_SLOT_CHARACTERS ||
|
|
641
|
+
slotValue.includes("\0") ||
|
|
642
|
+
hasUnpairedSurrogate(slotValue) ||
|
|
643
|
+
slotValue.includes("\n") ||
|
|
644
|
+
slotValue.includes("\r") ||
|
|
645
|
+
URL_IN_WORLD_NOTIFICATION_TEXT.test(slotValue)) {
|
|
646
|
+
throw worldJournalArgumentError(`slots.${key} must be 1 through ${WORLD_NOTIFICATIONS_MAX_SLOT_CHARACTERS} ` +
|
|
647
|
+
"characters of single-line text without a URL.", operation);
|
|
648
|
+
}
|
|
649
|
+
normalized[key] = slotValue;
|
|
650
|
+
}
|
|
651
|
+
return Object.freeze(normalized);
|
|
652
|
+
}
|
|
653
|
+
function worldNotificationCharacters(value) {
|
|
654
|
+
return Array.from(value).length;
|
|
655
|
+
}
|
|
656
|
+
function assertWorldNotificationTime(value, field, operation) {
|
|
657
|
+
if (typeof value !== "string") {
|
|
658
|
+
throw worldJournalArgumentError(`${field} must be a canonical ISO 8601 time with at most millisecond precision.`, operation);
|
|
659
|
+
}
|
|
660
|
+
const match = CANONICAL_WORLD_NOTIFICATION_TIME.exec(value);
|
|
661
|
+
const milliseconds = Date.parse(value);
|
|
662
|
+
if (!match || !Number.isFinite(milliseconds)) {
|
|
663
|
+
throw worldJournalArgumentError(`${field} must be a canonical ISO 8601 time with at most millisecond precision.`, operation);
|
|
664
|
+
}
|
|
665
|
+
const normalized = new Date(milliseconds).toISOString();
|
|
666
|
+
const normalizedFraction = normalized.slice(20, 23);
|
|
667
|
+
if (normalized.slice(0, 19) !== match[1] ||
|
|
668
|
+
(match[2] === undefined
|
|
669
|
+
? normalizedFraction !== "000"
|
|
670
|
+
: normalizedFraction !== match[2].padEnd(3, "0"))) {
|
|
671
|
+
throw worldJournalArgumentError(`${field} must be a canonical ISO 8601 time with at most millisecond precision.`, operation);
|
|
672
|
+
}
|
|
673
|
+
return { value, milliseconds };
|
|
674
|
+
}
|
|
675
|
+
async function callWorldNotificationStorage(platformHandle, op, args, operation) {
|
|
676
|
+
if (typeof platformHandle.storage !== "function" || platformHandle.storageEnabled === false) {
|
|
677
|
+
throw worldNotificationsUnavailable(operation);
|
|
678
|
+
}
|
|
679
|
+
try {
|
|
680
|
+
return await platformHandle.storage(op, JSON.stringify(args));
|
|
681
|
+
}
|
|
682
|
+
catch (error) {
|
|
683
|
+
if (isUnknownStorageOperation(error))
|
|
684
|
+
throw worldNotificationsUnavailable(operation);
|
|
685
|
+
throw error;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
function worldNotificationsUnavailable(operation) {
|
|
689
|
+
return storageError("CAULDRON_WORLD_NOTIFICATIONS_UNAVAILABLE", "This Castle host does not support persistent-world notifications.", operation);
|
|
690
|
+
}
|
|
691
|
+
function parseWorldNotificationResult(value, operation) {
|
|
692
|
+
let result;
|
|
693
|
+
try {
|
|
694
|
+
result = JSON.parse(value);
|
|
695
|
+
}
|
|
696
|
+
catch {
|
|
697
|
+
throw invalidWorldNotificationResponse(operation);
|
|
698
|
+
}
|
|
699
|
+
if (!isRecord(result))
|
|
700
|
+
throw invalidWorldNotificationResponse(operation);
|
|
701
|
+
return result;
|
|
702
|
+
}
|
|
703
|
+
function parseWorldNotificationCount(value, operation) {
|
|
704
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
705
|
+
throw invalidWorldNotificationResponse(operation);
|
|
706
|
+
}
|
|
707
|
+
return value;
|
|
708
|
+
}
|
|
709
|
+
function invalidWorldNotificationResponse(operation) {
|
|
710
|
+
return new TypeError(`${operation}: The platform returned an invalid response.`);
|
|
711
|
+
}
|
|
712
|
+
function normalizeWorldJournalAppendEntry(value, index, operation) {
|
|
713
|
+
const field = `entries[${index}]`;
|
|
714
|
+
assertOnlyKeys(value, ["kind", "disclosure", "participantIds", "data"], operation, field);
|
|
715
|
+
const entry = value;
|
|
716
|
+
if (typeof entry.kind !== "string" ||
|
|
717
|
+
entry.kind.length < 1 ||
|
|
718
|
+
entry.kind.length > WORLD_JOURNAL_MAX_KIND_CHARACTERS ||
|
|
719
|
+
!SAFE_WORLD_JOURNAL_KIND.test(entry.kind)) {
|
|
720
|
+
throw worldJournalArgumentError(`${field}.kind must be a safe key of at most ${WORLD_JOURNAL_MAX_KIND_CHARACTERS} characters.`, operation);
|
|
721
|
+
}
|
|
722
|
+
if (entry.disclosure !== "world" && entry.disclosure !== "participants") {
|
|
723
|
+
throw worldJournalArgumentError(`${field}.disclosure must be "world" or "participants".`, operation);
|
|
724
|
+
}
|
|
725
|
+
let participantIds;
|
|
726
|
+
if (entry.disclosure === "participants") {
|
|
727
|
+
participantIds = normalizeParticipantIds(entry.participantIds, WORLD_JOURNAL_MAX_PARTICIPANTS_PER_ENTRY, `${field}.participantIds`, operation);
|
|
728
|
+
}
|
|
729
|
+
else if (Object.prototype.hasOwnProperty.call(entry, "participantIds")) {
|
|
730
|
+
throw worldJournalArgumentError(`${field}.participantIds must be absent when disclosure is "world".`, operation);
|
|
731
|
+
}
|
|
732
|
+
let data;
|
|
733
|
+
if (Object.prototype.hasOwnProperty.call(entry, "data")) {
|
|
734
|
+
data = normalizeWorldJournalData(entry.data, field, operation);
|
|
735
|
+
}
|
|
736
|
+
return Object.freeze({
|
|
737
|
+
kind: entry.kind,
|
|
738
|
+
disclosure: entry.disclosure,
|
|
739
|
+
...(participantIds === undefined ? {} : { participantIds }),
|
|
740
|
+
...(data === undefined ? {} : { data }),
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
function normalizeWorldJournalData(value, field, operation) {
|
|
744
|
+
let encoded;
|
|
745
|
+
try {
|
|
746
|
+
encoded = encodeStorageValue(value, operation);
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
throw worldJournalArgumentError(`${field}.data must be JSON.`, operation);
|
|
750
|
+
}
|
|
751
|
+
const normalized = canonicalizeJson(JSON.parse(encoded));
|
|
752
|
+
assertWorldJournalDataText(normalized, `${field}.data`, operation);
|
|
753
|
+
if (utf8Length(JSON.stringify(normalized)) > WORLD_JOURNAL_MAX_DATA_BYTES) {
|
|
754
|
+
throw worldJournalArgumentError(`${field}.data may be at most ${WORLD_JOURNAL_MAX_DATA_BYTES} bytes when serialized.`, operation);
|
|
755
|
+
}
|
|
756
|
+
return normalized;
|
|
757
|
+
}
|
|
758
|
+
function assertWorldJournalDataText(value, field, operation) {
|
|
759
|
+
if (typeof value === "string") {
|
|
760
|
+
if (value.includes("\0") || hasUnpairedSurrogate(value)) {
|
|
761
|
+
throw worldJournalArgumentError(`${field} must contain valid text.`, operation);
|
|
762
|
+
}
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
if (Array.isArray(value)) {
|
|
766
|
+
value.forEach((item, index) => assertWorldJournalDataText(item, `${field}[${index}]`, operation));
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
if (value !== null && typeof value === "object") {
|
|
770
|
+
for (const [key, item] of Object.entries(value)) {
|
|
771
|
+
if (key.includes("\0") || hasUnpairedSurrogate(key)) {
|
|
772
|
+
throw worldJournalArgumentError(`${field} must contain valid text.`, operation);
|
|
773
|
+
}
|
|
774
|
+
assertWorldJournalDataText(item, `${field}.${key}`, operation);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
function canonicalizeJson(value) {
|
|
779
|
+
if (Array.isArray(value))
|
|
780
|
+
return value.map(canonicalizeJson);
|
|
781
|
+
if (value !== null && typeof value === "object") {
|
|
782
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalizeJson(value[key])]));
|
|
783
|
+
}
|
|
784
|
+
return value;
|
|
785
|
+
}
|
|
786
|
+
function normalizeParticipantIds(value, max, field, operation) {
|
|
787
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > max) {
|
|
788
|
+
throw worldJournalArgumentError(`${field} must contain from 1 through ${max} ids.`, operation);
|
|
789
|
+
}
|
|
790
|
+
const ids = [...new Set(value.map((id, index) => assertWorldParticipantId(id, `${field}[${index}]`, operation)))].sort();
|
|
791
|
+
if (ids.length > max) {
|
|
792
|
+
throw worldJournalArgumentError(`${field} must contain from 1 through ${max} ids.`, operation);
|
|
793
|
+
}
|
|
794
|
+
return Object.freeze(ids);
|
|
795
|
+
}
|
|
796
|
+
function assertWorldParticipantId(value, field, operation) {
|
|
797
|
+
if (typeof value !== "string" || value.length === 0 ||
|
|
798
|
+
value.includes("\0") || hasUnpairedSurrogate(value) ||
|
|
799
|
+
utf8Length(value) > WORLD_JOURNAL_MAX_PARTICIPANT_BYTES) {
|
|
800
|
+
throw worldJournalArgumentError(`${field} must be valid non-empty text of at most ${WORLD_JOURNAL_MAX_PARTICIPANT_BYTES} bytes.`, operation);
|
|
801
|
+
}
|
|
802
|
+
return value;
|
|
803
|
+
}
|
|
804
|
+
function assertWorldJournalOperationId(value, operation) {
|
|
805
|
+
if (typeof value !== "string" ||
|
|
806
|
+
value.length < 1 ||
|
|
807
|
+
value.length > WORLD_OPERATION_ID_MAX_CHARACTERS ||
|
|
808
|
+
value.includes("\0") ||
|
|
809
|
+
hasUnpairedSurrogate(value) ||
|
|
810
|
+
utf8Length(value) > WORLD_OPERATION_ID_MAX_BYTES) {
|
|
811
|
+
throw worldJournalArgumentError(`operationId must be valid text of at most ${WORLD_OPERATION_ID_MAX_CHARACTERS} characters and ${WORLD_OPERATION_ID_MAX_BYTES} bytes.`, operation);
|
|
812
|
+
}
|
|
813
|
+
return value;
|
|
814
|
+
}
|
|
815
|
+
function assertWorldJournalSequence(value, operation, field, positive = false) {
|
|
816
|
+
if (!isWorldJournalSequence(value, positive)) {
|
|
817
|
+
throw worldJournalArgumentError(`${field} must be a canonical decimal string no greater than ${WORLD_JOURNAL_MAX_SEQUENCE}.`, operation);
|
|
818
|
+
}
|
|
819
|
+
return value;
|
|
820
|
+
}
|
|
821
|
+
function assertBoundedInteger(value, min, max, field, operation) {
|
|
822
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) {
|
|
823
|
+
throw worldJournalArgumentError(`${field} must be an integer from ${min} through ${max}.`, operation);
|
|
824
|
+
}
|
|
825
|
+
return value;
|
|
826
|
+
}
|
|
827
|
+
async function callWorldJournalStorage(platformHandle, op, args, operation) {
|
|
828
|
+
if (typeof platformHandle.storage !== "function" || platformHandle.storageEnabled === false) {
|
|
829
|
+
throw worldJournalUnavailable(operation);
|
|
830
|
+
}
|
|
831
|
+
try {
|
|
832
|
+
return await platformHandle.storage(op, JSON.stringify(args));
|
|
833
|
+
}
|
|
834
|
+
catch (error) {
|
|
835
|
+
if (isUnknownStorageOperation(error))
|
|
836
|
+
throw worldJournalUnavailable(operation);
|
|
837
|
+
throw error;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
function isUnknownStorageOperation(error) {
|
|
841
|
+
if (!isRecord(error) ||
|
|
842
|
+
(error.code !== "CAULDRON_STORAGE_INVALID_ARGUMENT" &&
|
|
843
|
+
error.code !== "CAULDRON_STORAGE_BAD_REQUEST"))
|
|
844
|
+
return false;
|
|
845
|
+
const message = error.message;
|
|
846
|
+
return typeof message === "string" && /unknown (?:storage )?operation/i.test(message);
|
|
847
|
+
}
|
|
848
|
+
function worldJournalUnavailable(operation) {
|
|
849
|
+
return storageError("CAULDRON_WORLD_JOURNAL_UNAVAILABLE", "This Castle host does not support persistent-world journals.", operation);
|
|
850
|
+
}
|
|
851
|
+
function worldJournalArgumentError(message, operation) {
|
|
852
|
+
return storageError("INVALID_ARGUMENT", message, operation);
|
|
853
|
+
}
|
|
854
|
+
function parseWorldJournalJson(value, operation) {
|
|
855
|
+
try {
|
|
856
|
+
return JSON.parse(value);
|
|
857
|
+
}
|
|
858
|
+
catch {
|
|
859
|
+
throw invalidWorldJournalResponse(operation);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
function invalidWorldJournalResponse(operation) {
|
|
863
|
+
return new TypeError(`${operation}: The platform returned an invalid response.`);
|
|
864
|
+
}
|
|
865
|
+
function parseWorldJournalEntry(value, operation) {
|
|
866
|
+
if (!isRecord(value) ||
|
|
867
|
+
(value.disclosure !== "world" && value.disclosure !== "participants") ||
|
|
868
|
+
!Array.isArray(value.participantIds) ||
|
|
869
|
+
typeof value.kind !== "string" ||
|
|
870
|
+
typeof value.createdAt !== "string") {
|
|
871
|
+
throw invalidWorldJournalResponse(operation);
|
|
872
|
+
}
|
|
873
|
+
const participantIds = value.participantIds.map((id) => {
|
|
874
|
+
if (typeof id !== "string")
|
|
875
|
+
throw invalidWorldJournalResponse(operation);
|
|
876
|
+
return id;
|
|
877
|
+
});
|
|
878
|
+
if ((value.disclosure === "world" && participantIds.length !== 0) ||
|
|
879
|
+
(value.disclosure === "participants" && participantIds.length < 1)) {
|
|
880
|
+
throw invalidWorldJournalResponse(operation);
|
|
881
|
+
}
|
|
882
|
+
let data;
|
|
883
|
+
try {
|
|
884
|
+
data = JSON.parse(encodeStorageValue(value.data, operation));
|
|
885
|
+
}
|
|
886
|
+
catch {
|
|
887
|
+
throw invalidWorldJournalResponse(operation);
|
|
888
|
+
}
|
|
889
|
+
return Object.freeze({
|
|
890
|
+
seq: parseWorldJournalSequence(value.seq, operation, true),
|
|
891
|
+
kind: value.kind,
|
|
892
|
+
disclosure: value.disclosure,
|
|
893
|
+
participantIds: Object.freeze(participantIds),
|
|
894
|
+
data,
|
|
895
|
+
createdAt: value.createdAt,
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
function parseWorldJournalSequence(value, operation, positive = false) {
|
|
899
|
+
if (!isWorldJournalSequence(value, positive)) {
|
|
900
|
+
throw invalidWorldJournalResponse(operation);
|
|
901
|
+
}
|
|
902
|
+
return value;
|
|
903
|
+
}
|
|
904
|
+
function isWorldJournalSequence(value, positive) {
|
|
905
|
+
if (typeof value !== "string" || !/^(?:0|[1-9][0-9]*)$/.test(value))
|
|
906
|
+
return false;
|
|
907
|
+
if (positive && value === "0")
|
|
908
|
+
return false;
|
|
909
|
+
return value.length < WORLD_JOURNAL_MAX_SEQUENCE.length ||
|
|
910
|
+
(value.length === WORLD_JOURNAL_MAX_SEQUENCE.length &&
|
|
911
|
+
value <= WORLD_JOURNAL_MAX_SEQUENCE);
|
|
912
|
+
}
|
|
913
|
+
function parseWorldParticipant(value, operation) {
|
|
914
|
+
if (!isRecord(value) || typeof value.participantId !== "string" ||
|
|
915
|
+
typeof value.firstSeenAt !== "string" || typeof value.lastSeenAt !== "string" ||
|
|
916
|
+
(value.lastLeftAt !== null && typeof value.lastLeftAt !== "string") ||
|
|
917
|
+
!Number.isSafeInteger(value.visitCount) || value.visitCount < 0) {
|
|
918
|
+
throw invalidWorldJournalResponse(operation);
|
|
919
|
+
}
|
|
920
|
+
return Object.freeze({
|
|
921
|
+
participantId: value.participantId,
|
|
922
|
+
firstSeenAt: value.firstSeenAt,
|
|
923
|
+
lastSeenAt: value.lastSeenAt,
|
|
924
|
+
lastLeftAt: value.lastLeftAt,
|
|
925
|
+
visitCount: value.visitCount,
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
function assertOnlyKeys(value, allowed, operation, field) {
|
|
929
|
+
if (!isRecord(value) || Object.keys(value).some((key) => !allowed.includes(key))) {
|
|
930
|
+
throw worldJournalArgumentError(`${field} contains an unsupported field.`, operation);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
function isRecord(value) {
|
|
934
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
935
|
+
}
|
|
936
|
+
function hasUnpairedSurrogate(value) {
|
|
937
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
938
|
+
const code = value.charCodeAt(index);
|
|
939
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
940
|
+
const next = value.charCodeAt(index + 1);
|
|
941
|
+
if (!(next >= 0xdc00 && next <= 0xdfff))
|
|
942
|
+
return true;
|
|
943
|
+
index += 1;
|
|
944
|
+
}
|
|
945
|
+
else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
946
|
+
return true;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
return false;
|
|
950
|
+
}
|
|
951
|
+
function utf8Length(value) {
|
|
952
|
+
return new TextEncoder().encode(value).byteLength;
|
|
953
|
+
}
|
|
291
954
|
function createServerWorldCharacters(platformHandle) {
|
|
292
955
|
return Object.freeze({
|
|
293
956
|
async create(input) {
|
|
@@ -637,15 +1300,59 @@ function requireBroadcastBinary(platformHandle) {
|
|
|
637
1300
|
function unsupportedBinaryRuntime() {
|
|
638
1301
|
throw new TypeError("This Castle runtime does not support binary multiplayer messages yet.");
|
|
639
1302
|
}
|
|
1303
|
+
// The compatibility branch accepts the current Node shim's legacy
|
|
1304
|
+
// single-argument SessionPlayer event while the public handle contract uses the
|
|
1305
|
+
// runtime-agnostic (connId, trusted identity) pair. Either form may carry the hub
|
|
1306
|
+
// arrival after the player and the player's A/B arms after that, and a hub
|
|
1307
|
+
// player's block list on the player itself.
|
|
1308
|
+
function admitPlayer(state, connId, identity, handleArrival, handleVariants) {
|
|
1309
|
+
const player = joinedPlayer(connId, identity);
|
|
1310
|
+
const arrival = hubArrival(typeof connId === "string" ? handleArrival : identity);
|
|
1311
|
+
const known = experimentVariants(typeof connId === "string" ? handleVariants : handleArrival);
|
|
1312
|
+
state.players.set(player.playerId, player);
|
|
1313
|
+
if (known) {
|
|
1314
|
+
state.variants.set(player.playerId, known);
|
|
1315
|
+
}
|
|
1316
|
+
else {
|
|
1317
|
+
state.variants.delete(player.playerId);
|
|
1318
|
+
}
|
|
1319
|
+
recordHiddenUserIds(state.hidden, player.playerId, typeof connId === "string" ? identity : connId);
|
|
1320
|
+
if (arrival) {
|
|
1321
|
+
state.arrivals.set(player.playerId, arrival);
|
|
1322
|
+
}
|
|
1323
|
+
else {
|
|
1324
|
+
state.arrivals.delete(player.playerId);
|
|
1325
|
+
}
|
|
1326
|
+
return { player, arrival };
|
|
1327
|
+
}
|
|
1328
|
+
// Absent on a host that predates experiments, and for a player who is in none.
|
|
1329
|
+
function experimentVariants(value) {
|
|
1330
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1331
|
+
return null;
|
|
1332
|
+
const entries = Object.entries(value).filter((entry) => typeof entry[1] === "string");
|
|
1333
|
+
return entries.length > 0 ? Object.freeze(Object.fromEntries(entries)) : null;
|
|
1334
|
+
}
|
|
640
1335
|
function joinedPlayer(connId, identity) {
|
|
641
|
-
if (typeof connId === "string")
|
|
642
|
-
|
|
1336
|
+
if (typeof connId === "string") {
|
|
1337
|
+
const player = {
|
|
1338
|
+
...identity,
|
|
1339
|
+
playerId: connId,
|
|
1340
|
+
participantId: identity.scopedUserId ?? identity.userId,
|
|
1341
|
+
};
|
|
1342
|
+
// A hub player's block list stays in the wrapper (`session.hub.hiddenPair`):
|
|
1343
|
+
// decks broadcast player objects.
|
|
1344
|
+
delete player.hiddenUserIds;
|
|
1345
|
+
// A/B arms arrive beside the player; one a host put on it stays off it too.
|
|
1346
|
+
delete player.experimentVariants;
|
|
1347
|
+
return player;
|
|
1348
|
+
}
|
|
643
1349
|
const legacy = connId;
|
|
644
1350
|
return {
|
|
645
1351
|
playerId: legacy.playerId,
|
|
646
1352
|
userId: legacy.userId,
|
|
647
1353
|
username: legacy.username,
|
|
648
1354
|
isAnonymous: legacy.isAnonymous,
|
|
1355
|
+
participantId: legacy.scopedUserId ?? legacy.userId,
|
|
649
1356
|
...(legacy.scopedUserId ? { scopedUserId: legacy.scopedUserId } : {}),
|
|
650
1357
|
};
|
|
651
1358
|
}
|