castle-web-sdk 0.4.45 → 0.4.47
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 +526 -53
- package/dist/agent.js +4 -4
- package/dist/avatar.d.ts +16 -0
- package/dist/avatar.js +31 -0
- package/dist/castle.d.ts +10 -1
- package/dist/castle.js +5 -0
- package/dist/commands.d.ts +237 -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 +89 -0
- package/dist/hub.js +120 -0
- package/dist/multiplayer.d.ts +86 -6
- package/dist/multiplayer.js +226 -9
- package/dist/save.d.ts +197 -0
- package/dist/save.js +526 -0
- package/dist/saveCoordinator.d.ts +39 -0
- package/dist/saveCoordinator.js +252 -0
- package/dist/server/hub.d.ts +256 -0
- package/dist/server/hub.js +195 -0
- package/dist/server/platformHandle.d.ts +80 -3
- package/dist/server/storage.d.ts +71 -4
- package/dist/server/storage.js +71 -5
- package/dist/server/wrapper.d.ts +123 -9
- package/dist/server/wrapper.js +478 -38
- 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
|
}
|
|
@@ -62,18 +69,15 @@ async function bootSession(platformHandle, callbacks) {
|
|
|
62
69
|
players: Object.freeze(request.audience.players.map((player) => Object.freeze(joinedPlayer(player.playerId, player)))),
|
|
63
70
|
characterIds: Object.freeze([...request.audience.characterIds]),
|
|
64
71
|
}),
|
|
65
|
-
async send() {
|
|
72
|
+
async send(options) {
|
|
66
73
|
if (settled)
|
|
67
74
|
throw new TypeError("This shared character request is already settled.");
|
|
68
75
|
settled = true;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
rawReceipt = await platformHandle.worldCharacterSend(request.grantId);
|
|
72
|
-
}
|
|
73
|
-
catch (error) {
|
|
76
|
+
const rawReceipt = await sendWorldCharacterTurn(platformHandle, request.grantId, options)
|
|
77
|
+
.catch((error) => {
|
|
74
78
|
brokerFailure.rejection = { error };
|
|
75
79
|
throw error;
|
|
76
|
-
}
|
|
80
|
+
});
|
|
77
81
|
return parseWorldCharacterAdmission(rawReceipt, request.operationId);
|
|
78
82
|
},
|
|
79
83
|
reject(code) {
|
|
@@ -96,6 +100,7 @@ async function bootSession(platformHandle, callbacks) {
|
|
|
96
100
|
}
|
|
97
101
|
});
|
|
98
102
|
}
|
|
103
|
+
listenForWorldCharacterTools(platformHandle, callbacks, session);
|
|
99
104
|
if (callbacks.onWorldCharacterCommand &&
|
|
100
105
|
hasWorldCharacterPlatform(platformHandle, "worldCharacterCommand")) {
|
|
101
106
|
platformHandle.on("worldCharacterCommand", async (raw) => {
|
|
@@ -103,6 +108,7 @@ async function bootSession(platformHandle, callbacks) {
|
|
|
103
108
|
let settled = false;
|
|
104
109
|
const brokerFailure = { rejection: null };
|
|
105
110
|
const command = Object.freeze({
|
|
111
|
+
type: request.command.type,
|
|
106
112
|
requestId: request.requestId,
|
|
107
113
|
operationId: request.operationId,
|
|
108
114
|
speaker: Object.freeze(joinedPlayer(request.speaker.playerId, request.speaker)),
|
|
@@ -202,7 +208,22 @@ async function bootSession(platformHandle, callbacks) {
|
|
|
202
208
|
}, 1_000 / platformHandle.config.tickRate);
|
|
203
209
|
}
|
|
204
210
|
}
|
|
205
|
-
function createAuthorSession(platformHandle, players) {
|
|
211
|
+
function createAuthorSession(platformHandle, players, arrivals, hidden, variants) {
|
|
212
|
+
// Lenient about what it is handed: an unknown player is in no experiment.
|
|
213
|
+
const variantsOf = (player) => variants.get(typeof player === "string"
|
|
214
|
+
? player
|
|
215
|
+
: (player?.playerId ?? ""));
|
|
216
|
+
// The pinned place counts only where the host services the hub ops.
|
|
217
|
+
const hub = createServerHub(platformHandle, arrivals, (a, b) => {
|
|
218
|
+
if (hidden.size === 0 || a === b)
|
|
219
|
+
return false;
|
|
220
|
+
const userA = players.get(a)?.userId;
|
|
221
|
+
const userB = players.get(b)?.userId;
|
|
222
|
+
if (!userA || !userB)
|
|
223
|
+
return false;
|
|
224
|
+
return Boolean(hidden.get(a)?.has(userB) || hidden.get(b)?.has(userA));
|
|
225
|
+
});
|
|
226
|
+
const world = createServerWorld(platformHandle);
|
|
206
227
|
return {
|
|
207
228
|
get players() {
|
|
208
229
|
return Array.from(players.values());
|
|
@@ -210,8 +231,20 @@ function createAuthorSession(platformHandle, players) {
|
|
|
210
231
|
sessionId: platformHandle.config.sessionId,
|
|
211
232
|
deckId: platformHandle.config.deckId,
|
|
212
233
|
mode: platformHandle.config.mode,
|
|
234
|
+
place: typeof platformHandle.config.place === "string"
|
|
235
|
+
? platformHandle.config.place
|
|
236
|
+
: hub?.place ?? null,
|
|
213
237
|
storage: createServerStorage(platformHandle),
|
|
214
|
-
world
|
|
238
|
+
world,
|
|
239
|
+
characters: world?.characters ?? createPlaceCharacters(platformHandle),
|
|
240
|
+
hub,
|
|
241
|
+
experiment(player, name) {
|
|
242
|
+
const known = variantsOf(player);
|
|
243
|
+
return known && Object.hasOwn(known, name) ? known[name] : null;
|
|
244
|
+
},
|
|
245
|
+
experiments(player) {
|
|
246
|
+
return { ...variantsOf(player) };
|
|
247
|
+
},
|
|
215
248
|
send(playerId, data) {
|
|
216
249
|
if (isBinaryPayload(data)) {
|
|
217
250
|
requireSendBinary(platformHandle);
|
|
@@ -264,6 +297,24 @@ const WORLD_JOURNAL_MAX_READ_LIMIT = 128;
|
|
|
264
297
|
const WORLD_PARTICIPANTS_MAX_READ_LIMIT = 256;
|
|
265
298
|
const WORLD_JOURNAL_MAX_SEQUENCE = "9223372036854775807";
|
|
266
299
|
const SAFE_WORLD_JOURNAL_KIND = /^[A-Za-z0-9_.:-]+$/;
|
|
300
|
+
const WORLD_NOTIFICATIONS_MAX_PARTICIPANTS = 16;
|
|
301
|
+
const WORLD_NOTIFICATIONS_MAX_SLOTS = 4;
|
|
302
|
+
const WORLD_NOTIFICATIONS_MAX_SLOT_CHARACTERS = 40;
|
|
303
|
+
const WORLD_NOTIFICATIONS_MAX_SCHEDULE_MS = 30 * 24 * 60 * 60 * 1_000;
|
|
304
|
+
const WORLD_NOTIFICATIONS_PAST_TOLERANCE_MS = 60 * 1_000;
|
|
305
|
+
const WORLD_CATCH_UP_DEFAULT_MAX_ELAPSED_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
306
|
+
const SAFE_WORLD_NOTIFICATION_KEY = /^[A-Za-z0-9_-]+$/;
|
|
307
|
+
const CANONICAL_WORLD_NOTIFICATION_TIME = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,3}))?Z$/;
|
|
308
|
+
const URL_IN_WORLD_NOTIFICATION_TEXT = /(?:https?:\/\/|www\.|\b[a-z0-9-]+\.(?:app|com|dev|gg|io|net|org|xyz)\b)/i;
|
|
309
|
+
// A hub place's layer has no world of its own, but the host may still broker
|
|
310
|
+
// the place's shared characters into it (`placeCharacters` in the runtime
|
|
311
|
+
// init). The character API is the world one; only the binding differs, and the
|
|
312
|
+
// host stamps that.
|
|
313
|
+
function createPlaceCharacters(platformHandle) {
|
|
314
|
+
if (platformHandle.config.placeCharacters !== true)
|
|
315
|
+
return null;
|
|
316
|
+
return createServerWorldCharacters(platformHandle);
|
|
317
|
+
}
|
|
267
318
|
function createServerWorld(platformHandle) {
|
|
268
319
|
if (!platformHandle.config.world)
|
|
269
320
|
return null;
|
|
@@ -272,36 +323,149 @@ function createServerWorld(platformHandle) {
|
|
|
272
323
|
throw worldError("The persistent-world checkpoint broker is unavailable.", "read");
|
|
273
324
|
}
|
|
274
325
|
const initial = parseWorldSnapshot(platformHandle.config.world, "read");
|
|
326
|
+
const read = async () => {
|
|
327
|
+
const result = await platformHandle.worldCheckpointRead();
|
|
328
|
+
return parseWorldSnapshot(parsePlatformJson(result, "read"), "read");
|
|
329
|
+
};
|
|
330
|
+
const commit = async (input) => {
|
|
331
|
+
const expectedRevision = assertWorldRevision(input?.expectedRevision);
|
|
332
|
+
const operationId = assertWorldOperationId(input?.operationId);
|
|
333
|
+
const checkpoint = assertWorldCheckpoint(input?.checkpoint);
|
|
334
|
+
const raw = await platformHandle.worldCheckpointCommit(JSON.stringify({
|
|
335
|
+
expectedRevision,
|
|
336
|
+
operationId,
|
|
337
|
+
checkpoint,
|
|
338
|
+
}));
|
|
339
|
+
const result = parsePlatformJson(raw, "commit");
|
|
340
|
+
if (typeof result.replayed !== "boolean") {
|
|
341
|
+
throw worldError("The platform returned an invalid commit receipt.", "commit");
|
|
342
|
+
}
|
|
343
|
+
return Object.freeze({
|
|
344
|
+
revision: assertWorldRevision(result.revision),
|
|
345
|
+
replayed: result.replayed,
|
|
346
|
+
operationId,
|
|
347
|
+
});
|
|
348
|
+
};
|
|
275
349
|
return Object.freeze({
|
|
276
350
|
snapshot: initial,
|
|
277
351
|
journal: createServerWorldJournal(platformHandle),
|
|
278
352
|
participants: createServerWorldParticipants(platformHandle),
|
|
353
|
+
notifications: createServerWorldNotifications(platformHandle),
|
|
279
354
|
characters: createServerWorldCharacters(platformHandle),
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
355
|
+
read,
|
|
356
|
+
commit,
|
|
357
|
+
async catchUp(options) {
|
|
358
|
+
return catchUpWorld(platformHandle.config.world?.worldTime, read, commit, options);
|
|
283
359
|
},
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
async function catchUpWorld(worldTime, read, commit, options) {
|
|
363
|
+
const operation = "session.world.catchUp";
|
|
364
|
+
if (worldTime?.advance !== "wallClock") {
|
|
365
|
+
throw worldJournalArgumentError('The destination must opt in with worldTime.advance set to "wallClock".', operation);
|
|
366
|
+
}
|
|
367
|
+
assertOnlyKeys(options, ["key", "maxElapsedMs", "apply"], operation, "options");
|
|
368
|
+
const key = options.key === undefined
|
|
369
|
+
? "advancedThroughMs"
|
|
370
|
+
: assertWorldCatchUpKey(options.key, operation);
|
|
371
|
+
const maxElapsedMs = options.maxElapsedMs === undefined
|
|
372
|
+
? WORLD_CATCH_UP_DEFAULT_MAX_ELAPSED_MS
|
|
373
|
+
: assertBoundedInteger(options.maxElapsedMs, 0, Number.MAX_SAFE_INTEGER, "maxElapsedMs", operation);
|
|
374
|
+
if (typeof options.apply !== "function") {
|
|
375
|
+
throw worldJournalArgumentError("apply must be a function.", operation);
|
|
376
|
+
}
|
|
377
|
+
const now = Date.now();
|
|
378
|
+
let snapshot = await read();
|
|
379
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
380
|
+
const state = worldCatchUpState(snapshot.checkpoint, key, now, maxElapsedMs, operation);
|
|
381
|
+
if (state.markerExists && state.elapsedMs === 0) {
|
|
382
|
+
if (state.markerMs >= now) {
|
|
383
|
+
return Object.freeze({ applied: false, elapsedMs: 0, revision: snapshot.revision });
|
|
384
|
+
}
|
|
385
|
+
const checkpoint = checkpointWithCatchUpMarker(snapshot.checkpoint, key, state.markerMs, now, operation);
|
|
386
|
+
try {
|
|
387
|
+
const receipt = await commit({
|
|
388
|
+
expectedRevision: snapshot.revision,
|
|
389
|
+
operationId: `catchUp:${snapshot.revision}`,
|
|
390
|
+
checkpoint,
|
|
391
|
+
});
|
|
392
|
+
return Object.freeze({ applied: false, elapsedMs: 0, revision: receipt.revision });
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
if (attempt !== 0 || !isWorldRevisionConflict(error))
|
|
396
|
+
throw error;
|
|
397
|
+
snapshot = await read();
|
|
398
|
+
continue;
|
|
296
399
|
}
|
|
400
|
+
}
|
|
401
|
+
const returned = await options.apply(Object.freeze({
|
|
402
|
+
elapsedMs: state.elapsedMs,
|
|
403
|
+
now,
|
|
404
|
+
checkpoint: snapshot.checkpoint,
|
|
405
|
+
}));
|
|
406
|
+
const normalized = assertWorldCheckpoint(returned, "commit");
|
|
407
|
+
if (!isRecord(normalized.value)) {
|
|
408
|
+
throw worldJournalArgumentError("apply must return a checkpoint whose value is an object.", operation);
|
|
409
|
+
}
|
|
410
|
+
const checkpoint = checkpointWithCatchUpMarker(normalized, key, state.markerMs, now, operation);
|
|
411
|
+
try {
|
|
412
|
+
const receipt = await commit({
|
|
413
|
+
expectedRevision: snapshot.revision,
|
|
414
|
+
operationId: `catchUp:${snapshot.revision}`,
|
|
415
|
+
checkpoint,
|
|
416
|
+
});
|
|
297
417
|
return Object.freeze({
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
418
|
+
applied: !receipt.replayed,
|
|
419
|
+
elapsedMs: state.elapsedMs,
|
|
420
|
+
revision: receipt.revision,
|
|
301
421
|
});
|
|
302
|
-
}
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
if (attempt !== 0 || !isWorldRevisionConflict(error))
|
|
425
|
+
throw error;
|
|
426
|
+
snapshot = await read();
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
throw new TypeError(`${operation}: unreachable catch-up state.`);
|
|
430
|
+
}
|
|
431
|
+
function assertWorldCatchUpKey(value, operation) {
|
|
432
|
+
if (typeof value !== "string") {
|
|
433
|
+
throw worldJournalArgumentError("key must be a string.", operation);
|
|
434
|
+
}
|
|
435
|
+
return value;
|
|
436
|
+
}
|
|
437
|
+
function worldCatchUpState(checkpoint, key, now, maxElapsedMs, operation) {
|
|
438
|
+
if (!checkpoint ||
|
|
439
|
+
!isRecord(checkpoint.value) ||
|
|
440
|
+
!Object.prototype.hasOwnProperty.call(checkpoint.value, key)) {
|
|
441
|
+
return { markerExists: false, markerMs: now, elapsedMs: 0 };
|
|
442
|
+
}
|
|
443
|
+
const advancedThroughMs = checkpoint.value[key];
|
|
444
|
+
if (!Number.isSafeInteger(advancedThroughMs) || advancedThroughMs < 0) {
|
|
445
|
+
throw worldJournalArgumentError(`checkpoint.value.${key} must be a non-negative integer timestamp in milliseconds.`, operation);
|
|
446
|
+
}
|
|
447
|
+
const markerMs = advancedThroughMs;
|
|
448
|
+
return {
|
|
449
|
+
markerExists: true,
|
|
450
|
+
markerMs,
|
|
451
|
+
elapsedMs: Math.min(maxElapsedMs, Math.max(0, now - markerMs)),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
function checkpointWithCatchUpMarker(checkpoint, key, existingMarkerMs, now, operation) {
|
|
455
|
+
if (!isRecord(checkpoint.value)) {
|
|
456
|
+
throw worldJournalArgumentError("apply must return a checkpoint whose value is an object.", operation);
|
|
457
|
+
}
|
|
458
|
+
return Object.freeze({
|
|
459
|
+
version: checkpoint.version,
|
|
460
|
+
value: Object.freeze({
|
|
461
|
+
...checkpoint.value,
|
|
462
|
+
[key]: Math.max(existingMarkerMs, now),
|
|
463
|
+
}),
|
|
303
464
|
});
|
|
304
465
|
}
|
|
466
|
+
function isWorldRevisionConflict(error) {
|
|
467
|
+
return isRecord(error) && error.code === "CAULDRON_WORLD_REVISION_CONFLICT";
|
|
468
|
+
}
|
|
305
469
|
function createServerWorldJournal(platformHandle) {
|
|
306
470
|
return Object.freeze({
|
|
307
471
|
async append(input) {
|
|
@@ -382,6 +546,167 @@ function createServerWorldParticipants(platformHandle) {
|
|
|
382
546
|
},
|
|
383
547
|
});
|
|
384
548
|
}
|
|
549
|
+
function createServerWorldNotifications(platformHandle) {
|
|
550
|
+
return Object.freeze({
|
|
551
|
+
async notify(input) {
|
|
552
|
+
const operation = "session.world.notifications.notify";
|
|
553
|
+
assertOnlyKeys(input, ["key", "participantIds", "templateId", "slots", "at", "expiresAt"], operation, "input");
|
|
554
|
+
const now = Date.now();
|
|
555
|
+
const key = assertWorldNotificationSafeKey(input.key, "key", operation);
|
|
556
|
+
const participantIds = normalizeParticipantIds(input.participantIds, WORLD_NOTIFICATIONS_MAX_PARTICIPANTS, "participantIds", operation);
|
|
557
|
+
const templateId = assertWorldNotificationSafeKey(input.templateId, "templateId", operation);
|
|
558
|
+
const slots = input.slots === undefined
|
|
559
|
+
? undefined
|
|
560
|
+
: normalizeWorldNotificationSlots(input.slots, operation);
|
|
561
|
+
const at = input.at === undefined
|
|
562
|
+
? undefined
|
|
563
|
+
: assertWorldNotificationTime(input.at, "at", operation);
|
|
564
|
+
const atMs = at?.milliseconds ?? now;
|
|
565
|
+
if (atMs < now - WORLD_NOTIFICATIONS_PAST_TOLERANCE_MS ||
|
|
566
|
+
atMs > now + WORLD_NOTIFICATIONS_MAX_SCHEDULE_MS) {
|
|
567
|
+
throw worldJournalArgumentError("at must be no more than 60 seconds in the past and at most 30 days ahead.", operation);
|
|
568
|
+
}
|
|
569
|
+
// Ghost clamps a tolerated past `at` to its own now, so expiry is judged
|
|
570
|
+
// against that effective time, not the original timestamp.
|
|
571
|
+
const effectiveAtMs = Math.max(atMs, now);
|
|
572
|
+
const expiresAt = input.expiresAt === undefined
|
|
573
|
+
? undefined
|
|
574
|
+
: assertWorldNotificationTime(input.expiresAt, "expiresAt", operation);
|
|
575
|
+
if (expiresAt &&
|
|
576
|
+
(expiresAt.milliseconds <= effectiveAtMs ||
|
|
577
|
+
expiresAt.milliseconds > effectiveAtMs + WORLD_NOTIFICATIONS_MAX_SCHEDULE_MS)) {
|
|
578
|
+
throw worldJournalArgumentError("expiresAt must be after at and at most 30 days after it.", operation);
|
|
579
|
+
}
|
|
580
|
+
const args = {
|
|
581
|
+
key,
|
|
582
|
+
participantIds,
|
|
583
|
+
templateId,
|
|
584
|
+
...(slots === undefined ? {} : { slots }),
|
|
585
|
+
...(at === undefined ? {} : { at: at.value }),
|
|
586
|
+
...(expiresAt === undefined ? {} : { expiresAt: expiresAt.value }),
|
|
587
|
+
};
|
|
588
|
+
const result = parseWorldNotificationResult(await callWorldNotificationStorage(platformHandle, "worldNotify", args, operation), operation);
|
|
589
|
+
if (result.key !== key)
|
|
590
|
+
throw invalidWorldNotificationResponse(operation);
|
|
591
|
+
return Object.freeze({
|
|
592
|
+
key,
|
|
593
|
+
accepted: parseWorldNotificationCount(result.accepted, operation),
|
|
594
|
+
skipped: parseWorldNotificationCount(result.skipped, operation),
|
|
595
|
+
limited: parseWorldNotificationCount(result.limited, operation),
|
|
596
|
+
});
|
|
597
|
+
},
|
|
598
|
+
async cancel(input) {
|
|
599
|
+
const operation = "session.world.notifications.cancel";
|
|
600
|
+
assertOnlyKeys(input, ["key", "participantIds"], operation, "input");
|
|
601
|
+
const key = assertWorldNotificationSafeKey(input.key, "key", operation);
|
|
602
|
+
const participantIds = input.participantIds === undefined
|
|
603
|
+
? undefined
|
|
604
|
+
: normalizeParticipantIds(input.participantIds, WORLD_NOTIFICATIONS_MAX_PARTICIPANTS, "participantIds", operation);
|
|
605
|
+
const args = {
|
|
606
|
+
key,
|
|
607
|
+
...(participantIds === undefined ? {} : { participantIds }),
|
|
608
|
+
};
|
|
609
|
+
const result = parseWorldNotificationResult(await callWorldNotificationStorage(platformHandle, "worldNotifyCancel", args, operation), operation);
|
|
610
|
+
return Object.freeze({
|
|
611
|
+
cancelled: parseWorldNotificationCount(result.cancelled, operation),
|
|
612
|
+
});
|
|
613
|
+
},
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
function assertWorldNotificationSafeKey(value, field, operation) {
|
|
617
|
+
if (typeof value !== "string" ||
|
|
618
|
+
worldNotificationCharacters(value) < 1 ||
|
|
619
|
+
worldNotificationCharacters(value) > 64 ||
|
|
620
|
+
!SAFE_WORLD_NOTIFICATION_KEY.test(value)) {
|
|
621
|
+
throw worldJournalArgumentError(`${field} must be a safe key of at most 64 characters.`, operation);
|
|
622
|
+
}
|
|
623
|
+
return value;
|
|
624
|
+
}
|
|
625
|
+
function normalizeWorldNotificationSlots(value, operation) {
|
|
626
|
+
if (!isRecord(value)) {
|
|
627
|
+
throw worldJournalArgumentError("slots must be an object.", operation);
|
|
628
|
+
}
|
|
629
|
+
const entries = Object.entries(value);
|
|
630
|
+
if (entries.length > WORLD_NOTIFICATIONS_MAX_SLOTS) {
|
|
631
|
+
throw worldJournalArgumentError(`slots may contain at most ${WORLD_NOTIFICATIONS_MAX_SLOTS} values.`, operation);
|
|
632
|
+
}
|
|
633
|
+
const normalized = {};
|
|
634
|
+
for (const [key, slotValue] of entries.sort(([left], [right]) => left.localeCompare(right))) {
|
|
635
|
+
assertWorldNotificationSafeKey(key, `slots.${key}`, operation);
|
|
636
|
+
if (typeof slotValue !== "string" ||
|
|
637
|
+
worldNotificationCharacters(slotValue) < 1 ||
|
|
638
|
+
worldNotificationCharacters(slotValue) > WORLD_NOTIFICATIONS_MAX_SLOT_CHARACTERS ||
|
|
639
|
+
slotValue.includes("\0") ||
|
|
640
|
+
hasUnpairedSurrogate(slotValue) ||
|
|
641
|
+
slotValue.includes("\n") ||
|
|
642
|
+
slotValue.includes("\r") ||
|
|
643
|
+
URL_IN_WORLD_NOTIFICATION_TEXT.test(slotValue)) {
|
|
644
|
+
throw worldJournalArgumentError(`slots.${key} must be 1 through ${WORLD_NOTIFICATIONS_MAX_SLOT_CHARACTERS} ` +
|
|
645
|
+
"characters of single-line text without a URL.", operation);
|
|
646
|
+
}
|
|
647
|
+
normalized[key] = slotValue;
|
|
648
|
+
}
|
|
649
|
+
return Object.freeze(normalized);
|
|
650
|
+
}
|
|
651
|
+
function worldNotificationCharacters(value) {
|
|
652
|
+
return Array.from(value).length;
|
|
653
|
+
}
|
|
654
|
+
function assertWorldNotificationTime(value, field, operation) {
|
|
655
|
+
if (typeof value !== "string") {
|
|
656
|
+
throw worldJournalArgumentError(`${field} must be a canonical ISO 8601 time with at most millisecond precision.`, operation);
|
|
657
|
+
}
|
|
658
|
+
const match = CANONICAL_WORLD_NOTIFICATION_TIME.exec(value);
|
|
659
|
+
const milliseconds = Date.parse(value);
|
|
660
|
+
if (!match || !Number.isFinite(milliseconds)) {
|
|
661
|
+
throw worldJournalArgumentError(`${field} must be a canonical ISO 8601 time with at most millisecond precision.`, operation);
|
|
662
|
+
}
|
|
663
|
+
const normalized = new Date(milliseconds).toISOString();
|
|
664
|
+
const normalizedFraction = normalized.slice(20, 23);
|
|
665
|
+
if (normalized.slice(0, 19) !== match[1] ||
|
|
666
|
+
(match[2] === undefined
|
|
667
|
+
? normalizedFraction !== "000"
|
|
668
|
+
: normalizedFraction !== match[2].padEnd(3, "0"))) {
|
|
669
|
+
throw worldJournalArgumentError(`${field} must be a canonical ISO 8601 time with at most millisecond precision.`, operation);
|
|
670
|
+
}
|
|
671
|
+
return { value, milliseconds };
|
|
672
|
+
}
|
|
673
|
+
async function callWorldNotificationStorage(platformHandle, op, args, operation) {
|
|
674
|
+
if (typeof platformHandle.storage !== "function" || platformHandle.storageEnabled === false) {
|
|
675
|
+
throw worldNotificationsUnavailable(operation);
|
|
676
|
+
}
|
|
677
|
+
try {
|
|
678
|
+
return await platformHandle.storage(op, JSON.stringify(args));
|
|
679
|
+
}
|
|
680
|
+
catch (error) {
|
|
681
|
+
if (isUnknownStorageOperation(error))
|
|
682
|
+
throw worldNotificationsUnavailable(operation);
|
|
683
|
+
throw error;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
function worldNotificationsUnavailable(operation) {
|
|
687
|
+
return storageError("CAULDRON_WORLD_NOTIFICATIONS_UNAVAILABLE", "This Castle host does not support persistent-world notifications.", operation);
|
|
688
|
+
}
|
|
689
|
+
function parseWorldNotificationResult(value, operation) {
|
|
690
|
+
let result;
|
|
691
|
+
try {
|
|
692
|
+
result = JSON.parse(value);
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
throw invalidWorldNotificationResponse(operation);
|
|
696
|
+
}
|
|
697
|
+
if (!isRecord(result))
|
|
698
|
+
throw invalidWorldNotificationResponse(operation);
|
|
699
|
+
return result;
|
|
700
|
+
}
|
|
701
|
+
function parseWorldNotificationCount(value, operation) {
|
|
702
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
703
|
+
throw invalidWorldNotificationResponse(operation);
|
|
704
|
+
}
|
|
705
|
+
return value;
|
|
706
|
+
}
|
|
707
|
+
function invalidWorldNotificationResponse(operation) {
|
|
708
|
+
return new TypeError(`${operation}: The platform returned an invalid response.`);
|
|
709
|
+
}
|
|
385
710
|
function normalizeWorldJournalAppendEntry(value, index, operation) {
|
|
386
711
|
const field = `entries[${index}]`;
|
|
387
712
|
assertOnlyKeys(value, ["kind", "disclosure", "participantIds", "data"], operation, field);
|
|
@@ -754,12 +1079,13 @@ function assertRuntimeCharacterCapabilities(value) {
|
|
|
754
1079
|
}
|
|
755
1080
|
return Object.freeze({ actionIds: Object.freeze([...normalized].sort()) });
|
|
756
1081
|
}
|
|
1082
|
+
const LONE_SURROGATE = /[\uD800-\uDFFF]/u;
|
|
757
1083
|
function assertRuntimeCharacterText(value, field, maxCharacters, maxBytes) {
|
|
758
1084
|
if (typeof value !== "string" ||
|
|
759
1085
|
value.length === 0 ||
|
|
760
1086
|
value.length > maxCharacters ||
|
|
761
1087
|
value.includes("\0") ||
|
|
762
|
-
|
|
1088
|
+
LONE_SURROGATE.test(value) ||
|
|
763
1089
|
new TextEncoder().encode(value).byteLength > maxBytes) {
|
|
764
1090
|
throw new TypeError(`session.world.characters.create: ${field} must be valid non-empty text of at most ${maxCharacters} characters.`);
|
|
765
1091
|
}
|
|
@@ -863,6 +1189,82 @@ function assertPlatformWorldCharacterRead(value) {
|
|
|
863
1189
|
}
|
|
864
1190
|
return value;
|
|
865
1191
|
}
|
|
1192
|
+
// Tools ride only to a host that can answer them (an older host refuses a send
|
|
1193
|
+
// carrying anything beyond the grant); otherwise this is exactly the tool-less send.
|
|
1194
|
+
function sendWorldCharacterTurn(platformHandle, grantId, options) {
|
|
1195
|
+
const tools = options?.tools;
|
|
1196
|
+
return Array.isArray(tools) && tools.length > 0 &&
|
|
1197
|
+
typeof platformHandle.worldCharacterToolResult === "function"
|
|
1198
|
+
? platformHandle.worldCharacterSend(grantId, tools)
|
|
1199
|
+
: platformHandle.worldCharacterSend(grantId);
|
|
1200
|
+
}
|
|
1201
|
+
// Only a handler on a host that answers place tools listens; without one the
|
|
1202
|
+
// host times the lookup out and the character answers without it.
|
|
1203
|
+
function listenForWorldCharacterTools(platformHandle, callbacks, session) {
|
|
1204
|
+
const handler = callbacks.onWorldCharacterTool;
|
|
1205
|
+
const answer = platformHandle.worldCharacterToolResult?.bind(platformHandle);
|
|
1206
|
+
if (!handler || !answer)
|
|
1207
|
+
return;
|
|
1208
|
+
try {
|
|
1209
|
+
platformHandle.on("worldCharacterTool", async (raw) => {
|
|
1210
|
+
const call = platformWorldCharacterTool(raw);
|
|
1211
|
+
if (!call)
|
|
1212
|
+
return;
|
|
1213
|
+
const result = await worldCharacterToolResult(handler, call.request, session);
|
|
1214
|
+
try {
|
|
1215
|
+
await answer(call.requestId, result);
|
|
1216
|
+
}
|
|
1217
|
+
catch {
|
|
1218
|
+
// The host has stopped waiting (or refused); the character answers without it.
|
|
1219
|
+
}
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
catch {
|
|
1223
|
+
// Older shims may reject event names they do not recognize.
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
const MAX_WORLD_CHARACTER_TOOL_RESULT_BYTES = 1024;
|
|
1227
|
+
const WORLD_CHARACTER_TOOL_TOO_LARGE = '{"error":"result too large"}';
|
|
1228
|
+
const WORLD_CHARACTER_TOOL_FAILED = '{"error":"tool failed"}';
|
|
1229
|
+
function platformWorldCharacterTool(value) {
|
|
1230
|
+
if (!value ||
|
|
1231
|
+
typeof value !== "object" ||
|
|
1232
|
+
typeof value.requestId !== "string" ||
|
|
1233
|
+
typeof value.operationId !== "string" ||
|
|
1234
|
+
typeof value.characterId !== "string" ||
|
|
1235
|
+
typeof value.playerId !== "string" ||
|
|
1236
|
+
typeof value.name !== "string" ||
|
|
1237
|
+
!value.input ||
|
|
1238
|
+
typeof value.input !== "object" ||
|
|
1239
|
+
Array.isArray(value.input)) {
|
|
1240
|
+
return null;
|
|
1241
|
+
}
|
|
1242
|
+
return {
|
|
1243
|
+
requestId: value.requestId,
|
|
1244
|
+
request: Object.freeze({
|
|
1245
|
+
operationId: value.operationId,
|
|
1246
|
+
characterId: value.characterId,
|
|
1247
|
+
playerId: value.playerId,
|
|
1248
|
+
name: value.name,
|
|
1249
|
+
input: deepFreezeJsonObject(value.input),
|
|
1250
|
+
}),
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
async function worldCharacterToolResult(handler, request, session) {
|
|
1254
|
+
let result;
|
|
1255
|
+
try {
|
|
1256
|
+
const value = await handler(request, session);
|
|
1257
|
+
result = typeof value === "string" ? value : JSON.stringify(value);
|
|
1258
|
+
}
|
|
1259
|
+
catch {
|
|
1260
|
+
return WORLD_CHARACTER_TOOL_FAILED;
|
|
1261
|
+
}
|
|
1262
|
+
if (typeof result !== "string")
|
|
1263
|
+
return WORLD_CHARACTER_TOOL_FAILED;
|
|
1264
|
+
return new TextEncoder().encode(result).byteLength > MAX_WORLD_CHARACTER_TOOL_RESULT_BYTES
|
|
1265
|
+
? WORLD_CHARACTER_TOOL_TOO_LARGE
|
|
1266
|
+
: result;
|
|
1267
|
+
}
|
|
866
1268
|
function hasWorldCharacterPlatform(platformHandle, operation) {
|
|
867
1269
|
return (typeof platformHandle[operation] === "function" &&
|
|
868
1270
|
typeof platformHandle.worldCharacterReject === "function");
|
|
@@ -973,13 +1375,51 @@ function requireBroadcastBinary(platformHandle) {
|
|
|
973
1375
|
function unsupportedBinaryRuntime() {
|
|
974
1376
|
throw new TypeError("This Castle runtime does not support binary multiplayer messages yet.");
|
|
975
1377
|
}
|
|
1378
|
+
// The compatibility branch accepts the current Node shim's legacy
|
|
1379
|
+
// single-argument SessionPlayer event while the public handle contract uses the
|
|
1380
|
+
// runtime-agnostic (connId, trusted identity) pair. Either form may carry the hub
|
|
1381
|
+
// arrival after the player and the player's A/B arms after that, and a hub
|
|
1382
|
+
// player's block list on the player itself.
|
|
1383
|
+
function admitPlayer(state, connId, identity, handleArrival, handleVariants) {
|
|
1384
|
+
const player = joinedPlayer(connId, identity);
|
|
1385
|
+
const arrival = hubArrival(typeof connId === "string" ? handleArrival : identity);
|
|
1386
|
+
const known = experimentVariants(typeof connId === "string" ? handleVariants : handleArrival);
|
|
1387
|
+
state.players.set(player.playerId, player);
|
|
1388
|
+
if (known) {
|
|
1389
|
+
state.variants.set(player.playerId, known);
|
|
1390
|
+
}
|
|
1391
|
+
else {
|
|
1392
|
+
state.variants.delete(player.playerId);
|
|
1393
|
+
}
|
|
1394
|
+
recordHiddenUserIds(state.hidden, player.playerId, typeof connId === "string" ? identity : connId);
|
|
1395
|
+
if (arrival) {
|
|
1396
|
+
state.arrivals.set(player.playerId, arrival);
|
|
1397
|
+
}
|
|
1398
|
+
else {
|
|
1399
|
+
state.arrivals.delete(player.playerId);
|
|
1400
|
+
}
|
|
1401
|
+
return { player, arrival };
|
|
1402
|
+
}
|
|
1403
|
+
// Absent on a host that predates experiments, and for a player who is in none.
|
|
1404
|
+
function experimentVariants(value) {
|
|
1405
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1406
|
+
return null;
|
|
1407
|
+
const entries = Object.entries(value).filter((entry) => typeof entry[1] === "string");
|
|
1408
|
+
return entries.length > 0 ? Object.freeze(Object.fromEntries(entries)) : null;
|
|
1409
|
+
}
|
|
976
1410
|
function joinedPlayer(connId, identity) {
|
|
977
1411
|
if (typeof connId === "string") {
|
|
978
|
-
|
|
1412
|
+
const player = {
|
|
979
1413
|
...identity,
|
|
980
1414
|
playerId: connId,
|
|
981
1415
|
participantId: identity.scopedUserId ?? identity.userId,
|
|
982
1416
|
};
|
|
1417
|
+
// A hub player's block list stays in the wrapper (`session.hub.hiddenPair`):
|
|
1418
|
+
// decks broadcast player objects.
|
|
1419
|
+
delete player.hiddenUserIds;
|
|
1420
|
+
// A/B arms arrive beside the player; one a host put on it stays off it too.
|
|
1421
|
+
delete player.experimentVariants;
|
|
1422
|
+
return player;
|
|
983
1423
|
}
|
|
984
1424
|
const legacy = connId;
|
|
985
1425
|
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. */
|