castle-web-sdk 0.4.36 → 0.4.38

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.
@@ -1,4 +1,5 @@
1
1
  import { isBinaryPayload, toUint8Array, } from "../multiplayerProtocol";
2
+ import { encodeStorageValue } from "../storageJson";
2
3
  import { createServerStorage } from "./storage";
3
4
  // The publish bundler binds the author's default callbacks export once and
4
5
  // exports the returned one-argument function as `__castleBootSession`.
@@ -42,6 +43,109 @@ async function bootSession(platformHandle, callbacks) {
42
43
  catch {
43
44
  // Older shims may reject event names they do not recognize.
44
45
  }
46
+ if (callbacks.onWorldCharacterTurn &&
47
+ typeof platformHandle.worldCharacterSend === "function" &&
48
+ typeof platformHandle.worldCharacterReject === "function") {
49
+ platformHandle.on("worldCharacterTurn", async (raw) => {
50
+ const request = assertPlatformWorldCharacterTurn(raw);
51
+ let settled = false;
52
+ const turn = Object.freeze({
53
+ requestId: request.requestId,
54
+ operationId: request.operationId,
55
+ speaker: Object.freeze({ ...request.speaker }),
56
+ characterId: request.characterId,
57
+ input: request.input,
58
+ audience: Object.freeze({
59
+ disclosure: request.audience.disclosure,
60
+ players: Object.freeze(request.audience.players.map((player) => Object.freeze({ ...player }))),
61
+ characterIds: Object.freeze([...request.audience.characterIds]),
62
+ }),
63
+ async send() {
64
+ if (settled)
65
+ throw new TypeError("This shared character request is already settled.");
66
+ settled = true;
67
+ const rawReceipt = await platformHandle.worldCharacterSend(request.grantId);
68
+ return parseWorldCharacterAdmission(rawReceipt, request.operationId);
69
+ },
70
+ reject(code) {
71
+ if (settled)
72
+ return;
73
+ settled = true;
74
+ platformHandle.worldCharacterReject(request.grantId, code);
75
+ },
76
+ });
77
+ try {
78
+ await callbacks.onWorldCharacterTurn(session, turn);
79
+ }
80
+ finally {
81
+ if (!settled)
82
+ turn.reject();
83
+ }
84
+ });
85
+ }
86
+ if (callbacks.onWorldCharacterCommand &&
87
+ hasWorldCharacterPlatform(platformHandle, "worldCharacterCommand")) {
88
+ platformHandle.on("worldCharacterCommand", async (raw) => {
89
+ const request = assertPlatformWorldCharacterCommand(raw);
90
+ let settled = false;
91
+ const command = Object.freeze({
92
+ requestId: request.requestId,
93
+ operationId: request.operationId,
94
+ speaker: Object.freeze({ ...request.speaker }),
95
+ command: deepFreezeJsonObject(request.command),
96
+ async apply() {
97
+ if (settled)
98
+ throw new TypeError("This shared character command is already settled.");
99
+ settled = true;
100
+ return parseSharedCharacterJson(await platformHandle.worldCharacterCommand(request.grantId));
101
+ },
102
+ reject(code) {
103
+ if (settled)
104
+ return;
105
+ settled = true;
106
+ platformHandle.worldCharacterReject(request.grantId, code);
107
+ },
108
+ });
109
+ try {
110
+ await callbacks.onWorldCharacterCommand(session, command);
111
+ }
112
+ finally {
113
+ if (!settled)
114
+ command.reject();
115
+ }
116
+ });
117
+ }
118
+ if (callbacks.onWorldCharacterRead &&
119
+ hasWorldCharacterPlatform(platformHandle, "worldCharacterRead")) {
120
+ platformHandle.on("worldCharacterRead", async (raw) => {
121
+ const request = assertPlatformWorldCharacterRead(raw);
122
+ let settled = false;
123
+ const read = Object.freeze({
124
+ requestId: request.requestId,
125
+ speaker: Object.freeze({ ...request.speaker }),
126
+ query: deepFreezeJsonObject(request.query),
127
+ async read() {
128
+ if (settled)
129
+ throw new TypeError("This shared character read is already settled.");
130
+ settled = true;
131
+ return parseSharedCharacterJson(await platformHandle.worldCharacterRead(request.grantId));
132
+ },
133
+ reject(code) {
134
+ if (settled)
135
+ return;
136
+ settled = true;
137
+ platformHandle.worldCharacterReject(request.grantId, code);
138
+ },
139
+ });
140
+ try {
141
+ await callbacks.onWorldCharacterRead(session, read);
142
+ }
143
+ finally {
144
+ if (!settled)
145
+ read.reject();
146
+ }
147
+ });
148
+ }
45
149
  platformHandle.on("replaced", async () => {
46
150
  await callbacks.onReplaced?.(session);
47
151
  });
@@ -68,6 +172,7 @@ function createAuthorSession(platformHandle, players) {
68
172
  deckId: platformHandle.config.deckId,
69
173
  mode: platformHandle.config.mode,
70
174
  storage: createServerStorage(platformHandle),
175
+ world: createServerWorld(platformHandle),
71
176
  send(playerId, data) {
72
177
  if (isBinaryPayload(data)) {
73
178
  requireSendBinary(platformHandle);
@@ -106,6 +211,235 @@ function createAuthorSession(platformHandle, players) {
106
211
  },
107
212
  };
108
213
  }
214
+ const WORLD_OPERATION_ID_MAX_BYTES = 128;
215
+ const WORLD_OPERATION_ID_MAX_CHARACTERS = 120;
216
+ const WORLD_CHECKPOINT_VERSION_MAX = 2_147_483_647;
217
+ function createServerWorld(platformHandle) {
218
+ if (!platformHandle.config.world)
219
+ return null;
220
+ if (typeof platformHandle.worldCheckpointRead !== "function" ||
221
+ typeof platformHandle.worldCheckpointCommit !== "function") {
222
+ throw worldError("The persistent-world checkpoint broker is unavailable.", "read");
223
+ }
224
+ const initial = parseWorldSnapshot(platformHandle.config.world, "read");
225
+ return Object.freeze({
226
+ snapshot: initial,
227
+ characters: createServerWorldCharacters(platformHandle),
228
+ async read() {
229
+ const result = await platformHandle.worldCheckpointRead();
230
+ return parseWorldSnapshot(parsePlatformJson(result, "read"), "read");
231
+ },
232
+ async commit(input) {
233
+ const expectedRevision = assertWorldRevision(input?.expectedRevision);
234
+ const operationId = assertWorldOperationId(input?.operationId);
235
+ const checkpoint = assertWorldCheckpoint(input?.checkpoint);
236
+ const raw = await platformHandle.worldCheckpointCommit(JSON.stringify({
237
+ expectedRevision,
238
+ operationId,
239
+ checkpoint,
240
+ }));
241
+ const result = parsePlatformJson(raw, "commit");
242
+ if (typeof result.replayed !== "boolean") {
243
+ throw worldError("The platform returned an invalid commit receipt.", "commit");
244
+ }
245
+ return Object.freeze({
246
+ revision: assertWorldRevision(result.revision),
247
+ replayed: result.replayed,
248
+ operationId,
249
+ });
250
+ },
251
+ });
252
+ }
253
+ function createServerWorldCharacters(platformHandle) {
254
+ return Object.freeze({
255
+ async publicInspect() {
256
+ if (typeof platformHandle.storage !== "function") {
257
+ throw new TypeError("The shared character broker is unavailable.");
258
+ }
259
+ return parseSharedCharacterJson(await platformHandle.storage("worldCharacterPublicInspect", "{}"));
260
+ },
261
+ async publicRelay(input) {
262
+ if (typeof platformHandle.storage !== "function") {
263
+ throw new TypeError("The shared character broker is unavailable.");
264
+ }
265
+ const operationId = assertWorldOperationId(input?.operationId);
266
+ const sourceId = assertWorldCharacterId(input?.sourceId, "sourceId", 128);
267
+ const transmittingCharacterId = assertWorldCharacterId(input?.transmittingCharacterId, "transmittingCharacterId");
268
+ if (!Array.isArray(input?.recipientCharacterIds) ||
269
+ input.recipientCharacterIds.length > 3) {
270
+ throw new TypeError("session.world.characters.publicRelay: recipientCharacterIds may contain at most 3 ids.");
271
+ }
272
+ const recipientCharacterIds = input.recipientCharacterIds.map((id) => assertWorldCharacterId(id, "recipientCharacterIds"));
273
+ if (new Set(recipientCharacterIds).size !== recipientCharacterIds.length) {
274
+ throw new TypeError("session.world.characters.publicRelay: recipientCharacterIds may not contain duplicates.");
275
+ }
276
+ return parseSharedCharacterJson(await platformHandle.storage("worldCharacterPublicRelay", JSON.stringify({
277
+ operationId,
278
+ sourceId,
279
+ transmittingCharacterId,
280
+ recipientCharacterIds: recipientCharacterIds.sort(),
281
+ })));
282
+ },
283
+ });
284
+ }
285
+ function parseWorldSnapshot(value, operation) {
286
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
287
+ throw worldError("The platform returned an invalid world checkpoint.", "read");
288
+ }
289
+ const raw = value;
290
+ return Object.freeze({
291
+ revision: assertWorldRevision(raw.revision),
292
+ checkpoint: raw.checkpoint === null
293
+ ? null
294
+ : assertWorldCheckpoint(raw.checkpoint, operation),
295
+ });
296
+ }
297
+ function assertPlatformWorldCharacterTurn(value) {
298
+ if (!value ||
299
+ typeof value !== "object" ||
300
+ typeof value.grantId !== "string" ||
301
+ typeof value.requestId !== "string" ||
302
+ typeof value.operationId !== "string" ||
303
+ typeof value.characterId !== "string" ||
304
+ typeof value.input !== "string" ||
305
+ !value.speaker ||
306
+ typeof value.speaker.playerId !== "string" ||
307
+ !value.audience ||
308
+ (value.audience.disclosure !== "participants" && value.audience.disclosure !== "world") ||
309
+ !Array.isArray(value.audience.players) ||
310
+ !Array.isArray(value.audience.characterIds)) {
311
+ throw new TypeError("The platform sent an invalid shared character request.");
312
+ }
313
+ return value;
314
+ }
315
+ function assertPlatformWorldCharacterCommand(value) {
316
+ if (!value ||
317
+ typeof value !== "object" ||
318
+ typeof value.grantId !== "string" ||
319
+ typeof value.requestId !== "string" ||
320
+ typeof value.operationId !== "string" ||
321
+ !value.speaker ||
322
+ typeof value.speaker.playerId !== "string" ||
323
+ !value.command ||
324
+ typeof value.command !== "object" ||
325
+ Array.isArray(value.command)) {
326
+ throw new TypeError("The platform sent an invalid shared character command.");
327
+ }
328
+ return value;
329
+ }
330
+ function assertPlatformWorldCharacterRead(value) {
331
+ if (!value ||
332
+ typeof value !== "object" ||
333
+ typeof value.grantId !== "string" ||
334
+ typeof value.requestId !== "string" ||
335
+ !value.speaker ||
336
+ typeof value.speaker.playerId !== "string" ||
337
+ !value.query ||
338
+ typeof value.query !== "object" ||
339
+ Array.isArray(value.query)) {
340
+ throw new TypeError("The platform sent an invalid shared character read.");
341
+ }
342
+ return value;
343
+ }
344
+ function hasWorldCharacterPlatform(platformHandle, operation) {
345
+ return (typeof platformHandle[operation] === "function" &&
346
+ typeof platformHandle.worldCharacterReject === "function");
347
+ }
348
+ function deepFreezeJsonObject(value) {
349
+ const normalized = JSON.parse(JSON.stringify(value));
350
+ deepFreeze(normalized);
351
+ return normalized;
352
+ }
353
+ function deepFreeze(value) {
354
+ if (!value || typeof value !== "object")
355
+ return;
356
+ Object.freeze(value);
357
+ for (const child of Object.values(value))
358
+ deepFreeze(child);
359
+ }
360
+ function parseSharedCharacterJson(json) {
361
+ try {
362
+ return JSON.parse(json);
363
+ }
364
+ catch {
365
+ throw new TypeError("The platform returned an invalid shared character response.");
366
+ }
367
+ }
368
+ function assertWorldCharacterId(value, field, maxCharacters = 100) {
369
+ if (typeof value !== "string" ||
370
+ value.length === 0 ||
371
+ value.length > maxCharacters ||
372
+ value.includes("\0") ||
373
+ new TextEncoder().encode(value).byteLength > 128) {
374
+ throw new TypeError(`session.world.characters: ${field} must be non-empty text of at most ${maxCharacters} characters and 128 bytes.`);
375
+ }
376
+ return value;
377
+ }
378
+ function parseWorldCharacterAdmission(json, operationId) {
379
+ let value;
380
+ try {
381
+ value = JSON.parse(json);
382
+ }
383
+ catch {
384
+ throw new TypeError("The platform returned an invalid shared character receipt.");
385
+ }
386
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
387
+ throw new TypeError("The platform returned an invalid shared character receipt.");
388
+ }
389
+ const receipt = value;
390
+ if (receipt.operationId !== operationId ||
391
+ (receipt.status !== "queued" &&
392
+ receipt.status !== "completed" &&
393
+ receipt.status !== "interrupted") ||
394
+ typeof receipt.replayed !== "boolean") {
395
+ throw new TypeError("The platform returned an invalid shared character receipt.");
396
+ }
397
+ return Object.freeze({
398
+ operationId,
399
+ status: receipt.status,
400
+ replayed: receipt.replayed,
401
+ });
402
+ }
403
+ function assertWorldCheckpoint(value, operation = "commit") {
404
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
405
+ throw worldError("A world checkpoint requires version and value.", operation);
406
+ }
407
+ const raw = value;
408
+ if (!Number.isSafeInteger(raw.version) ||
409
+ raw.version < 0 ||
410
+ raw.version > WORLD_CHECKPOINT_VERSION_MAX) {
411
+ throw worldError(`A world checkpoint version must be an integer from 0 through ${WORLD_CHECKPOINT_VERSION_MAX}.`, operation);
412
+ }
413
+ const text = encodeStorageValue(raw.value, `session.world.${operation}`);
414
+ const normalized = JSON.parse(text);
415
+ return Object.freeze({ version: raw.version, value: normalized });
416
+ }
417
+ function assertWorldRevision(value) {
418
+ if (typeof value === "string" && /^(?:0|[1-9][0-9]*)$/.test(value))
419
+ return value;
420
+ throw worldError("A world checkpoint revision must be a canonical decimal string.", "commit");
421
+ }
422
+ function assertWorldOperationId(value) {
423
+ if (typeof value !== "string" ||
424
+ value.length === 0 ||
425
+ value.length > WORLD_OPERATION_ID_MAX_CHARACTERS ||
426
+ value.includes("\0") ||
427
+ new TextEncoder().encode(value).byteLength > WORLD_OPERATION_ID_MAX_BYTES) {
428
+ throw worldError(`A world checkpoint operation id must be at most ${WORLD_OPERATION_ID_MAX_CHARACTERS} characters and ${WORLD_OPERATION_ID_MAX_BYTES} bytes.`, "commit");
429
+ }
430
+ return value;
431
+ }
432
+ function parsePlatformJson(value, operation) {
433
+ try {
434
+ return JSON.parse(value);
435
+ }
436
+ catch {
437
+ throw worldError("The platform returned an invalid world checkpoint response.", operation);
438
+ }
439
+ }
440
+ function worldError(message, operation) {
441
+ return new TypeError(`session.world.${operation}: ${message}`);
442
+ }
109
443
  function requireSendBinary(platformHandle) {
110
444
  if (typeof platformHandle.sendBinary !== "function")
111
445
  unsupportedBinaryRuntime();
package/dist/transport.js CHANGED
@@ -23,6 +23,7 @@ const INTERACTIVE_COMMANDS = new Set([
23
23
  "pass.offer",
24
24
  "snapshot.offer",
25
25
  "portal.open",
26
+ "multiplayer.joinDestination",
26
27
  // The host's voice prompt and the browser's microphone permission.
27
28
  "voice.requestJoin",
28
29
  ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-sdk",
3
- "version": "0.4.36",
3
+ "version": "0.4.38",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/castle-xyz/castle-experimental-web.git"