castle-web-sdk 0.4.45 → 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 +449 -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 +223 -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 +86 -6
- package/dist/multiplayer.js +209 -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 +49 -2
- package/dist/server/storage.d.ts +58 -4
- package/dist/server/storage.js +25 -5
- package/dist/server/wrapper.d.ts +89 -6
- package/dist/server/wrapper.js +395 -30
- 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
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,19 +13,23 @@ 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);
|
|
30
|
+
arrivals.delete(connId);
|
|
31
|
+
hidden.delete(connId);
|
|
32
|
+
variants.delete(connId);
|
|
26
33
|
if (player) {
|
|
27
34
|
await callbacks.onPlayerLeave?.(session, player);
|
|
28
35
|
}
|
|
@@ -103,6 +110,7 @@ async function bootSession(platformHandle, callbacks) {
|
|
|
103
110
|
let settled = false;
|
|
104
111
|
const brokerFailure = { rejection: null };
|
|
105
112
|
const command = Object.freeze({
|
|
113
|
+
type: request.command.type,
|
|
106
114
|
requestId: request.requestId,
|
|
107
115
|
operationId: request.operationId,
|
|
108
116
|
speaker: Object.freeze(joinedPlayer(request.speaker.playerId, request.speaker)),
|
|
@@ -202,7 +210,22 @@ async function bootSession(platformHandle, callbacks) {
|
|
|
202
210
|
}, 1_000 / platformHandle.config.tickRate);
|
|
203
211
|
}
|
|
204
212
|
}
|
|
205
|
-
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);
|
|
206
229
|
return {
|
|
207
230
|
get players() {
|
|
208
231
|
return Array.from(players.values());
|
|
@@ -210,8 +233,20 @@ function createAuthorSession(platformHandle, players) {
|
|
|
210
233
|
sessionId: platformHandle.config.sessionId,
|
|
211
234
|
deckId: platformHandle.config.deckId,
|
|
212
235
|
mode: platformHandle.config.mode,
|
|
236
|
+
place: typeof platformHandle.config.place === "string"
|
|
237
|
+
? platformHandle.config.place
|
|
238
|
+
: hub?.place ?? null,
|
|
213
239
|
storage: createServerStorage(platformHandle),
|
|
214
|
-
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
|
+
},
|
|
215
250
|
send(playerId, data) {
|
|
216
251
|
if (isBinaryPayload(data)) {
|
|
217
252
|
requireSendBinary(platformHandle);
|
|
@@ -264,6 +299,24 @@ const WORLD_JOURNAL_MAX_READ_LIMIT = 128;
|
|
|
264
299
|
const WORLD_PARTICIPANTS_MAX_READ_LIMIT = 256;
|
|
265
300
|
const WORLD_JOURNAL_MAX_SEQUENCE = "9223372036854775807";
|
|
266
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
|
+
}
|
|
267
320
|
function createServerWorld(platformHandle) {
|
|
268
321
|
if (!platformHandle.config.world)
|
|
269
322
|
return null;
|
|
@@ -272,36 +325,149 @@ function createServerWorld(platformHandle) {
|
|
|
272
325
|
throw worldError("The persistent-world checkpoint broker is unavailable.", "read");
|
|
273
326
|
}
|
|
274
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
|
+
};
|
|
275
351
|
return Object.freeze({
|
|
276
352
|
snapshot: initial,
|
|
277
353
|
journal: createServerWorldJournal(platformHandle),
|
|
278
354
|
participants: createServerWorldParticipants(platformHandle),
|
|
355
|
+
notifications: createServerWorldNotifications(platformHandle),
|
|
279
356
|
characters: createServerWorldCharacters(platformHandle),
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
357
|
+
read,
|
|
358
|
+
commit,
|
|
359
|
+
async catchUp(options) {
|
|
360
|
+
return catchUpWorld(platformHandle.config.world?.worldTime, read, commit, options);
|
|
283
361
|
},
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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 });
|
|
296
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}`,
|
|
417
|
+
checkpoint,
|
|
418
|
+
});
|
|
297
419
|
return Object.freeze({
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
420
|
+
applied: !receipt.replayed,
|
|
421
|
+
elapsedMs: state.elapsedMs,
|
|
422
|
+
revision: receipt.revision,
|
|
301
423
|
});
|
|
302
|
-
}
|
|
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
|
+
}),
|
|
303
466
|
});
|
|
304
467
|
}
|
|
468
|
+
function isWorldRevisionConflict(error) {
|
|
469
|
+
return isRecord(error) && error.code === "CAULDRON_WORLD_REVISION_CONFLICT";
|
|
470
|
+
}
|
|
305
471
|
function createServerWorldJournal(platformHandle) {
|
|
306
472
|
return Object.freeze({
|
|
307
473
|
async append(input) {
|
|
@@ -382,6 +548,167 @@ function createServerWorldParticipants(platformHandle) {
|
|
|
382
548
|
},
|
|
383
549
|
});
|
|
384
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
|
+
}
|
|
385
712
|
function normalizeWorldJournalAppendEntry(value, index, operation) {
|
|
386
713
|
const field = `entries[${index}]`;
|
|
387
714
|
assertOnlyKeys(value, ["kind", "disclosure", "participantIds", "data"], operation, field);
|
|
@@ -973,13 +1300,51 @@ function requireBroadcastBinary(platformHandle) {
|
|
|
973
1300
|
function unsupportedBinaryRuntime() {
|
|
974
1301
|
throw new TypeError("This Castle runtime does not support binary multiplayer messages yet.");
|
|
975
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
|
+
}
|
|
976
1335
|
function joinedPlayer(connId, identity) {
|
|
977
1336
|
if (typeof connId === "string") {
|
|
978
|
-
|
|
1337
|
+
const player = {
|
|
979
1338
|
...identity,
|
|
980
1339
|
playerId: connId,
|
|
981
1340
|
participantId: identity.scopedUserId ?? identity.userId,
|
|
982
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;
|
|
983
1348
|
}
|
|
984
1349
|
const legacy = connId;
|
|
985
1350
|
return {
|
package/dist/store.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { StoreAllowance } from "./commands";
|
|
2
2
|
import type { Json } from "./types";
|
|
3
|
+
import { type SaveApi, type SaveBucketApi } from "./save";
|
|
3
4
|
export interface StoreListOptions {
|
|
4
5
|
/** Only keys beginning with this. Omit for every key in the scope. */
|
|
5
6
|
prefix?: string;
|
|
@@ -21,7 +22,7 @@ export interface StoreListPage<T extends Json = Json> {
|
|
|
21
22
|
*/
|
|
22
23
|
cursor: string | null;
|
|
23
24
|
}
|
|
24
|
-
export interface StoreScopeApi {
|
|
25
|
+
export interface StoreScopeApi extends SaveBucketApi {
|
|
25
26
|
/** Reads keys. Keys with no value are absent from the result. */
|
|
26
27
|
get<T extends Json = Json>(keys: string[]): Promise<Record<string, T>>;
|
|
27
28
|
/**
|
|
@@ -63,7 +64,7 @@ export interface StoreUsage {
|
|
|
63
64
|
/** `Store.deck`: one allowance shared by every player of the deck. */
|
|
64
65
|
shared: StoreAllowance;
|
|
65
66
|
}
|
|
66
|
-
export interface CastleStoreApi {
|
|
67
|
+
export interface CastleStoreApi extends SaveApi {
|
|
67
68
|
/** Shared by everyone playing the deck. */
|
|
68
69
|
readonly deck: StoreScopeApi;
|
|
69
70
|
/** The current player's data, which other players can read. */
|
package/dist/store.js
CHANGED
|
@@ -2,6 +2,7 @@ import { chunked } from "./chunk";
|
|
|
2
2
|
import { decodeStorageValue, encodeStorageValue, storageError } from "./storageJson";
|
|
3
3
|
import { hostRequest } from "./transport";
|
|
4
4
|
import { onUnloadFlush } from "./unloadFlush";
|
|
5
|
+
import { createSaveApi, createSaveBucket, } from "./save";
|
|
5
6
|
// Per-key deck storage for Cauldron decks.
|
|
6
7
|
//
|
|
7
8
|
// The older Storage/SharedStorage keep one document per bucket and rewrite the
|
|
@@ -212,6 +213,7 @@ function assertKeys(keys, operation) {
|
|
|
212
213
|
}
|
|
213
214
|
function createScope(scope, subject) {
|
|
214
215
|
return {
|
|
216
|
+
...createSaveBucket(saveApi, scope, subject ?? undefined),
|
|
215
217
|
async get(keys) {
|
|
216
218
|
const { entries } = await hostRequest("cauldronStorage.get", {
|
|
217
219
|
scope,
|
|
@@ -309,7 +311,10 @@ async function usage() {
|
|
|
309
311
|
const { usage: allowances } = await hostRequest("cauldronStorage.usage", {});
|
|
310
312
|
return { player: allowances.player, shared: allowances.shared };
|
|
311
313
|
}
|
|
314
|
+
// Saves skip the write coalescing above: each is one request, sent at once.
|
|
315
|
+
const saveApi = createSaveApi((verb, input) => hostRequest(`cauldronStorage.${verb}`, input));
|
|
312
316
|
export const Store = {
|
|
317
|
+
...saveApi,
|
|
313
318
|
deck: createScope("deck", null),
|
|
314
319
|
me: createScope("user", null),
|
|
315
320
|
user(userId) {
|
package/dist/transport.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type CommandName, type CommandParams, type CommandResult, type LifecycleEvent, type VoiceEvent } from "./commands";
|
|
1
|
+
import { type CommandName, type CommandParams, type CommandResult, type HubEvent, type LifecycleEvent, type VoiceEvent } from "./commands";
|
|
2
2
|
type PostChannel = "mobile" | "web";
|
|
3
3
|
export interface HostStreamOptions {
|
|
4
4
|
onStream?: (event: unknown) => void;
|
|
@@ -21,5 +21,6 @@ export declare function hostNotify(event: LifecycleEvent): void;
|
|
|
21
21
|
export declare function postUnloadDone(unloadId: string, state?: string): void;
|
|
22
22
|
export declare function setUnloadHandler(handler: (unloadId: string) => void): void;
|
|
23
23
|
export declare function setVoiceEventHandler(handler: ((event: VoiceEvent) => void) | null): void;
|
|
24
|
+
export declare function setHubEventHandler(handler: ((event: HubEvent) => void) | null): void;
|
|
24
25
|
export declare function installHostListener(): void;
|
|
25
26
|
export {};
|
package/dist/transport.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// A streaming command (agent.chat) gets stream envelopes on its requestId before
|
|
12
12
|
// its response. Each one goes to the caller's onStream and restarts the wait,
|
|
13
13
|
// and the caller's AbortSignal sends the host a cancel envelope.
|
|
14
|
-
import { CASTLE_SDK_PROTOCOL, COMMAND_TIMEOUTS_MS, STREAM_IDLE_TIMEOUT_MS, isResponseEnvelope, isStreamEnvelope, unloadRequestId, voiceEventFrom, } from "./commands";
|
|
14
|
+
import { CASTLE_SDK_PROTOCOL, COMMAND_TIMEOUTS_MS, STREAM_IDLE_TIMEOUT_MS, isResponseEnvelope, isStreamEnvelope, hubEventFrom, unloadRequestId, voiceEventFrom, } from "./commands";
|
|
15
15
|
import { getCastleEmbed } from "./context";
|
|
16
16
|
import { CastleError } from "./errors";
|
|
17
17
|
import { cancelLocalCommand, releaseLocalCommand, sendLocalCommand } from "./runtime";
|
|
@@ -35,6 +35,7 @@ const pending = new Map();
|
|
|
35
35
|
let listenersInstalled = false;
|
|
36
36
|
let unloadHandler = null;
|
|
37
37
|
let voiceEventHandler = null;
|
|
38
|
+
let hubEventHandler = null;
|
|
38
39
|
export async function hostRequest(command, params, stream = {}) {
|
|
39
40
|
try {
|
|
40
41
|
const env = await sendCommand(resolveChannel(), command, params, stream);
|
|
@@ -103,6 +104,12 @@ export function setVoiceEventHandler(handler) {
|
|
|
103
104
|
if (handler)
|
|
104
105
|
installHostListener();
|
|
105
106
|
}
|
|
107
|
+
// Registered by hub.ts while the deck listens for the host's Back control.
|
|
108
|
+
export function setHubEventHandler(handler) {
|
|
109
|
+
hubEventHandler = handler;
|
|
110
|
+
if (handler)
|
|
111
|
+
installHostListener();
|
|
112
|
+
}
|
|
106
113
|
function resolveChannel() {
|
|
107
114
|
if (typeof window === "undefined")
|
|
108
115
|
return "local";
|
|
@@ -233,6 +240,11 @@ function receive(message) {
|
|
|
233
240
|
voiceEventHandler?.(voiceEvent);
|
|
234
241
|
return;
|
|
235
242
|
}
|
|
243
|
+
const hubEvent = hubEventFrom(message);
|
|
244
|
+
if (hubEvent) {
|
|
245
|
+
hubEventHandler?.(hubEvent);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
236
248
|
const unloadId = unloadRequestId(message);
|
|
237
249
|
if (unloadId !== null)
|
|
238
250
|
unloadHandler?.(unloadId);
|