@diffenderfer-games/hub 2.0.0-next.1 → 2.0.0-next.3

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.
Files changed (102) hide show
  1. package/CHANGELOG.md +40 -6
  2. package/README.md +25 -10
  3. package/dist/auto.d.ts +7 -7
  4. package/dist/auto.js +3 -3
  5. package/dist/doctor/cli.mjs +14 -7
  6. package/dist/doctor/cli.mjs.map +1 -1
  7. package/dist/index.d.ts +7 -7
  8. package/dist/index.js +3 -3
  9. package/dist/input.d.ts +2 -2
  10. package/dist/input.js +2 -2
  11. package/dist/mp.d.ts +3 -3
  12. package/dist/race.d.ts +1 -1
  13. package/dist/rooms.d.ts +3 -3
  14. package/dist/shared/bridge.js +9 -3
  15. package/dist/shared/bridge.js.map +1 -1
  16. package/dist/shared/client-version.gen.js +1 -1
  17. package/dist/shared/daily.d.ts +10 -4
  18. package/dist/shared/daily.d.ts.map +1 -1
  19. package/dist/shared/daily.js +12 -2
  20. package/dist/shared/daily.js.map +1 -1
  21. package/dist/shared/index.d.ts +315 -36
  22. package/dist/shared/index.d.ts.map +1 -1
  23. package/dist/shared/input.d.ts +47 -5
  24. package/dist/shared/input.d.ts.map +1 -1
  25. package/dist/shared/input.js +139 -23
  26. package/dist/shared/input.js.map +1 -1
  27. package/dist/shared/latest-state.js +1 -1
  28. package/dist/shared/mp.js +10 -2
  29. package/dist/shared/mp.js.map +1 -1
  30. package/dist/shared/multiplayer-types.d.ts +15 -2
  31. package/dist/shared/multiplayer-types.d.ts.map +1 -1
  32. package/dist/shared/race-types.d.ts +1 -1
  33. package/dist/shared/race.js +1 -1
  34. package/dist/shared/room-types.d.ts +28 -3
  35. package/dist/shared/room-types.d.ts.map +1 -1
  36. package/dist/shared/rooms.d.ts.map +1 -1
  37. package/dist/shared/rooms.js +10 -2
  38. package/dist/shared/rooms.js.map +1 -1
  39. package/dist/shared/runtime-opener.js +1 -1
  40. package/dist/shared/social.d.ts +23 -8
  41. package/dist/shared/social.d.ts.map +1 -1
  42. package/dist/shared/social.js +6 -1
  43. package/dist/shared/social.js.map +1 -1
  44. package/dist/shared/src.js +1588 -388
  45. package/dist/shared/src.js.map +1 -1
  46. package/dist/shared/wire-types.d.ts +6 -2
  47. package/dist/shared/wire-types.d.ts.map +1 -1
  48. package/dist/social.d.ts +5 -5
  49. package/dist/social.js +1 -1
  50. package/package.json +2 -2
  51. package/src/core/connection.ts +187 -92
  52. package/src/core/game-data.ts +11 -9
  53. package/src/core/hub-economy.ts +47 -10
  54. package/src/core/hub-progress.ts +13 -16
  55. package/src/core/hub.ts +4 -2
  56. package/src/core/offline/hub-kinds/daily-kind.ts +68 -0
  57. package/src/core/offline/hub-kinds/economy-kinds.ts +86 -0
  58. package/src/core/offline/hub-kinds/game-fields.ts +2 -0
  59. package/src/core/offline/hub-kinds/index.ts +35 -0
  60. package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
  61. package/src/core/offline/hub-kinds/progress-kinds.ts +148 -0
  62. package/src/core/offline/kind-adapters.ts +48 -0
  63. package/src/core/offline/local-inventory.ts +64 -0
  64. package/src/core/offline/local-store.ts +20 -22
  65. package/src/core/offline/offline-api.ts +65 -0
  66. package/src/core/offline/outbox-entry.ts +59 -0
  67. package/src/core/offline/outbox-kinds.ts +78 -0
  68. package/src/core/offline/outbox-merge.ts +39 -0
  69. package/src/core/offline/outbox-replayer.ts +141 -0
  70. package/src/core/offline/outbox-store.ts +85 -0
  71. package/src/core/offline/outbox.ts +232 -0
  72. package/src/core/offline/queue.ts +4 -49
  73. package/src/core/offline/replay-lease.ts +87 -0
  74. package/src/core/offline/replay-policy.ts +52 -0
  75. package/src/core/rest/rest-client.ts +14 -3
  76. package/src/core/rest/routes.gen.ts +108 -87
  77. package/src/core/storage/json-guards.ts +6 -0
  78. package/src/core/version/client-version.gen.ts +1 -1
  79. package/src/daily/daily-system.ts +8 -1
  80. package/src/daily/types.ts +3 -2
  81. package/src/doctor/checks.ts +10 -2
  82. package/src/index.ts +6 -1
  83. package/src/input/focus-ring.ts +13 -0
  84. package/src/input/input-registry.ts +9 -2
  85. package/src/input/input-system.ts +4 -0
  86. package/src/input/modality.ts +122 -0
  87. package/src/input/nav-state.ts +13 -4
  88. package/src/input/zero-config.ts +1 -14
  89. package/src/input.ts +3 -0
  90. package/src/mp.ts +1 -1
  91. package/src/overlay/overlay.ts +5 -3
  92. package/src/overlay/styles.ts +4 -3
  93. package/src/realtime/bridge.ts +5 -0
  94. package/src/realtime/multiplayer-forwarder.ts +13 -1
  95. package/src/realtime/multiplayer-types.ts +15 -1
  96. package/src/realtime/room-types.ts +28 -2
  97. package/src/realtime/rooms-forwarder.ts +13 -1
  98. package/src/realtime/runtime-api.ts +19 -8
  99. package/src/realtime/social-forwarder.ts +5 -0
  100. package/src/realtime/social-types.ts +8 -0
  101. package/src/realtime/wire-types.ts +7 -0
  102. package/src/rooms.ts +1 -1
@@ -3,7 +3,7 @@ import { n as getDaily } from "./daily.js";
3
3
  import { t as HUB_CLIENT_VERSION } from "./client-version.gen.js";
4
4
  import { n as getInput } from "./input.js";
5
5
  import { t as getUiStack } from "./page-stack.js";
6
- import { d as linkRealtimeToHub } from "./bridge.js";
6
+ import { f as linkRealtimeToHub } from "./bridge.js";
7
7
  import { t as multiplayerForwarder } from "./mp.js";
8
8
  import { t as raceForwarder } from "./race.js";
9
9
  import { t as roomsForwarder } from "./rooms.js";
@@ -48,6 +48,10 @@ function parseJsonText(text) {
48
48
  return;
49
49
  }
50
50
  }
51
+ /** Whether `candidate` is a JSON object (not an array or `null`). */
52
+ function isJsonObject(candidate) {
53
+ return isRecord(candidate) && Object.values(candidate).every(isJson);
54
+ }
51
55
  //#endregion
52
56
  //#region src/core/errors.ts
53
57
  /**
@@ -177,37 +181,212 @@ function createPageStore() {
177
181
  const storage = pageStorage();
178
182
  return storage === void 0 ? createMemoryStore() : storageBacked(storage);
179
183
  }
180
- /** The well-formed events of a stored buffer. */
181
- function readEvents(stored) {
182
- if (!Array.isArray(stored)) return [];
183
- return stored.flatMap((entry) => {
184
- if (!isRecord(entry)) return [];
185
- const type = optionalString(entry.type);
186
- const ts = optionalNumber(entry.ts);
187
- return type === void 0 || ts === void 0 ? [] : [{
188
- type,
189
- ts
190
- }];
191
- });
184
+ //#endregion
185
+ //#region src/core/offline/favorites.ts
186
+ /**
187
+ * Starred games are site-wide, not per game, so they live under one global
188
+ * key (`hub:favorites`, the v1 client's) rather than in a game's cache. The
189
+ * list mirrors the hub's so the menu still shows it offline.
190
+ */
191
+ /** The key of the mirrored favorites list. */
192
+ const FAVORITES_KEY = "hub:favorites";
193
+ /** The mirrored favorite slugs (empty when none or unreadable). */
194
+ function readLocalFavorites() {
195
+ const text = createPageStore().read(FAVORITES_KEY);
196
+ return text === null ? [] : stringsIn(parseJsonText(text));
192
197
  }
193
- /** The buffer with one more event, trimmed to {@link EVENT_BUFFER_CAP}: oldest heartbeats go first, then the oldest events. */
194
- function appendEvent(events, incoming) {
195
- let buffered = [...events, incoming];
196
- let excess = buffered.length - 100;
197
- if (excess <= 0) return buffered;
198
- buffered = buffered.filter((event) => {
199
- if (excess > 0 && event.type === "heartbeat") {
200
- excess -= 1;
201
- return false;
198
+ /** Mirror the hub's favorites list. */
199
+ function writeLocalFavorites(games) {
200
+ createPageStore().write(FAVORITES_KEY, JSON.stringify(games));
201
+ }
202
+ //#endregion
203
+ //#region src/core/game-data.ts
204
+ /** Always true: JSON the game stored is read back as the game's type. */
205
+ function isGameOwnData(_stored) {
206
+ return true;
207
+ }
208
+ /**
209
+ * A game's payload as the JSON the hub stores: a copy of exactly what
210
+ * `JSON.stringify` sends, so an optional field left `undefined` is simply
211
+ * absent (as on the wire) and the outbox's payload checks accept it, and a
212
+ * later change to the game's object can't alter a queued write.
213
+ */
214
+ function toStoredJson(payload) {
215
+ const stored = parseJsonText(JSON.stringify(payload));
216
+ if (isJson(stored)) return stored;
217
+ throw new TypeError("This value cannot be stored as JSON.");
218
+ }
219
+ /** JSON the game stored, typed as the game's own type. */
220
+ function toGameData(stored) {
221
+ if (isGameOwnData(stored)) return stored;
222
+ throw new TypeError("unreachable: stored game data is the game's own");
223
+ }
224
+ //#endregion
225
+ //#region src/core/offline/kind-adapters.ts
226
+ /** A kind whose payloads are checked by `read` (the hub's own kinds). */
227
+ function checkedKind(read, definition) {
228
+ const readOrThrow = (stored) => {
229
+ const payload = read(stored);
230
+ if (payload === void 0) throw new Error("Malformed offline operation payload.");
231
+ return payload;
232
+ };
233
+ const { mergeKey, merge } = definition;
234
+ return {
235
+ run: (stored, context) => definition.run(readOrThrow(stored), context),
236
+ accepts: (stored) => read(stored) !== void 0,
237
+ ...mergeKey === void 0 ? {} : { mergeKey: (stored) => mergeKey(readOrThrow(stored)) },
238
+ ...merge === void 0 ? {} : { merge: (waiting, incoming) => merge(readOrThrow(waiting), readOrThrow(incoming)) },
239
+ replaces: definition.replaces === true
240
+ };
241
+ }
242
+ /** A game's kind: a bare handler or a full definition, its payload typed by the game. */
243
+ function gameKind(definition) {
244
+ const full = typeof definition === "function" ? { run: definition } : definition;
245
+ const { mergeKey, merge } = full;
246
+ return {
247
+ run: (stored, context) => full.run(toGameData(stored), context),
248
+ accepts: () => true,
249
+ ...mergeKey === void 0 ? {} : { mergeKey: (stored) => mergeKey(toGameData(stored)) },
250
+ ...merge === void 0 ? {} : { merge: (waiting, incoming) => {
251
+ const merged = merge(toGameData(waiting), toGameData(incoming));
252
+ return merged === void 0 ? void 0 : toStoredJson(merged);
253
+ } },
254
+ replaces: full.replaces === true
255
+ };
256
+ }
257
+ //#endregion
258
+ //#region src/core/offline/hub-kinds/daily-kind.ts
259
+ /** The kind name. */
260
+ const DAILY_KIND = "hub.daily";
261
+ function readDaily(stored) {
262
+ if (!isRecord(stored) || !isJsonObject(stored.meta)) return;
263
+ const day = optionalString(stored.day);
264
+ const token = optionalString(stored.token);
265
+ return day === void 0 || token === void 0 ? void 0 : {
266
+ day,
267
+ token,
268
+ meta: stored.meta
269
+ };
270
+ }
271
+ /** A completion reply read back, or `undefined` when it doesn't read as one. */
272
+ function readDailyCompletion(result) {
273
+ if (!isRecord(result) || result.ok !== true) return;
274
+ const day = optionalString(result.day);
275
+ const count = optionalNumber(result.count);
276
+ const rank = result.rank === null ? null : optionalNumber(result.rank);
277
+ return day === void 0 || count === void 0 || rank === void 0 ? void 0 : {
278
+ ok: true,
279
+ day,
280
+ count,
281
+ rank
282
+ };
283
+ }
284
+ /** The daily kind, by name; `onCompleted` hears each completion that lands from the queue. */
285
+ function dailyKinds(game, onCompleted) {
286
+ return { [DAILY_KIND]: checkedKind(readDaily, { run: async ({ day, token, meta }, context) => {
287
+ let completion;
288
+ try {
289
+ completion = await context.call("completeDailyChallenge", {
290
+ ...game(),
291
+ day,
292
+ token,
293
+ meta
294
+ });
295
+ } catch (failure) {
296
+ if (!(failure instanceof HubError) || failure.status !== 401) throw failure;
297
+ const started = await context.call("startDailyChallenge", {
298
+ ...game(),
299
+ day
300
+ });
301
+ completion = await context.call("completeDailyChallenge", {
302
+ ...game(),
303
+ day,
304
+ token: started.token,
305
+ meta
306
+ });
202
307
  }
203
- return true;
204
- });
205
- return buffered.length > 100 ? buffered.slice(buffered.length - 100) : buffered;
308
+ if (context.isReplay) onCompleted({
309
+ day,
310
+ token,
311
+ meta
312
+ }, completion);
313
+ return completion;
314
+ } }) };
315
+ }
316
+ //#endregion
317
+ //#region src/core/offline/hub-kinds/economy-kinds.ts
318
+ /** The kind names. */
319
+ const ECONOMY_KINDS = {
320
+ grant: "hub.grant",
321
+ exchange: "hub.exchange",
322
+ favorite: "hub.favorite"
323
+ };
324
+ /** Goods read back, or `undefined` when malformed. */
325
+ function readGoods(stored) {
326
+ if (!isRecord(stored)) return;
327
+ const coins = stored.coins === void 0 ? void 0 : optionalNumber(stored.coins);
328
+ if (stored.coins !== void 0 && coins === void 0) return;
329
+ const items = {};
330
+ if (stored.items !== void 0) {
331
+ if (!isRecord(stored.items)) return;
332
+ for (const [key, qty] of Object.entries(stored.items)) {
333
+ const count = optionalNumber(qty);
334
+ if (count === void 0) return;
335
+ items[key] = count;
336
+ }
337
+ }
338
+ return {
339
+ ...stored.items === void 0 ? {} : { items },
340
+ ...coins === void 0 ? {} : { coins }
341
+ };
342
+ }
343
+ function readExchange(stored) {
344
+ if (!isRecord(stored)) return;
345
+ const take = stored.take === void 0 ? void 0 : readGoods(stored.take);
346
+ const give = stored.give === void 0 ? void 0 : readGoods(stored.give);
347
+ if (stored.take !== void 0 && take === void 0 || stored.give !== void 0 && give === void 0) return;
348
+ return {
349
+ ...take === void 0 ? {} : { take },
350
+ ...give === void 0 ? {} : { give }
351
+ };
352
+ }
353
+ function readFavorite(stored) {
354
+ if (!isRecord(stored) || typeof stored.on !== "boolean") return;
355
+ const slug = optionalString(stored.slug);
356
+ return slug === void 0 ? void 0 : {
357
+ slug,
358
+ on: stored.on
359
+ };
360
+ }
361
+ /** The economy kinds, by name; `onFavorites` hears the starred list each star answers with. */
362
+ function economyKinds(game, onFavorites) {
363
+ return {
364
+ [ECONOMY_KINDS.grant]: checkedKind(readGoods, { run: (goods, context) => context.call("grantInventoryItems", {
365
+ ...game(),
366
+ ...goods
367
+ }) }),
368
+ [ECONOMY_KINDS.exchange]: checkedKind(readExchange, { run: (exchange, context) => context.call("exchangeInventoryItems", {
369
+ ...game(),
370
+ ...exchange
371
+ }) }),
372
+ [ECONOMY_KINDS.favorite]: checkedKind(readFavorite, {
373
+ mergeKey: (favorite) => favorite.slug,
374
+ replaces: true,
375
+ run: async (favorite, context) => {
376
+ const reply = await context.call("setGameFavorite", {
377
+ slug: favorite.slug,
378
+ on: favorite.on
379
+ });
380
+ onFavorites(reply.games);
381
+ return reply;
382
+ }
383
+ })
384
+ };
206
385
  }
207
386
  //#endregion
208
387
  //#region src/core/offline/queue.ts
209
388
  /** The score options in a stored op, or `undefined` when they are malformed. */
210
- function readScoreOptions(stored) {
389
+ function readScoreOptions$1(stored) {
211
390
  const title = optionalString(stored.title);
212
391
  const sort = stored.sort === "asc" || stored.sort === "desc" ? stored.sort : void 0;
213
392
  if (stored.meta !== void 0 && !isJson(stored.meta)) return;
@@ -257,7 +436,7 @@ function readOpInput(stored) {
257
436
  }
258
437
  case "submitScore": {
259
438
  const score = optionalNumber(stored.score);
260
- const opts = isRecord(stored.opts) ? readScoreOptions(stored.opts) : void 0;
439
+ const opts = isRecord(stored.opts) ? readScoreOptions$1(stored.opts) : void 0;
261
440
  return key !== void 0 && score !== void 0 && opts !== void 0 ? {
262
441
  kind: "submitScore",
263
442
  key,
@@ -294,47 +473,310 @@ function readQueue(stored) {
294
473
  function betterScore(first, second, sort) {
295
474
  return sort === "asc" ? Math.min(first, second) : Math.max(first, second);
296
475
  }
297
- /** The op that `queued` becomes when `incoming` collapses into it, or `undefined` when they don't collapse. */
298
- function collapsedOp(queued, incoming) {
299
- if (queued.kind === "incrStat" && incoming.kind === "incrStat" && queued.key === incoming.key) return {
300
- ...queued,
301
- amount: queued.amount + incoming.amount
476
+ //#endregion
477
+ //#region src/core/offline/hub-kinds/progress-kinds.ts
478
+ /** The kind names. */
479
+ const PROGRESS_KINDS = {
480
+ save: "hub.save",
481
+ stat: "hub.stat",
482
+ score: "hub.score",
483
+ profile: "hub.profile"
484
+ };
485
+ function readSave$1(stored) {
486
+ if (!isRecord(stored)) return;
487
+ const slot = optionalString(stored.slot);
488
+ if (slot === void 0) return;
489
+ if (stored.deleted === true) return {
490
+ slot,
491
+ deleted: true
302
492
  };
303
- if (queued.kind === "maxStat" && incoming.kind === "maxStat" && queued.key === incoming.key) return {
304
- ...queued,
305
- value: Math.max(queued.value, incoming.value)
493
+ return isJson(stored.data) && isNullableString(stored.label) ? {
494
+ slot,
495
+ data: stored.data,
496
+ label: stored.label
497
+ } : void 0;
498
+ }
499
+ function readStat(stored) {
500
+ if (!isRecord(stored)) return;
501
+ const key = optionalString(stored.key);
502
+ const value = optionalNumber(stored.value);
503
+ const mode = stored.mode === "set" || stored.mode === "incr" || stored.mode === "max" ? stored.mode : void 0;
504
+ return key === void 0 || value === void 0 || mode === void 0 ? void 0 : {
505
+ key,
506
+ mode,
507
+ value
306
508
  };
307
- if (queued.kind === "setStat" && incoming.kind === "setStat" && queued.key === incoming.key) return {
308
- ...queued,
309
- value: incoming.value
509
+ }
510
+ /** Score options read back, or `undefined` when malformed. */
511
+ function readScoreOptions(stored) {
512
+ if (!isRecord(stored)) return;
513
+ const title = optionalString(stored.title);
514
+ const sort = stored.sort === "asc" || stored.sort === "desc" ? stored.sort : void 0;
515
+ if (stored.meta !== void 0 && !isJson(stored.meta)) return;
516
+ return {
517
+ ...title === void 0 ? {} : { title },
518
+ ...sort === void 0 ? {} : { sort },
519
+ ...stored.meta === void 0 ? {} : { meta: stored.meta }
310
520
  };
311
- if (queued.kind === "submitScore" && incoming.kind === "submitScore" && queued.key === incoming.key) return {
312
- ...queued,
313
- score: betterScore(queued.score, incoming.score, incoming.opts.sort),
314
- opts: incoming.opts
521
+ }
522
+ function readScore(stored) {
523
+ if (!isRecord(stored) || !isJson(stored.opts)) return;
524
+ const key = optionalString(stored.key);
525
+ const score = optionalNumber(stored.score);
526
+ const opts = readScoreOptions(stored.opts);
527
+ return key === void 0 || score === void 0 || opts === void 0 ? void 0 : {
528
+ key,
529
+ score,
530
+ opts
315
531
  };
316
- if (queued.kind === "setProfile" && incoming.kind === "setProfile") return {
317
- ...queued,
318
- patch: {
319
- ...queued.patch,
320
- ...incoming.patch
321
- }
532
+ }
533
+ /** A profile patch read back, or `undefined` when malformed. */
534
+ function readProfile$1(stored) {
535
+ if (!isRecord(stored)) return;
536
+ const { displayName, data: profileData } = stored;
537
+ if (displayName !== void 0 && !isNullableString(displayName) || profileData !== void 0 && !isJson(profileData)) return;
538
+ return {
539
+ ...displayName === void 0 ? {} : { displayName },
540
+ ...profileData === void 0 ? {} : { data: profileData }
322
541
  };
323
542
  }
324
- /** Whether an op writes save slot `slot`. */
325
- function writesSlot(op, slot) {
326
- return (op.kind === "putSave" || op.kind === "deleteSave") && op.slot === slot;
543
+ /** Two waiting stat updates of one key folded together, or `undefined` when their modes differ. */
544
+ function mergeStats(waiting, incoming) {
545
+ if (waiting.mode !== incoming.mode) return;
546
+ switch (incoming.mode) {
547
+ case "incr": return {
548
+ ...waiting,
549
+ value: waiting.value + incoming.value
550
+ };
551
+ case "max": return {
552
+ ...waiting,
553
+ value: Math.max(waiting.value, incoming.value)
554
+ };
555
+ case "set": return incoming;
556
+ }
327
557
  }
558
+ /** The progress kinds, by name. */
559
+ function progressKinds(game) {
560
+ return {
561
+ [PROGRESS_KINDS.save]: checkedKind(readSave$1, {
562
+ mergeKey: (save) => save.slot,
563
+ replaces: true,
564
+ run: async (save, context) => {
565
+ if ("deleted" in save) await context.call("deleteSave", {
566
+ ...game(),
567
+ slot: save.slot
568
+ });
569
+ else await context.call("writeSave", {
570
+ ...game(),
571
+ slot: save.slot,
572
+ data: save.data,
573
+ label: save.label
574
+ });
575
+ }
576
+ }),
577
+ [PROGRESS_KINDS.stat]: checkedKind(readStat, {
578
+ mergeKey: (stat) => stat.key,
579
+ merge: mergeStats,
580
+ run: async (stat, context) => {
581
+ await context.call("updateGameStat", {
582
+ ...game(),
583
+ key: stat.key,
584
+ value: stat.value,
585
+ mode: stat.mode
586
+ });
587
+ }
588
+ }),
589
+ [PROGRESS_KINDS.score]: checkedKind(readScore, {
590
+ mergeKey: (submission) => submission.key,
591
+ merge: (waiting, incoming) => ({
592
+ ...waiting,
593
+ score: betterScore(waiting.score, incoming.score, incoming.opts.sort),
594
+ opts: incoming.opts
595
+ }),
596
+ run: async (submission, context) => {
597
+ await context.call("submitScore", {
598
+ ...game(),
599
+ key: submission.key,
600
+ score: submission.score,
601
+ ...submission.opts
602
+ });
603
+ }
604
+ }),
605
+ [PROGRESS_KINDS.profile]: checkedKind(readProfile$1, {
606
+ mergeKey: () => "profile",
607
+ merge: (waiting, incoming) => ({
608
+ ...waiting,
609
+ ...incoming
610
+ }),
611
+ run: async (patch, context) => {
612
+ await context.call("setGameProfile", {
613
+ ...game(),
614
+ ...patch
615
+ });
616
+ }
617
+ })
618
+ };
619
+ }
620
+ //#endregion
621
+ //#region src/core/offline/hub-kinds/legacy-ops.ts
622
+ /** The outbox operation for a v1 queued write, or `undefined` when it no longer reads as one. */
623
+ function legacyOperation(op) {
624
+ switch (op.kind) {
625
+ case "putSave": return {
626
+ kind: PROGRESS_KINDS.save,
627
+ payload: {
628
+ slot: op.slot,
629
+ data: op.data,
630
+ label: op.label
631
+ }
632
+ };
633
+ case "deleteSave": return {
634
+ kind: PROGRESS_KINDS.save,
635
+ payload: {
636
+ slot: op.slot,
637
+ deleted: true
638
+ }
639
+ };
640
+ case "setStat": return {
641
+ kind: PROGRESS_KINDS.stat,
642
+ payload: {
643
+ key: op.key,
644
+ mode: "set",
645
+ value: op.value
646
+ }
647
+ };
648
+ case "maxStat": return {
649
+ kind: PROGRESS_KINDS.stat,
650
+ payload: {
651
+ key: op.key,
652
+ mode: "max",
653
+ value: op.value
654
+ }
655
+ };
656
+ case "incrStat": return {
657
+ kind: PROGRESS_KINDS.stat,
658
+ payload: {
659
+ key: op.key,
660
+ mode: "incr",
661
+ value: op.amount
662
+ }
663
+ };
664
+ case "submitScore": {
665
+ const opts = readScoreOptions({ ...op.opts });
666
+ return opts === void 0 ? void 0 : {
667
+ kind: PROGRESS_KINDS.score,
668
+ payload: {
669
+ key: op.key,
670
+ score: op.score,
671
+ opts
672
+ }
673
+ };
674
+ }
675
+ case "setProfile": {
676
+ const patch = readProfile$1({ ...op.patch });
677
+ return patch === void 0 ? void 0 : {
678
+ kind: PROGRESS_KINDS.profile,
679
+ payload: patch
680
+ };
681
+ }
682
+ }
683
+ }
684
+ //#endregion
685
+ //#region src/core/offline/hub-kinds/index.ts
328
686
  /**
329
- * The queue with `incoming` added: a save or delete replaces any queued
330
- * write to the same slot (and goes last); a compatible op merges into the
331
- * first queued one in place; anything else is appended.
687
+ * Every kind the hub itself queues offline (`hub.*`), registered on a
688
+ * client's outbox. The docs' "Offline and replay" table lists them.
332
689
  */
333
- function enqueueOp(queue, incoming) {
334
- if (incoming.kind === "putSave" || incoming.kind === "deleteSave") return [...queue.filter((op) => !writesSlot(op, incoming.slot)), incoming];
335
- const mergeAt = queue.findIndex((op) => collapsedOp(op, incoming) !== void 0);
336
- if (mergeAt < 0) return [...queue, incoming];
337
- return queue.map((op, i) => i === mergeAt ? collapsedOp(op, incoming) ?? op : op);
690
+ /** Register every hub kind on `outbox`. */
691
+ function registerHubKinds(outbox, hooks) {
692
+ const kinds = {
693
+ ...progressKinds(hooks.game),
694
+ ...dailyKinds(hooks.game, hooks.onDailyComplete),
695
+ ...economyKinds(hooks.game, writeLocalFavorites)
696
+ };
697
+ for (const [kind, definition] of Object.entries(kinds)) outbox.define(kind, definition);
698
+ }
699
+ /** The well-formed events of a stored buffer. */
700
+ function readEvents(stored) {
701
+ if (!Array.isArray(stored)) return [];
702
+ return stored.flatMap((entry) => {
703
+ if (!isRecord(entry)) return [];
704
+ const type = optionalString(entry.type);
705
+ const ts = optionalNumber(entry.ts);
706
+ return type === void 0 || ts === void 0 ? [] : [{
707
+ type,
708
+ ts
709
+ }];
710
+ });
711
+ }
712
+ /** The buffer with one more event, trimmed to {@link EVENT_BUFFER_CAP}: oldest heartbeats go first, then the oldest events. */
713
+ function appendEvent(events, incoming) {
714
+ let buffered = [...events, incoming];
715
+ let excess = buffered.length - 100;
716
+ if (excess <= 0) return buffered;
717
+ buffered = buffered.filter((event) => {
718
+ if (excess > 0 && event.type === "heartbeat") {
719
+ excess -= 1;
720
+ return false;
721
+ }
722
+ return true;
723
+ });
724
+ return buffered.length > 100 ? buffered.slice(buffered.length - 100) : buffered;
725
+ }
726
+ //#endregion
727
+ //#region src/core/offline/local-inventory.ts
728
+ /** An empty inventory. */
729
+ const EMPTY_INVENTORY = {
730
+ items: [],
731
+ coins: 0
732
+ };
733
+ /** A cached inventory, or `null` when there is none or it is malformed. */
734
+ function readInventory(stored) {
735
+ if (!isRecord(stored) || !Array.isArray(stored.items)) return null;
736
+ const coins = optionalNumber(stored.coins);
737
+ const items = stored.items.flatMap((item) => {
738
+ const key = isRecord(item) ? optionalString(item.key) : void 0;
739
+ const qty = isRecord(item) ? optionalNumber(item.qty) : void 0;
740
+ return key === void 0 || qty === void 0 ? [] : [{
741
+ key,
742
+ qty
743
+ }];
744
+ });
745
+ return coins === void 0 ? null : {
746
+ items,
747
+ coins
748
+ };
749
+ }
750
+ /** The holdings as a key-to-quantity map. */
751
+ function quantities(inventory) {
752
+ return new Map(inventory.items.map((item) => [item.key, item.qty]));
753
+ }
754
+ /** The holdings from a map (positive counts only, sorted by key). */
755
+ function fromQuantities(held, coins) {
756
+ return {
757
+ items: [...held].filter(([, qty]) => qty > 0).sort(([first], [second]) => first.localeCompare(second)).map(([key, qty]) => ({
758
+ key,
759
+ qty
760
+ })),
761
+ coins
762
+ };
763
+ }
764
+ /** `inventory` with `goods` added. */
765
+ function withGoods(inventory, goods) {
766
+ const held = quantities(inventory);
767
+ for (const [key, qty] of Object.entries(goods.items ?? {})) held.set(key, (held.get(key) ?? 0) + qty);
768
+ return fromQuantities(held, inventory.coins + (goods.coins ?? 0));
769
+ }
770
+ /** `inventory` with `goods` removed, or the hub's refusal message when it can't cover them. */
771
+ function withoutGoods(inventory, goods) {
772
+ if ((goods.coins ?? 0) > inventory.coins) return "You don't have enough coins.";
773
+ const held = quantities(inventory);
774
+ for (const [key, qty] of Object.entries(goods.items ?? {})) {
775
+ const have = held.get(key) ?? 0;
776
+ if (qty > have) return `You don't have ${String(qty)}× "${key}".`;
777
+ held.set(key, have - qty);
778
+ }
779
+ return fromQuantities(held, inventory.coins - (goods.coins ?? 0));
338
780
  }
339
781
  //#endregion
340
782
  //#region src/core/offline/local-store.ts
@@ -400,7 +842,6 @@ var LocalStore = class {
400
842
  constructor(slug, store = createPageStore()) {
401
843
  _defineProperty(this, "prefix", void 0);
402
844
  _defineProperty(this, "store", void 0);
403
- _defineProperty(this, "sequence", 0);
404
845
  this.prefix = `hub:${slug}:`;
405
846
  this.store = store;
406
847
  }
@@ -514,27 +955,20 @@ var LocalStore = class {
514
955
  best
515
956
  };
516
957
  }
517
- /** The queued writes, oldest first. */
518
- getQueue() {
519
- return readQueue(this.readStored(`${this.prefix}queue`));
958
+ /** The v1 client's queued writes, removed from storage (the caller moves them into the outbox). */
959
+ takeLegacyQueue() {
960
+ const queue = readQueue(this.readStored(`${this.prefix}queue`));
961
+ this.store.remove(`${this.prefix}queue`);
962
+ return queue;
520
963
  }
521
- /** Replace the queue. */
522
- setQueue(queue) {
523
- this.writeStored(`${this.prefix}queue`, queue);
524
- }
525
- /** How many writes are queued. */
526
- queueLength() {
527
- return this.getQueue().length;
964
+ /** This game's cached inventory (empty when none is cached). */
965
+ getInventory() {
966
+ return readInventory(this.readStored(`${this.prefix}inventory`)) ?? EMPTY_INVENTORY;
528
967
  }
529
- /** Queue a write, collapsing it with a compatible queued one. */
530
- enqueue(input) {
531
- this.sequence += 1;
532
- const now = Date.now();
533
- this.setQueue(enqueueOp(this.getQueue(), {
534
- ...input,
535
- id: `${String(now)}-${String(this.sequence)}`,
536
- ts: now
537
- }));
968
+ /** Store this game's inventory and return it. */
969
+ setInventory(inventory) {
970
+ this.writeStored(`${this.prefix}inventory`, inventory);
971
+ return inventory;
538
972
  }
539
973
  /** The buffered analytics events, oldest first. */
540
974
  getEvents() {
@@ -574,6 +1008,574 @@ var LocalStore = class {
574
1008
  }
575
1009
  };
576
1010
  //#endregion
1011
+ //#region src/core/offline/outbox-kinds.ts
1012
+ /** The prefix reserved for the hub's own kinds. */
1013
+ const HUB_KIND_PREFIX = "hub.";
1014
+ /** The registered kinds of one client. */
1015
+ var KindRegistry = class {
1016
+ constructor() {
1017
+ _defineProperty(this, "kinds", /* @__PURE__ */ new Map());
1018
+ }
1019
+ /** Register (or replace) a kind. */
1020
+ register(kind, definition) {
1021
+ this.kinds.set(kind, definition);
1022
+ }
1023
+ /** A registered kind, or `undefined`. */
1024
+ get(kind) {
1025
+ return this.kinds.get(kind);
1026
+ }
1027
+ };
1028
+ //#endregion
1029
+ //#region src/core/offline/offline-api.ts
1030
+ /**
1031
+ * `hub.offline`: the outbox, opened to games. A game defines its own kinds
1032
+ * (a handler per kind, which may make several dependent calls) and enqueues
1033
+ * operations of them; the hub queues them in order, replays them when it is
1034
+ * reachable, and reports what happened.
1035
+ */
1036
+ /** The game-facing outbox API. */
1037
+ var OfflineApi = class {
1038
+ /** The API over `host`. */
1039
+ constructor(host) {
1040
+ _defineProperty(this, "host", void 0);
1041
+ this.host = host;
1042
+ }
1043
+ /**
1044
+ * Define what an operation of `kind` does when it replays. Define every
1045
+ * kind at load, before or after enqueueing: waiting ops of an undefined
1046
+ * kind hold the queue until it is defined. `hub.*` kinds are the hub's own.
1047
+ */
1048
+ define(kind, definition) {
1049
+ if (kind.startsWith("hub.") || kind === "") throw new HubError(`Offline kinds starting with "${HUB_KIND_PREFIX}" are the hub's own; name yours after your game.`);
1050
+ this.host.outbox.define(kind, gameKind(definition));
1051
+ this.host.requestSync();
1052
+ }
1053
+ /**
1054
+ * Queue an operation of `kind` and replay it as soon as the hub is
1055
+ * reachable (at once when online). Resolves the queued op; its outcome
1056
+ * arrives as a `replayed` or `failed` event. Throws a `HubError` (code
1057
+ * `offline_queue_full`) when the outbox is full.
1058
+ */
1059
+ enqueue(kind, payload) {
1060
+ const op = this.host.outbox.stamp(kind, toStoredJson(payload));
1061
+ this.host.outbox.add(op);
1062
+ this.host.requestSync();
1063
+ return op;
1064
+ }
1065
+ /** The waiting operations (the hub's and the game's), oldest first. */
1066
+ pending() {
1067
+ return this.host.outbox.pending();
1068
+ }
1069
+ /** Listen for `queued`, `replayed` or `failed`; returns the unsubscribe function. */
1070
+ on(name, listener) {
1071
+ return this.host.outbox.on(name, listener);
1072
+ }
1073
+ };
1074
+ //#endregion
1075
+ //#region src/core/offline/outbox-entry.ts
1076
+ /** How wide the time part of an id is (enough for epoch ms until the year 5138). */
1077
+ const TIME_DIGITS = 14;
1078
+ /** How wide the sequence part of an id is. */
1079
+ const SEQUENCE_DIGITS = 6;
1080
+ /**
1081
+ * A new entry id: the queue time, a per-page sequence and a random tag, so
1082
+ * ids sort by time and two tabs queueing in the same millisecond never clash.
1083
+ */
1084
+ function newEntryId(now, sequence) {
1085
+ const tag = Math.floor(Math.random() * 36 ** 6).toString(36).padStart(6, "0");
1086
+ return `${String(now).padStart(TIME_DIGITS, "0")}-${String(sequence % 10 ** SEQUENCE_DIGITS).padStart(SEQUENCE_DIGITS, "0")}-${tag}`;
1087
+ }
1088
+ /** A stored entry, or `undefined` when it is malformed. */
1089
+ function readEntry(stored) {
1090
+ if (!isRecord(stored) || !isJson(stored.payload)) return;
1091
+ const id = optionalString(stored.id);
1092
+ const kind = optionalString(stored.kind);
1093
+ const createdAt = optionalNumber(stored.createdAt);
1094
+ const attempts = optionalNumber(stored.attempts);
1095
+ const retryAt = optionalNumber(stored.retryAt);
1096
+ const lastError = optionalString(stored.lastError);
1097
+ if (id === void 0 || kind === void 0 || createdAt === void 0 || attempts === void 0 || retryAt === void 0) return;
1098
+ return {
1099
+ id,
1100
+ kind,
1101
+ payload: stored.payload,
1102
+ createdAt,
1103
+ attempts,
1104
+ retryAt,
1105
+ ...lastError === void 0 ? {} : { lastError }
1106
+ };
1107
+ }
1108
+ //#endregion
1109
+ //#region src/core/offline/outbox-merge.ts
1110
+ /** How `incoming` (of kind `kind`) joins `entries` (oldest first). */
1111
+ function planEnqueue(entries, incoming, kind) {
1112
+ const separately = {
1113
+ put: incoming,
1114
+ remove: [],
1115
+ isMerged: false
1116
+ };
1117
+ const { mergeKey } = kind;
1118
+ if (mergeKey === void 0) return separately;
1119
+ const key = mergeKey(incoming.payload);
1120
+ const sameKey = entries.filter((entry) => entry.kind === incoming.kind && kind.accepts(entry.payload) && mergeKey(entry.payload) === key);
1121
+ if (kind.replaces) return {
1122
+ put: incoming,
1123
+ remove: sameKey.filter((entry) => entry.attempts === 0).map((entry) => entry.id),
1124
+ isMerged: false
1125
+ };
1126
+ const newest = sameKey.at(-1);
1127
+ if (kind.merge === void 0 || newest === void 0 || newest.attempts > 0) return separately;
1128
+ const merged = kind.merge(newest.payload, incoming.payload);
1129
+ return merged === void 0 ? separately : {
1130
+ put: {
1131
+ ...newest,
1132
+ payload: merged
1133
+ },
1134
+ remove: [],
1135
+ isMerged: true
1136
+ };
1137
+ }
1138
+ //#endregion
1139
+ //#region src/core/offline/replay-policy.ts
1140
+ /**
1141
+ * What a failed replay does next, and how long it waits:
1142
+ *
1143
+ * - `offline`: the hub (or the network) can't be reached. The op stays at
1144
+ * the head of the queue and replay waits for the connection.
1145
+ * - `retry`: the hub answered with a passing problem (408, 429, 5xx) or a
1146
+ * game handler's fetch failed. The op stays at the head and is retried with
1147
+ * backoff, up to {@link MAX_RETRIES} times, then dropped.
1148
+ * - `drop`: the hub refused it (any other status) or the handler threw
1149
+ * something else. The op is dropped and reported, and the queue moves on.
1150
+ */
1151
+ /** The first retry's delay. */
1152
+ const BASE_DELAY_MS = 1e3;
1153
+ /** The longest delay between tries. */
1154
+ const MAX_DELAY_MS = 6e4;
1155
+ /** Statuses that mean "try again later". */
1156
+ const PASSING_STATUSES = /* @__PURE__ */ new Set([
1157
+ 408,
1158
+ 425,
1159
+ 429
1160
+ ]);
1161
+ /** The verdict for a failure. */
1162
+ function verdictFor(failure) {
1163
+ if (failure instanceof HubError) {
1164
+ if (failure.status === void 0) return "offline";
1165
+ return failure.status >= 500 || PASSING_STATUSES.has(failure.status) ? "retry" : "drop";
1166
+ }
1167
+ return failure instanceof TypeError ? "retry" : "drop";
1168
+ }
1169
+ /** The delay before try `attempts + 1`: doubling from 1 s, capped at 60 s, with jitter so tabs and players spread out. */
1170
+ function backoffMs(attempts, random = Math.random) {
1171
+ const ceiling = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** Math.max(0, attempts - 1));
1172
+ return Math.round(ceiling / 2 + ceiling / 2 * random());
1173
+ }
1174
+ /** A failure's message, for reports. */
1175
+ function failureMessage(failure) {
1176
+ return failure instanceof Error ? failure.message : String(failure);
1177
+ }
1178
+ //#endregion
1179
+ //#region src/core/offline/outbox-replayer.ts
1180
+ /** A report for a replay that didn't get the lease. */
1181
+ const LEASE_HELD = {
1182
+ replayed: [],
1183
+ failed: [],
1184
+ stoppedBy: "lease"
1185
+ };
1186
+ /** Replays one game's outbox. */
1187
+ var OutboxReplayer = class {
1188
+ /** A replayer over `parts`. */
1189
+ constructor(parts) {
1190
+ _defineProperty(this, "parts", void 0);
1191
+ this.parts = parts;
1192
+ }
1193
+ /** Replay everything due, in order. `isEager` skips the backoff of ops waiting for the connection (it just came back). */
1194
+ async replay(isEager = false) {
1195
+ let report = LEASE_HELD;
1196
+ return await this.parts.lease.hold(async () => {
1197
+ report = await this.drain(isEager);
1198
+ }) ? report : LEASE_HELD;
1199
+ }
1200
+ async drain(isEager) {
1201
+ const { store, kinds, now } = this.parts;
1202
+ const replayed = [];
1203
+ const failed = [];
1204
+ for (let head = store.list()[0]; head !== void 0; head = store.list()[0]) {
1205
+ if (head.retryAt > now() && !(isEager && head.lastError === void 0)) {
1206
+ this.parts.scheduleRetry(head.retryAt - now());
1207
+ return {
1208
+ replayed,
1209
+ failed,
1210
+ stoppedBy: "backoff"
1211
+ };
1212
+ }
1213
+ const kind = kinds.get(head.kind);
1214
+ if (kind === void 0) return {
1215
+ replayed,
1216
+ failed,
1217
+ stoppedBy: "unknown-kind"
1218
+ };
1219
+ if (!kind.accepts(head.payload)) {
1220
+ store.remove(head.id);
1221
+ this.drop(failed, head, "Malformed offline operation payload.");
1222
+ continue;
1223
+ }
1224
+ const started = {
1225
+ ...head,
1226
+ attempts: head.attempts + 1
1227
+ };
1228
+ store.put(started);
1229
+ try {
1230
+ const result = await kind.run(head.payload, this.parts.contextFor(head, started.attempts));
1231
+ store.remove(head.id);
1232
+ replayed.push(started);
1233
+ this.parts.onReplayed(started, result ?? void 0);
1234
+ } catch (failure) {
1235
+ const stop = this.settleFailure(started, failure, failed);
1236
+ if (stop !== void 0) return {
1237
+ replayed,
1238
+ failed,
1239
+ stoppedBy: stop
1240
+ };
1241
+ }
1242
+ }
1243
+ return {
1244
+ replayed,
1245
+ failed
1246
+ };
1247
+ }
1248
+ /** Record a failed try; answers why replay stops, or `undefined` to carry on with the next op. */
1249
+ settleFailure(started, failure, failed) {
1250
+ const { store, now } = this.parts;
1251
+ const verdict = verdictFor(failure);
1252
+ const message = failureMessage(failure);
1253
+ if (verdict === "drop" || verdict === "retry" && started.attempts >= 8) {
1254
+ store.remove(started.id);
1255
+ this.drop(failed, started, message);
1256
+ return;
1257
+ }
1258
+ const delay = backoffMs(started.attempts);
1259
+ const { id, kind, payload, createdAt, attempts } = started;
1260
+ const retrying = {
1261
+ id,
1262
+ kind,
1263
+ payload,
1264
+ createdAt,
1265
+ attempts,
1266
+ retryAt: now() + delay
1267
+ };
1268
+ const waiting = verdict === "offline" ? retrying : {
1269
+ ...retrying,
1270
+ lastError: message
1271
+ };
1272
+ store.put(waiting);
1273
+ if (verdict === "offline") this.parts.onUnreachable();
1274
+ this.parts.scheduleRetry(delay);
1275
+ return verdict === "offline" ? "connection" : "backoff";
1276
+ }
1277
+ drop(failed, entry, error) {
1278
+ const failure = {
1279
+ entry,
1280
+ error
1281
+ };
1282
+ failed.push(failure);
1283
+ this.parts.onFailed(failure);
1284
+ }
1285
+ };
1286
+ //#endregion
1287
+ //#region src/core/offline/outbox-store.ts
1288
+ /**
1289
+ * Where the outbox lives: one `localStorage` key per entry,
1290
+ * `hub:<slug>:outbox:<id>`, read back in id (queue) order. A tab adding an
1291
+ * entry and another removing one touch different keys, so neither loses the
1292
+ * other's change. The outbox is capped by entry count and stored size.
1293
+ */
1294
+ /** The most entries one game's outbox holds. */
1295
+ const OUTBOX_MAX_ENTRIES = 500;
1296
+ /** The most stored characters one game's outbox holds (about 2 MB of `localStorage`). */
1297
+ const OUTBOX_MAX_CHARS = 1e6;
1298
+ /** One game's outbox. */
1299
+ var OutboxStore = class {
1300
+ /** The outbox for game `slug` in `store`. */
1301
+ constructor(slug, store) {
1302
+ _defineProperty(this, "prefix", void 0);
1303
+ _defineProperty(this, "store", void 0);
1304
+ this.prefix = `hub:${slug}:outbox:`;
1305
+ this.store = store;
1306
+ }
1307
+ /** Every well-formed entry, oldest first. Malformed ones are removed. */
1308
+ list() {
1309
+ const entries = [];
1310
+ for (const key of this.entryKeys()) {
1311
+ const text = this.store.read(key);
1312
+ const entry = text === null ? void 0 : readEntry(parseJsonText(text));
1313
+ if (entry === void 0) this.store.remove(key);
1314
+ else entries.push(entry);
1315
+ }
1316
+ return entries;
1317
+ }
1318
+ /** How many entries are queued. */
1319
+ count() {
1320
+ return this.entryKeys().length;
1321
+ }
1322
+ /** One entry, or `undefined` once it is gone (replayed, or removed by another tab). */
1323
+ get(id) {
1324
+ const text = this.store.read(this.prefix + id);
1325
+ return text === null ? void 0 : readEntry(parseJsonText(text));
1326
+ }
1327
+ /** Whether `extra` more stored characters in one more entry still fit under the caps. */
1328
+ fits(extraChars) {
1329
+ const keys = this.entryKeys();
1330
+ if (keys.length >= 500) return false;
1331
+ return keys.reduce((total, key) => total + (this.store.read(key)?.length ?? 0), 0) + extraChars <= OUTBOX_MAX_CHARS;
1332
+ }
1333
+ /** Store an entry (new or updated); `false` when the storage refused it (full or blocked). */
1334
+ put(entry) {
1335
+ const text = JSON.stringify(entry);
1336
+ this.store.write(this.prefix + entry.id, text);
1337
+ return this.store.read(this.prefix + entry.id) === text;
1338
+ }
1339
+ /** Forget an entry. */
1340
+ remove(id) {
1341
+ this.store.remove(this.prefix + id);
1342
+ }
1343
+ /** Whether a storage key belongs to this outbox (for cross-tab `storage` events). */
1344
+ owns(key) {
1345
+ return key === null || key.startsWith(this.prefix);
1346
+ }
1347
+ entryKeys() {
1348
+ return this.store.keys().filter((key) => key.startsWith(this.prefix)).sort();
1349
+ }
1350
+ };
1351
+ //#endregion
1352
+ //#region src/core/offline/replay-lease.ts
1353
+ /**
1354
+ * One replayer at a time per game, across tabs and across the client copies
1355
+ * on one page: a Web Lock where the browser has them, else a lease in
1356
+ * `localStorage` that its holder renews while it works and that lapses if
1357
+ * the tab dies. A replayer that can't get it simply doesn't run; the holder
1358
+ * replays for everyone.
1359
+ */
1360
+ /** How long a `localStorage` lease lasts without renewal. */
1361
+ const STORAGE_LEASE_MS = 15e3;
1362
+ /** How often the holder renews it. */
1363
+ const RENEW_EVERY_MS = 5e3;
1364
+ /** A lease on the Web Locks API. */
1365
+ function webLockLease(locks, name) {
1366
+ return { hold: (work) => locks.request(name, { ifAvailable: true }, async (lock) => {
1367
+ if (lock === null) return false;
1368
+ await work();
1369
+ return true;
1370
+ }) };
1371
+ }
1372
+ /** The lease's holder and expiry, or `undefined` when there is none. */
1373
+ function readLease(text) {
1374
+ const stored = text === null ? void 0 : parseJsonText(text);
1375
+ if (!isRecord(stored)) return;
1376
+ const owner = optionalString(stored.owner);
1377
+ const until = optionalNumber(stored.until);
1378
+ return owner === void 0 || until === void 0 ? void 0 : {
1379
+ owner,
1380
+ until
1381
+ };
1382
+ }
1383
+ /** A lease kept in `store` under `key`, for pages without Web Locks. */
1384
+ function storageLease(store, key, now = Date.now) {
1385
+ const owner = `${String(now())}-${Math.random().toString(36).slice(2)}`;
1386
+ const claim = () => {
1387
+ const current = readLease(store.read(key));
1388
+ if (current !== void 0 && current.owner !== owner && current.until > now()) return false;
1389
+ store.write(key, JSON.stringify({
1390
+ owner,
1391
+ until: now() + STORAGE_LEASE_MS
1392
+ }));
1393
+ return readLease(store.read(key))?.owner === owner;
1394
+ };
1395
+ return { async hold(work) {
1396
+ if (!claim()) return false;
1397
+ const renewal = setInterval(claim, RENEW_EVERY_MS);
1398
+ try {
1399
+ await work();
1400
+ } finally {
1401
+ clearInterval(renewal);
1402
+ if (readLease(store.read(key))?.owner === owner) store.remove(key);
1403
+ }
1404
+ return true;
1405
+ } };
1406
+ }
1407
+ /** The page's Web Locks, when it has them. */
1408
+ function pageLocks() {
1409
+ return typeof navigator !== "undefined" && "locks" in navigator ? navigator.locks : void 0;
1410
+ }
1411
+ /** The lease for game `slug`'s outbox: a Web Lock when available, else one in `store`. */
1412
+ function createReplayLease(slug, store) {
1413
+ const name = `hub:${slug}:outbox-lease`;
1414
+ const locks = pageLocks();
1415
+ return locks === void 0 ? storageLease(store, name) : webLockLease(locks, name);
1416
+ }
1417
+ //#endregion
1418
+ //#region src/core/offline/outbox.ts
1419
+ /** The code of the error `add` throws when the outbox is full. */
1420
+ const OUTBOX_FULL_CODE = "offline_queue_full";
1421
+ /** How long a replayer that lost the lease waits before checking again. */
1422
+ const LEASE_RECHECK_MS = 3e3;
1423
+ /** One game's outbox. */
1424
+ var Outbox = class {
1425
+ /** The outbox for `options.slug`. */
1426
+ constructor(options) {
1427
+ _defineProperty(this, "kinds", new KindRegistry());
1428
+ _defineProperty(this, "entries", void 0);
1429
+ _defineProperty(this, "options", void 0);
1430
+ _defineProperty(this, "replayer", void 0);
1431
+ _defineProperty(this, "now", void 0);
1432
+ _defineProperty(this, "sequence", 0);
1433
+ _defineProperty(this, "retryTimer", void 0);
1434
+ _defineProperty(this, "retryAt", Number.POSITIVE_INFINITY);
1435
+ _defineProperty(this, "listeners", {
1436
+ queued: /* @__PURE__ */ new Set(),
1437
+ replayed: /* @__PURE__ */ new Set(),
1438
+ failed: /* @__PURE__ */ new Set()
1439
+ });
1440
+ this.options = options;
1441
+ this.now = options.now ?? Date.now;
1442
+ this.entries = new OutboxStore(options.slug, options.store);
1443
+ this.replayer = new OutboxReplayer({
1444
+ store: this.entries,
1445
+ kinds: this.kinds,
1446
+ lease: createReplayLease(options.slug, options.store),
1447
+ contextFor: (entry, attempt) => this.contextFor(entry, attempt, true),
1448
+ onReplayed: (op, result) => {
1449
+ this.fire("replayed", result === void 0 ? { op } : {
1450
+ op,
1451
+ result
1452
+ });
1453
+ },
1454
+ onFailed: ({ entry, error }) => {
1455
+ this.fire("failed", {
1456
+ op: entry,
1457
+ error
1458
+ });
1459
+ },
1460
+ onUnreachable: options.onUnreachable,
1461
+ scheduleRetry: (delayMs) => {
1462
+ this.scheduleRetry(delayMs);
1463
+ },
1464
+ now: this.now
1465
+ });
1466
+ }
1467
+ /** Register a kind. */
1468
+ define(kind, definition) {
1469
+ this.kinds.register(kind, definition);
1470
+ }
1471
+ /** A new, not yet stored, op of `kind`. */
1472
+ stamp(kind, payload) {
1473
+ this.sequence += 1;
1474
+ const now = this.now();
1475
+ return {
1476
+ id: newEntryId(now, this.sequence),
1477
+ kind,
1478
+ payload,
1479
+ createdAt: now,
1480
+ attempts: 0,
1481
+ retryAt: 0
1482
+ };
1483
+ }
1484
+ /**
1485
+ * Queue an op, folding it into a waiting one where its kind says so.
1486
+ * Throws a {@link HubError} (code {@link OUTBOX_FULL_CODE}) when the
1487
+ * outbox is full or the storage refuses it.
1488
+ */
1489
+ add(op) {
1490
+ const kind = this.kinds.get(op.kind);
1491
+ const plan = kind === void 0 ? {
1492
+ put: op,
1493
+ remove: [],
1494
+ isMerged: false
1495
+ } : planEnqueue(this.entries.list(), op, kind);
1496
+ const extra = plan.isMerged ? 0 : JSON.stringify(plan.put).length;
1497
+ if (plan.remove.length === 0 && !plan.isMerged && !this.entries.fits(extra)) throw outboxFull();
1498
+ if (!this.entries.put(plan.put)) throw outboxFull();
1499
+ for (const id of plan.remove) this.entries.remove(id);
1500
+ this.fire("queued", { op: plan.put });
1501
+ this.options.onChange();
1502
+ }
1503
+ /** Perform an op now, without queueing it (the online path); throws what its handler throws. */
1504
+ async runNow(op) {
1505
+ const kind = this.kinds.get(op.kind);
1506
+ if (kind === void 0) throw new HubError(`No offline kind "${op.kind}" is defined.`);
1507
+ return await kind.run(op.payload, this.contextFor(op, 1, false)) ?? void 0;
1508
+ }
1509
+ /** Replay what is due, in order. `isEager`: the connection just came back, so ops waiting for it go at once. */
1510
+ async replay(isEager = false) {
1511
+ this.clearRetry();
1512
+ const report = await this.replayer.replay(isEager);
1513
+ if (report.stoppedBy === "lease" && this.entries.count() > 0 || report.stoppedBy === "unknown-kind") this.scheduleRetry(report.stoppedBy === "lease" ? LEASE_RECHECK_MS : backoffMs(this.entries.list()[0]?.attempts ?? 1));
1514
+ this.options.onChange();
1515
+ return report;
1516
+ }
1517
+ /** The waiting ops, oldest first. */
1518
+ pending() {
1519
+ return this.entries.list();
1520
+ }
1521
+ /** How many ops are waiting. */
1522
+ count() {
1523
+ return this.entries.count();
1524
+ }
1525
+ /** Listen for an event; returns the unsubscribe function. */
1526
+ on(name, listener) {
1527
+ const listeners = this.listeners[name];
1528
+ listeners.add(listener);
1529
+ return () => {
1530
+ listeners.delete(listener);
1531
+ };
1532
+ }
1533
+ fire(name, detail) {
1534
+ const listeners = this.listeners[name];
1535
+ for (const listener of listeners) try {
1536
+ listener(detail);
1537
+ } catch {}
1538
+ this.options.emit(`hub:offline-${name}`, detail);
1539
+ }
1540
+ contextFor(op, attempt, isReplay) {
1541
+ const { rest } = this.options;
1542
+ let step = 0;
1543
+ return {
1544
+ opId: op.id,
1545
+ attempt,
1546
+ isReplay,
1547
+ key: (stepName) => `${op.id}.${stepName}`,
1548
+ call: (name, ...fieldsArgument) => {
1549
+ const opKey = `${op.id}.${String(step)}`;
1550
+ step += 1;
1551
+ return rest.callOnce(opKey, name, ...fieldsArgument);
1552
+ }
1553
+ };
1554
+ }
1555
+ scheduleRetry(delayMs) {
1556
+ const at = this.now() + delayMs;
1557
+ if (at >= this.retryAt) return;
1558
+ this.clearRetry();
1559
+ this.retryAt = at;
1560
+ this.retryTimer = setTimeout(() => {
1561
+ this.retryTimer = void 0;
1562
+ this.retryAt = Number.POSITIVE_INFINITY;
1563
+ this.options.onRetryDue();
1564
+ }, delayMs);
1565
+ }
1566
+ clearRetry() {
1567
+ if (this.retryTimer !== void 0) {
1568
+ clearTimeout(this.retryTimer);
1569
+ this.retryTimer = void 0;
1570
+ }
1571
+ this.retryAt = Number.POSITIVE_INFINITY;
1572
+ }
1573
+ };
1574
+ /** The error for a full outbox. */
1575
+ function outboxFull() {
1576
+ return new HubError("Too many changes are waiting to sync. Reconnect to save more.", 507, { code: OUTBOX_FULL_CODE });
1577
+ }
1578
+ //#endregion
577
1579
  //#region src/core/page.ts
578
1580
  /** Whether this code runs in a browser page. */
579
1581
  function isBrowserPage() {
@@ -614,427 +1616,547 @@ function emitWindowEvent(eventName, detail) {
614
1616
  }
615
1617
  //#endregion
616
1618
  //#region src/core/rest/routes.gen.ts
617
- /** Every public REST endpoint's method, path (under `/_api`) and query keys, from the contract. */
1619
+ /** Every public REST endpoint's method, path (under `/_api`), query keys and offline mode (`'replay'` when it may be queued offline), from the contract. */
618
1620
  const ROUTES = {
619
1621
  ensureGuestSession: {
620
1622
  method: "POST",
621
1623
  path: "/auth/guest",
622
- query: []
1624
+ query: [],
1625
+ offline: null
623
1626
  },
624
1627
  signUpWithPassword: {
625
1628
  method: "POST",
626
1629
  path: "/auth/signup",
627
- query: []
1630
+ query: [],
1631
+ offline: null
628
1632
  },
629
1633
  logInWithPassword: {
630
1634
  method: "POST",
631
1635
  path: "/auth/login",
632
- query: []
1636
+ query: [],
1637
+ offline: null
633
1638
  },
634
1639
  logOut: {
635
1640
  method: "POST",
636
1641
  path: "/auth/logout",
637
- query: []
1642
+ query: [],
1643
+ offline: null
638
1644
  },
639
1645
  getCurrentUser: {
640
1646
  method: "GET",
641
1647
  path: "/me",
642
- query: []
1648
+ query: [],
1649
+ offline: null
643
1650
  },
644
1651
  claimGuestAccount: {
645
1652
  method: "POST",
646
1653
  path: "/auth/claim",
647
- query: []
1654
+ query: [],
1655
+ offline: null
1656
+ },
1657
+ getGamePlayerCounts: {
1658
+ method: "GET",
1659
+ path: "/catalog/players",
1660
+ query: [],
1661
+ offline: null
1662
+ },
1663
+ getMyGameActivity: {
1664
+ method: "GET",
1665
+ path: "/games/:slug/me/activity",
1666
+ query: [],
1667
+ offline: null
648
1668
  },
649
1669
  listGames: {
650
1670
  method: "GET",
651
1671
  path: "/games",
652
- query: []
1672
+ query: [],
1673
+ offline: null
653
1674
  },
654
1675
  listWhatsNewNotes: {
655
1676
  method: "GET",
656
1677
  path: "/changes",
657
- query: ["game", "since"]
1678
+ query: ["game", "since"],
1679
+ offline: null
658
1680
  },
659
1681
  listFavoriteGames: {
660
1682
  method: "GET",
661
1683
  path: "/favorites",
662
- query: []
1684
+ query: [],
1685
+ offline: null
663
1686
  },
664
1687
  setGameFavorite: {
665
1688
  method: "PUT",
666
1689
  path: "/favorites/:slug",
667
- query: []
1690
+ query: [],
1691
+ offline: "replay"
668
1692
  },
669
1693
  markWhatsNewSeen: {
670
1694
  method: "POST",
671
1695
  path: "/changes/seen",
672
- query: []
1696
+ query: [],
1697
+ offline: null
673
1698
  },
674
1699
  getGameStats: {
675
1700
  method: "GET",
676
1701
  path: "/games/:slug/stats",
677
- query: ["user"]
1702
+ query: ["user"],
1703
+ offline: null
678
1704
  },
679
1705
  getGameProfile: {
680
1706
  method: "GET",
681
1707
  path: "/games/:slug/profile",
682
- query: []
1708
+ query: [],
1709
+ offline: null
683
1710
  },
684
1711
  setGameProfile: {
685
1712
  method: "PUT",
686
1713
  path: "/games/:slug/profile",
687
- query: []
1714
+ query: [],
1715
+ offline: "replay"
688
1716
  },
689
1717
  listSaves: {
690
1718
  method: "GET",
691
1719
  path: "/games/:slug/saves",
692
- query: []
1720
+ query: [],
1721
+ offline: null
693
1722
  },
694
1723
  getSave: {
695
1724
  method: "GET",
696
1725
  path: "/games/:slug/saves/:slot",
697
- query: []
1726
+ query: [],
1727
+ offline: null
698
1728
  },
699
1729
  writeSave: {
700
1730
  method: "PUT",
701
1731
  path: "/games/:slug/saves/:slot",
702
- query: []
1732
+ query: [],
1733
+ offline: "replay"
703
1734
  },
704
1735
  deleteSave: {
705
1736
  method: "DELETE",
706
1737
  path: "/games/:slug/saves/:slot",
707
- query: []
1738
+ query: [],
1739
+ offline: "replay"
708
1740
  },
709
1741
  updateGameStat: {
710
1742
  method: "POST",
711
1743
  path: "/games/:slug/stats",
712
- query: []
1744
+ query: [],
1745
+ offline: "replay"
713
1746
  },
714
1747
  resetMyGameProgress: {
715
1748
  method: "POST",
716
1749
  path: "/games/:slug/reset",
717
- query: []
1750
+ query: [],
1751
+ offline: null
718
1752
  },
719
1753
  listMyLeaderboardRecords: {
720
1754
  method: "GET",
721
1755
  path: "/me/records",
722
- query: ["game"]
1756
+ query: ["game"],
1757
+ offline: null
723
1758
  },
724
1759
  getMyOfflineSnapshot: {
725
1760
  method: "GET",
726
1761
  path: "/me/snapshot",
727
- query: ["top"]
1762
+ query: ["top"],
1763
+ offline: null
728
1764
  },
729
1765
  listTopOfEveryLeaderboard: {
730
1766
  method: "GET",
731
1767
  path: "/leaderboards",
732
- query: ["top"]
1768
+ query: ["top"],
1769
+ offline: null
733
1770
  },
734
1771
  listGameLeaderboards: {
735
1772
  method: "GET",
736
1773
  path: "/games/:slug/leaderboards",
737
- query: []
1774
+ query: [],
1775
+ offline: null
738
1776
  },
739
1777
  getLeaderboard: {
740
1778
  method: "GET",
741
1779
  path: "/games/:slug/leaderboards/:key",
742
- query: ["limit"]
1780
+ query: ["limit"],
1781
+ offline: null
743
1782
  },
744
1783
  submitScore: {
745
1784
  method: "POST",
746
1785
  path: "/games/:slug/leaderboards/:key/scores",
747
- query: []
1786
+ query: [],
1787
+ offline: "replay"
748
1788
  },
749
1789
  listDailyChallengesForAllGames: {
750
1790
  method: "GET",
751
1791
  path: "/daily",
752
- query: []
1792
+ query: [],
1793
+ offline: null
753
1794
  },
754
1795
  getGameDailyChallenges: {
755
1796
  method: "GET",
756
1797
  path: "/games/:slug/daily",
757
- query: []
1798
+ query: [],
1799
+ offline: null
758
1800
  },
759
1801
  startDailyChallenge: {
760
1802
  method: "POST",
761
1803
  path: "/games/:slug/daily/:day/start",
762
- query: []
1804
+ query: [],
1805
+ offline: null
763
1806
  },
764
1807
  completeDailyChallenge: {
765
1808
  method: "POST",
766
1809
  path: "/games/:slug/daily/:day/complete",
767
- query: []
1810
+ query: [],
1811
+ offline: "replay"
768
1812
  },
769
1813
  getMyInventory: {
770
1814
  method: "GET",
771
1815
  path: "/games/:slug/inventory",
772
- query: []
1816
+ query: [],
1817
+ offline: null
773
1818
  },
774
1819
  grantInventoryItems: {
775
1820
  method: "POST",
776
1821
  path: "/games/:slug/inventory/grant",
777
- query: []
1822
+ query: [],
1823
+ offline: "replay"
778
1824
  },
779
1825
  exchangeInventoryItems: {
780
1826
  method: "POST",
781
1827
  path: "/games/:slug/inventory/exchange",
782
- query: []
1828
+ query: [],
1829
+ offline: "replay"
783
1830
  },
784
1831
  getPlayerInventory: {
785
1832
  method: "GET",
786
1833
  path: "/games/:slug/players/:userId/inventory",
787
- query: []
1834
+ query: [],
1835
+ offline: null
788
1836
  },
789
1837
  listTradePartners: {
790
1838
  method: "GET",
791
1839
  path: "/games/:slug/trades/players",
792
- query: []
1840
+ query: [],
1841
+ offline: null
793
1842
  },
794
1843
  listMyTrades: {
795
1844
  method: "GET",
796
1845
  path: "/games/:slug/trades",
797
- query: ["box", "status"]
1846
+ query: ["box", "status"],
1847
+ offline: null
798
1848
  },
799
1849
  declineTrade: {
800
1850
  method: "POST",
801
1851
  path: "/games/:slug/trades/:id/decline",
802
- query: []
1852
+ query: [],
1853
+ offline: null
803
1854
  },
804
1855
  cancelTrade: {
805
1856
  method: "POST",
806
1857
  path: "/games/:slug/trades/:id/cancel",
807
- query: []
1858
+ query: [],
1859
+ offline: null
808
1860
  },
809
1861
  createTrade: {
810
1862
  method: "POST",
811
1863
  path: "/games/:slug/trades",
812
- query: []
1864
+ query: [],
1865
+ offline: null
813
1866
  },
814
1867
  acceptTrade: {
815
1868
  method: "POST",
816
1869
  path: "/games/:slug/trades/:id/accept",
817
- query: []
1870
+ query: [],
1871
+ offline: null
818
1872
  },
819
1873
  counterTradeOffer: {
820
1874
  method: "POST",
821
1875
  path: "/games/:slug/trades/:id/counter",
822
- query: []
1876
+ query: [],
1877
+ offline: null
823
1878
  },
824
1879
  getSiteAnalytics: {
825
1880
  method: "GET",
826
1881
  path: "/analytics",
827
- query: []
1882
+ query: [],
1883
+ offline: null
828
1884
  },
829
1885
  reportClientError: {
830
1886
  method: "POST",
831
1887
  path: "/client-log",
832
- query: []
1888
+ query: [],
1889
+ offline: null
833
1890
  },
834
1891
  recordGameEvent: {
835
1892
  method: "POST",
836
1893
  path: "/games/:slug/events",
837
- query: []
1894
+ query: [],
1895
+ offline: null
838
1896
  },
839
1897
  getGameAnalytics: {
840
1898
  method: "GET",
841
1899
  path: "/games/:slug/analytics",
842
- query: []
1900
+ query: [],
1901
+ offline: null
843
1902
  },
844
1903
  getSocialConfig: {
845
1904
  method: "GET",
846
1905
  path: "/social/config",
847
- query: []
1906
+ query: [],
1907
+ offline: null
848
1908
  },
849
1909
  getOnlineCounts: {
850
1910
  method: "GET",
851
1911
  path: "/social/counts",
852
- query: []
1912
+ query: [],
1913
+ offline: null
853
1914
  },
854
1915
  getMySocialProfile: {
855
1916
  method: "GET",
856
1917
  path: "/social/me",
857
- query: []
1918
+ query: [],
1919
+ offline: null
858
1920
  },
859
1921
  listFriendsAndRequests: {
860
1922
  method: "GET",
861
1923
  path: "/social/friends",
862
- query: []
1924
+ query: [],
1925
+ offline: null
863
1926
  },
864
1927
  findPlayerByName: {
865
1928
  method: "GET",
866
1929
  path: "/social/players/by-name/:name",
867
- query: []
1930
+ query: [],
1931
+ offline: null
868
1932
  },
869
1933
  getPlayerCard: {
870
1934
  method: "GET",
871
1935
  path: "/social/players/:id",
872
- query: []
1936
+ query: [],
1937
+ offline: null
873
1938
  },
874
1939
  updateMySocialProfile: {
875
1940
  method: "PUT",
876
1941
  path: "/social/me",
877
- query: []
1942
+ query: [],
1943
+ offline: null
878
1944
  },
879
1945
  setParentChatLock: {
880
1946
  method: "POST",
881
1947
  path: "/social/chat-lock",
882
- query: []
1948
+ query: [],
1949
+ offline: null
883
1950
  },
884
1951
  sendFriendRequest: {
885
1952
  method: "POST",
886
1953
  path: "/social/friends",
887
- query: []
1954
+ query: [],
1955
+ offline: null
888
1956
  },
889
1957
  acceptFriendRequest: {
890
1958
  method: "POST",
891
1959
  path: "/social/friends/:id/accept",
892
- query: []
1960
+ query: [],
1961
+ offline: null
893
1962
  },
894
1963
  declineFriendRequest: {
895
1964
  method: "POST",
896
1965
  path: "/social/friends/:id/decline",
897
- query: []
1966
+ query: [],
1967
+ offline: null
898
1968
  },
899
1969
  removeFriend: {
900
1970
  method: "DELETE",
901
1971
  path: "/social/friends/:id",
902
- query: []
1972
+ query: [],
1973
+ offline: null
903
1974
  },
904
1975
  setFriendNotifications: {
905
1976
  method: "PUT",
906
1977
  path: "/social/friends/:id/notify",
907
- query: []
1978
+ query: [],
1979
+ offline: null
908
1980
  },
909
1981
  blockPlayer: {
910
1982
  method: "POST",
911
1983
  path: "/social/blocks",
912
- query: []
1984
+ query: [],
1985
+ offline: null
913
1986
  },
914
1987
  unblockPlayer: {
915
1988
  method: "DELETE",
916
1989
  path: "/social/blocks/:id",
917
- query: []
1990
+ query: [],
1991
+ offline: null
918
1992
  },
919
1993
  listRecentPlayers: {
920
1994
  method: "GET",
921
1995
  path: "/social/recent",
922
- query: []
1996
+ query: [],
1997
+ offline: null
923
1998
  },
924
1999
  listConversations: {
925
2000
  method: "GET",
926
2001
  path: "/social/conversations",
927
- query: []
2002
+ query: [],
2003
+ offline: null
928
2004
  },
929
2005
  listDirectMessages: {
930
2006
  method: "GET",
931
2007
  path: "/social/messages/:userId",
932
- query: ["before"]
2008
+ query: ["before"],
2009
+ offline: null
933
2010
  },
934
2011
  sendDirectMessage: {
935
2012
  method: "POST",
936
2013
  path: "/social/messages/:userId",
937
- query: []
2014
+ query: [],
2015
+ offline: null
938
2016
  },
939
2017
  markDirectMessagesRead: {
940
2018
  method: "POST",
941
2019
  path: "/social/messages/:userId/read",
942
- query: []
2020
+ query: [],
2021
+ offline: null
943
2022
  },
944
2023
  getNotificationPreferences: {
945
2024
  method: "GET",
946
2025
  path: "/social/notify",
947
- query: []
2026
+ query: [],
2027
+ offline: null
948
2028
  },
949
2029
  unsubscribeFromPush: {
950
2030
  method: "DELETE",
951
2031
  path: "/social/push",
952
- query: []
2032
+ query: [],
2033
+ offline: null
953
2034
  },
954
2035
  setNotificationPreferences: {
955
2036
  method: "PUT",
956
2037
  path: "/social/notify",
957
- query: []
2038
+ query: [],
2039
+ offline: null
958
2040
  },
959
2041
  subscribeToPush: {
960
2042
  method: "POST",
961
2043
  path: "/social/push",
962
- query: []
2044
+ query: [],
2045
+ offline: null
963
2046
  },
964
2047
  fileReport: {
965
2048
  method: "POST",
966
2049
  path: "/social/reports",
967
- query: []
2050
+ query: [],
2051
+ offline: null
968
2052
  },
969
2053
  listPublicSuggestions: {
970
2054
  method: "GET",
971
2055
  path: "/suggestions",
972
- query: ["sort"]
2056
+ query: ["sort"],
2057
+ offline: null
973
2058
  },
974
2059
  submitSuggestion: {
975
2060
  method: "POST",
976
2061
  path: "/suggestions",
977
- query: []
2062
+ query: [],
2063
+ offline: null
978
2064
  },
979
2065
  voteForSuggestion: {
980
2066
  method: "POST",
981
2067
  path: "/suggestions/:id/vote",
982
- query: []
2068
+ query: [],
2069
+ offline: null
983
2070
  },
984
2071
  removeSuggestionVote: {
985
2072
  method: "DELETE",
986
2073
  path: "/suggestions/:id/vote",
987
- query: []
2074
+ query: [],
2075
+ offline: null
988
2076
  },
989
2077
  withdrawSuggestion: {
990
2078
  method: "DELETE",
991
2079
  path: "/suggestions/:id",
992
- query: []
2080
+ query: [],
2081
+ offline: null
993
2082
  },
994
2083
  sendGameInvite: {
995
2084
  method: "POST",
996
2085
  path: "/mp/invites",
997
- query: []
2086
+ query: [],
2087
+ offline: null
998
2088
  },
999
2089
  acceptGameInvite: {
1000
2090
  method: "POST",
1001
2091
  path: "/mp/invites/:id/accept",
1002
- query: []
2092
+ query: [],
2093
+ offline: null
1003
2094
  },
1004
2095
  declineGameInvite: {
1005
2096
  method: "POST",
1006
2097
  path: "/mp/invites/:id/decline",
1007
- query: []
2098
+ query: [],
2099
+ offline: null
2100
+ },
2101
+ createInviteLink: {
2102
+ method: "POST",
2103
+ path: "/mp/links",
2104
+ query: [],
2105
+ offline: null
2106
+ },
2107
+ listInviteLinks: {
2108
+ method: "GET",
2109
+ path: "/mp/links",
2110
+ query: ["game"],
2111
+ offline: null
2112
+ },
2113
+ revokeInviteLink: {
2114
+ method: "DELETE",
2115
+ path: "/mp/links/:token",
2116
+ query: [],
2117
+ offline: null
1008
2118
  },
1009
2119
  cancelGameInvite: {
1010
2120
  method: "DELETE",
1011
2121
  path: "/mp/invites/:id",
1012
- query: []
2122
+ query: [],
2123
+ offline: null
1013
2124
  },
1014
2125
  redeemLaunchToken: {
1015
2126
  method: "GET",
1016
2127
  path: "/mp/launch/:token",
1017
- query: []
2128
+ query: [],
2129
+ offline: null
2130
+ },
2131
+ openInviteLink: {
2132
+ method: "POST",
2133
+ path: "/mp/links/:token/open",
2134
+ query: [],
2135
+ offline: null
1018
2136
  },
1019
2137
  setPartyJoinInfo: {
1020
2138
  method: "POST",
1021
2139
  path: "/mp/parties/:id/join-info",
1022
- query: []
2140
+ query: [],
2141
+ offline: null
1023
2142
  },
1024
2143
  leaveParty: {
1025
2144
  method: "POST",
1026
2145
  path: "/mp/parties/:id/leave",
1027
- query: []
2146
+ query: [],
2147
+ offline: null
1028
2148
  },
1029
2149
  launchIntoFriendsRoom: {
1030
2150
  method: "POST",
1031
2151
  path: "/mp/join",
1032
- query: []
2152
+ query: [],
2153
+ offline: null
1033
2154
  },
1034
2155
  getGameServerTicket: {
1035
2156
  method: "POST",
1036
2157
  path: "/mp/ticket",
1037
- query: []
2158
+ query: [],
2159
+ offline: null
1038
2160
  },
1039
2161
  listMyRaceHistory: {
1040
2162
  method: "GET",
@@ -1044,14 +2166,90 @@ const ROUTES = {
1044
2166
  "with",
1045
2167
  "before",
1046
2168
  "limit"
1047
- ]
2169
+ ],
2170
+ offline: null
2171
+ },
2172
+ listTournaments: {
2173
+ method: "GET",
2174
+ path: "/tournaments",
2175
+ query: ["game"],
2176
+ offline: null
2177
+ },
2178
+ getTournament: {
2179
+ method: "GET",
2180
+ path: "/tournaments/:id",
2181
+ query: ["key"],
2182
+ offline: null
2183
+ },
2184
+ createTournament: {
2185
+ method: "POST",
2186
+ path: "/tournaments",
2187
+ query: [],
2188
+ offline: null
2189
+ },
2190
+ inviteToTournament: {
2191
+ method: "POST",
2192
+ path: "/tournaments/:id/invites",
2193
+ query: [],
2194
+ offline: null
2195
+ },
2196
+ followTournament: {
2197
+ method: "PUT",
2198
+ path: "/tournaments/:id/follow",
2199
+ query: [],
2200
+ offline: null
2201
+ },
2202
+ unfollowTournament: {
2203
+ method: "DELETE",
2204
+ path: "/tournaments/:id/follow",
2205
+ query: [],
2206
+ offline: null
2207
+ },
2208
+ startTournament: {
2209
+ method: "POST",
2210
+ path: "/tournaments/:id/start",
2211
+ query: [],
2212
+ offline: null
2213
+ },
2214
+ cancelTournament: {
2215
+ method: "DELETE",
2216
+ path: "/tournaments/:id",
2217
+ query: [],
2218
+ offline: null
2219
+ },
2220
+ joinTournament: {
2221
+ method: "POST",
2222
+ path: "/tournaments/:id/join",
2223
+ query: [],
2224
+ offline: null
2225
+ },
2226
+ leaveTournament: {
2227
+ method: "POST",
2228
+ path: "/tournaments/:id/leave",
2229
+ query: [],
2230
+ offline: null
2231
+ },
2232
+ launchTournamentMatch: {
2233
+ method: "POST",
2234
+ path: "/tournaments/:id/matches/:match/launch",
2235
+ query: [],
2236
+ offline: null
2237
+ },
2238
+ getPlayOnline: {
2239
+ method: "GET",
2240
+ path: "/play-online",
2241
+ query: [],
2242
+ offline: null
1048
2243
  },
1049
2244
  getHubVersion: {
1050
2245
  method: "GET",
1051
2246
  path: "/version",
1052
- query: []
2247
+ query: [],
2248
+ offline: null
1053
2249
  }
1054
2250
  };
2251
+ /** The request header that carries an offline operation key (the contract's `OFFLINE_OP_HEADER`). */
2252
+ const OFFLINE_OP_HEADER = "x-hub-op";
1055
2253
  //#endregion
1056
2254
  //#region src/core/rest/rest-client.ts
1057
2255
  /**
@@ -1122,7 +2320,7 @@ async function failureOf(name, response) {
1122
2320
  }
1123
2321
  /** A REST client for one API base. */
1124
2322
  function createRestClient(options) {
1125
- async function send(name, fields) {
2323
+ async function send(name, fields, opKey) {
1126
2324
  const planned = planRequest(name, fields);
1127
2325
  let response;
1128
2326
  try {
@@ -1131,7 +2329,8 @@ function createRestClient(options) {
1131
2329
  credentials: "same-origin",
1132
2330
  headers: {
1133
2331
  "content-type": "application/json",
1134
- [CLIENT_VERSION_HEADER]: HUB_CLIENT_VERSION
2332
+ [CLIENT_VERSION_HEADER]: HUB_CLIENT_VERSION,
2333
+ ...opKey !== void 0 && ROUTES[name].offline === "replay" ? { [OFFLINE_OP_HEADER]: opKey } : {}
1135
2334
  },
1136
2335
  ...planned.bodyText === void 0 ? {} : { body: planned.bodyText }
1137
2336
  });
@@ -1144,20 +2343,19 @@ function createRestClient(options) {
1144
2343
  }
1145
2344
  return {
1146
2345
  apiBase: options.apiBase,
1147
- call: (name, ...fieldsArgument) => send(name, fieldsArgument[0])
2346
+ call: (name, ...fieldsArgument) => send(name, fieldsArgument[0]),
2347
+ callOnce: (opKey, name, ...fieldsArgument) => send(name, fieldsArgument[0], opKey)
1148
2348
  };
1149
2349
  }
1150
2350
  //#endregion
1151
2351
  //#region src/core/connection.ts
1152
- /**
1153
- * The machinery under every `Hub` call: reaching the REST API, falling back
1154
- * to the offline cache, queueing writes made offline and replaying them,
1155
- * buffering analytics, warming the cache for every game, and reporting
1156
- * online/offline/sync status (to `onStatus` listeners and as `hub:*` window
1157
- * events, which the injected menu listens for).
1158
- */
1159
2352
  /** How long `prefetchAll()` waits between snapshots unless forced. */
1160
2353
  const PREFETCH_INTERVAL_MS = 3e4;
2354
+ /** A sync that did nothing. */
2355
+ const NOTHING_SYNCED = {
2356
+ flushed: 0,
2357
+ conflicts: []
2358
+ };
1161
2359
  /** Whether a failure means "the hub can't be reached" (rather than a refusal). */
1162
2360
  function isTransportFailure(failure) {
1163
2361
  return failure instanceof HubError && failure.isOffline;
@@ -1170,10 +2368,15 @@ var HubConnection = class {
1170
2368
  _defineProperty(this, "apiBase", void 0);
1171
2369
  _defineProperty(this, "rest", void 0);
1172
2370
  _defineProperty(this, "store", void 0);
2371
+ _defineProperty(this, "outbox", void 0);
1173
2372
  _defineProperty(this, "offlineMode", void 0);
1174
2373
  _defineProperty(this, "isReachable", true);
1175
- _defineProperty(this, "isSyncing", false);
2374
+ _defineProperty(this, "runningSync", null);
2375
+ _defineProperty(this, "followingSync", null);
2376
+ _defineProperty(this, "isFollowingEager", false);
2377
+ _defineProperty(this, "directSend", null);
1176
2378
  _defineProperty(this, "lastPrefetchAt", 0);
2379
+ _defineProperty(this, "offlineApi", void 0);
1177
2380
  _defineProperty(this, "statusListeners", /* @__PURE__ */ new Set());
1178
2381
  this.slug = options.slug ?? detectSlug();
1179
2382
  this.apiBase = options.apiBase === void 0 ? detectApiBase() : absoluteApiBase(options.apiBase);
@@ -1185,16 +2388,37 @@ var HubConnection = class {
1185
2388
  this.setReachable(true);
1186
2389
  }
1187
2390
  });
1188
- this.store = new LocalStore(this.slug ?? "app");
1189
- if (typeof navigator !== "undefined" && "onLine" in navigator) this.isReachable = navigator.onLine;
1190
- if (isBrowserPage()) {
1191
- window.addEventListener("online", () => {
1192
- this.setReachable(true);
1193
- });
1194
- window.addEventListener("offline", () => {
2391
+ const pageStore = createPageStore();
2392
+ this.store = new LocalStore(this.slug ?? "app", pageStore);
2393
+ this.outbox = new Outbox({
2394
+ slug: this.slug ?? "app",
2395
+ store: pageStore,
2396
+ rest: this.rest,
2397
+ emit: emitWindowEvent,
2398
+ onUnreachable: () => {
1195
2399
  this.setReachable(false);
1196
- });
1197
- }
2400
+ },
2401
+ onChange: () => {
2402
+ this.notifyStatus();
2403
+ },
2404
+ onRetryDue: () => {
2405
+ this.retrySync();
2406
+ }
2407
+ });
2408
+ registerHubKinds(this.outbox, {
2409
+ game: () => this.gameFields(),
2410
+ onDailyComplete: (payload, completion) => {
2411
+ emitWindowEvent("hub:daily-complete", {
2412
+ day: payload.day,
2413
+ count: completion.count,
2414
+ rank: completion.rank,
2415
+ meta: payload.meta
2416
+ });
2417
+ }
2418
+ });
2419
+ this.importLegacyQueue();
2420
+ if (typeof navigator !== "undefined" && "onLine" in navigator) this.isReachable = navigator.onLine;
2421
+ if (isBrowserPage()) this.listenToPage();
1198
2422
  }
1199
2423
  /** `true` when calls are being served from the offline cache rather than the hub. */
1200
2424
  get isOffline() {
@@ -1204,9 +2428,22 @@ var HubConnection = class {
1204
2428
  get isOnline() {
1205
2429
  return this.isReachable;
1206
2430
  }
1207
- /** Queued writes plus buffered analytics events waiting to sync. */
2431
+ /**
2432
+ * The offline outbox: define your own kinds of operation, queue them, and
2433
+ * hear how they replay (`hub.offline.define`, `enqueue`, `pending`, `on`).
2434
+ */
2435
+ get offline() {
2436
+ this.offlineApi ?? (this.offlineApi = new OfflineApi({
2437
+ outbox: this.outbox,
2438
+ requestSync: () => {
2439
+ if (!this.isOffline) this.sync();
2440
+ }
2441
+ }));
2442
+ return this.offlineApi;
2443
+ }
2444
+ /** Queued operations plus buffered analytics events waiting to sync. */
1208
2445
  pending() {
1209
- return this.store.queueLength() + this.store.getEvents().length;
2446
+ return this.outbox.count() + this.store.getEvents().length;
1210
2447
  }
1211
2448
  /** Subscribe to connection and sync status changes; returns the unsubscribe function. */
1212
2449
  onStatus(listener) {
@@ -1227,16 +2464,13 @@ var HubConnection = class {
1227
2464
  } catch {}
1228
2465
  emitWindowEvent("hub:status", status);
1229
2466
  }
1230
- /** Record reachability; coming back online syncs the queue and re-warms the cache. */
2467
+ /** Record reachability; coming back online replays the outbox and re-warms the cache. */
1231
2468
  setReachable(isReachableNow) {
1232
2469
  if (isReachableNow === this.isReachable) return;
1233
2470
  this.isReachable = isReachableNow;
1234
2471
  emitWindowEvent(isReachableNow ? "hub:online" : "hub:offline", { pending: this.pending() });
1235
2472
  this.notifyStatus();
1236
- if (isReachableNow) {
1237
- this.sync();
1238
- this.prefetchAll().catch(() => void 0);
1239
- }
2473
+ if (isReachableNow) this.replayAll(true).then(() => this.prefetchAll()).catch(() => void 0);
1240
2474
  }
1241
2475
  /** `{ slug }` for this game's endpoints; throws when the slug is unknown. */
1242
2476
  gameFields() {
@@ -1258,132 +2492,96 @@ var HubConnection = class {
1258
2492
  return offlineAnswer();
1259
2493
  }
1260
2494
  }
1261
- /** Send one queued write. */
1262
- async sendOp(op) {
1263
- const game = this.gameFields();
1264
- switch (op.kind) {
1265
- case "putSave":
1266
- await this.rest.call("writeSave", {
1267
- ...game,
1268
- slot: op.slot,
1269
- data: op.data,
1270
- label: op.label
1271
- });
1272
- return;
1273
- case "deleteSave":
1274
- await this.rest.call("deleteSave", {
1275
- ...game,
1276
- slot: op.slot
1277
- });
1278
- return;
1279
- case "setStat":
1280
- await this.rest.call("updateGameStat", {
1281
- ...game,
1282
- key: op.key,
1283
- value: op.value,
1284
- mode: "set"
1285
- });
1286
- return;
1287
- case "maxStat":
1288
- await this.rest.call("updateGameStat", {
1289
- ...game,
1290
- key: op.key,
1291
- value: op.value,
1292
- mode: "max"
1293
- });
1294
- return;
1295
- case "incrStat":
1296
- await this.rest.call("updateGameStat", {
1297
- ...game,
1298
- key: op.key,
1299
- value: op.amount,
1300
- mode: "incr"
1301
- });
1302
- return;
1303
- case "submitScore":
1304
- await this.rest.call("submitScore", {
1305
- ...game,
1306
- key: op.key,
1307
- score: op.score,
1308
- ...op.opts
1309
- });
1310
- return;
1311
- case "setProfile":
1312
- await this.rest.call("setGameProfile", {
1313
- ...game,
1314
- ...op.patch
1315
- });
1316
- return;
1317
- }
1318
- }
1319
2495
  /**
1320
- * Apply a write to the offline cache at once, then send it now (online
1321
- * with an empty queue) or queue it behind the others. Resolves with the
1322
- * local answer; a real API error on the direct path reaches the caller and
1323
- * is not queued.
2496
+ * Apply a write to the offline cache at once, then perform it as an op of
2497
+ * `kind`: sent now when online with nothing waiting, else queued behind
2498
+ * the others. Resolves with the local answer (or, sent now, with
2499
+ * `replyAnswer`'s reading of the hub's reply). A real API error on the
2500
+ * direct path reaches the caller and is not queued; a transport failure
2501
+ * queues the op under the same id, so if the request did land, its replay
2502
+ * is recognised by the hub and not applied twice.
1324
2503
  */
1325
- async mutateQueued(input, localAnswer) {
2504
+ async mutateOffline(kind, payload, localAnswer, replyAnswer) {
1326
2505
  const answer = localAnswer();
1327
- if (this.isOffline || this.store.queueLength() > 0) {
1328
- this.store.enqueue(input);
1329
- this.notifyStatus();
2506
+ const op = this.outbox.stamp(kind, payload);
2507
+ if (this.isOffline || this.outbox.count() > 0 || this.runningSync !== null || this.directSend !== null) {
2508
+ this.outbox.add(op);
1330
2509
  if (!this.isOffline) this.sync();
1331
2510
  return answer;
1332
2511
  }
2512
+ const sending = this.outbox.runNow(op);
2513
+ this.directSend = sending;
1333
2514
  try {
1334
- await this.sendOp(input);
1335
- return answer;
2515
+ const result = await sending;
2516
+ return replyAnswer?.(result) ?? answer;
1336
2517
  } catch (failure) {
1337
2518
  if (!isTransportFailure(failure)) throw failure;
1338
2519
  this.setReachable(false);
1339
- this.store.enqueue(input);
1340
- this.notifyStatus();
2520
+ this.outbox.add({
2521
+ ...op,
2522
+ attempts: 1
2523
+ });
1341
2524
  return answer;
2525
+ } finally {
2526
+ if (this.directSend === sending) this.directSend = null;
1342
2527
  }
1343
2528
  }
1344
2529
  /**
1345
- * Replay the queue, then the buffered events, in order. Stops at the first
1346
- * transport failure (offline again: the rest stays queued); a write the hub
1347
- * refuses is dropped and reported as a conflict.
2530
+ * Replay the outbox, then the buffered events, in order. Stops at the
2531
+ * first transport failure (offline again: the rest stays queued); an op
2532
+ * the hub refuses is dropped and reported as a conflict.
2533
+ */
2534
+ sync() {
2535
+ return this.isOffline ? Promise.resolve(NOTHING_SYNCED) : this.replayAll(false);
2536
+ }
2537
+ /** A retry came due: try again even when the hub was last unreachable (the try is the probe), unless the browser itself is offline. */
2538
+ retrySync() {
2539
+ const isBrowserOffline = typeof navigator !== "undefined" && "onLine" in navigator && !navigator.onLine;
2540
+ return this.offlineMode === "always" || isBrowserOffline ? Promise.resolve(NOTHING_SYNCED) : this.replayAll(false);
2541
+ }
2542
+ /**
2543
+ * One replay at a time per client. A call during one queues a single
2544
+ * follow-up replay (eager if any caller asked for that), so an op queued
2545
+ * or a reconnect that happens mid-replay is never missed.
1348
2546
  */
1349
- async sync() {
1350
- if (this.isSyncing || this.isOffline || this.slug === null) return {
1351
- flushed: 0,
1352
- conflicts: []
2547
+ replayAll(isEager) {
2548
+ if (this.slug === null || this.offlineMode === "always") return Promise.resolve(NOTHING_SYNCED);
2549
+ const running = this.runningSync;
2550
+ if (running === null) return this.startReplay(isEager);
2551
+ this.isFollowingEager || (this.isFollowingEager = isEager);
2552
+ this.followingSync ?? (this.followingSync = running.catch(() => NOTHING_SYNCED).then(() => {
2553
+ const isFollowUpEager = this.isFollowingEager;
2554
+ this.isFollowingEager = false;
2555
+ this.followingSync = null;
2556
+ return this.startReplay(isFollowUpEager);
2557
+ }));
2558
+ return this.followingSync;
2559
+ }
2560
+ startReplay(isEager) {
2561
+ const run = this.runReplay(isEager).finally(() => {
2562
+ if (this.runningSync === run) this.runningSync = null;
2563
+ });
2564
+ this.runningSync = run;
2565
+ return run;
2566
+ }
2567
+ async runReplay(isEager) {
2568
+ if (this.directSend !== null) await this.directSend.catch(() => void 0);
2569
+ const report = await this.outbox.replay(isEager);
2570
+ const conflicts = report.failed.map((failure) => ({
2571
+ op: failure.entry,
2572
+ error: failure.error
2573
+ }));
2574
+ if (report.stoppedBy !== "connection") await this.flushEvents();
2575
+ const result = {
2576
+ flushed: report.replayed.length,
2577
+ conflicts
1353
2578
  };
1354
- this.isSyncing = true;
1355
- let flushed = 0;
1356
- const conflicts = [];
1357
- try {
1358
- let queue = this.store.getQueue();
1359
- for (let op = queue[0]; op !== void 0; op = queue[0]) {
1360
- try {
1361
- await this.sendOp(op);
1362
- flushed += 1;
1363
- } catch (failure) {
1364
- if (isTransportFailure(failure)) break;
1365
- conflicts.push({
1366
- op,
1367
- error: failure instanceof Error ? failure.message : String(failure)
1368
- });
1369
- }
1370
- queue = queue.slice(1);
1371
- this.store.setQueue(queue);
1372
- }
1373
- await this.flushEvents();
1374
- } finally {
1375
- this.isSyncing = false;
1376
- }
1377
2579
  emitWindowEvent("hub:sync", {
1378
2580
  pending: this.pending(),
1379
- flushed,
1380
- conflicts
2581
+ ...result
1381
2582
  });
1382
2583
  this.notifyStatus();
1383
- return {
1384
- flushed,
1385
- conflicts
1386
- };
2584
+ return result;
1387
2585
  }
1388
2586
  /** Send the buffered analytics events; a refused one is dropped (telemetry never blocks). */
1389
2587
  async flushEvents() {
@@ -1401,6 +2599,31 @@ var HubConnection = class {
1401
2599
  this.store.setEvents(events);
1402
2600
  }
1403
2601
  }
2602
+ /** Move a v1 copy's queued writes into the outbox, in their order. */
2603
+ importLegacyQueue() {
2604
+ if (this.slug === null) return;
2605
+ try {
2606
+ for (const legacy of this.store.takeLegacyQueue()) {
2607
+ const operation = legacyOperation(legacy);
2608
+ if (operation !== void 0) this.outbox.add(this.outbox.stamp(operation.kind, operation.payload));
2609
+ }
2610
+ } catch {}
2611
+ }
2612
+ /** Follow the browser's connection, other tabs' outbox changes, and replay what an earlier page left queued. */
2613
+ listenToPage() {
2614
+ window.addEventListener("online", () => {
2615
+ this.setReachable(true);
2616
+ });
2617
+ window.addEventListener("offline", () => {
2618
+ this.setReachable(false);
2619
+ });
2620
+ window.addEventListener("storage", (event) => {
2621
+ if (event instanceof StorageEvent && this.outbox.entries.owns(event.key)) this.notifyStatus();
2622
+ });
2623
+ if (this.outbox.count() > 0) setTimeout(() => {
2624
+ this.sync();
2625
+ }, 0);
2626
+ }
1404
2627
  /**
1405
2628
  * Warm the offline cache for every game the player has touched, in one
1406
2629
  * request, so going offline later still shows complete progress.
@@ -1416,32 +2639,13 @@ var HubConnection = class {
1416
2639
  throw failure;
1417
2640
  }
1418
2641
  this.lastPrefetchAt = Date.now();
2642
+ if (this.outbox.count() > 0) return snapshot;
1419
2643
  this.store.hydrate(snapshot);
1420
2644
  emitWindowEvent("hub:prefetch", { games: Object.keys(snapshot.games).length });
1421
2645
  return snapshot;
1422
2646
  }
1423
2647
  };
1424
2648
  //#endregion
1425
- //#region src/core/game-data.ts
1426
- /** Always true: a game's payload is sent as JSON (`JSON.stringify` drops what JSON can't hold). */
1427
- function isSentAsJson(_payload) {
1428
- return true;
1429
- }
1430
- /** Always true: JSON the game stored is read back as the game's type. */
1431
- function isGameOwnData(_stored) {
1432
- return true;
1433
- }
1434
- /** A game's payload as the JSON the hub stores. */
1435
- function toStoredJson(payload) {
1436
- if (isSentAsJson(payload)) return payload;
1437
- throw new TypeError("unreachable: every payload is sent as JSON");
1438
- }
1439
- /** JSON the game stored, typed as the game's own type. */
1440
- function toGameData(stored) {
1441
- if (isGameOwnData(stored)) return stored;
1442
- throw new TypeError("unreachable: stored game data is the game's own");
1443
- }
1444
- //#endregion
1445
2649
  //#region src/core/hub-progress.ts
1446
2650
  /** A profile with the game's own data typed as `TData`. */
1447
2651
  function typedProfile(profile) {
@@ -1542,13 +2746,11 @@ var HubProgress = class extends HubConnection {
1542
2746
  }
1543
2747
  /** Create or update this game's profile for the current user. */
1544
2748
  setProfile(patch) {
1545
- const queued = {};
1546
- if (patch.displayName !== void 0) queued.displayName = patch.displayName;
1547
- if (patch.data !== void 0) queued.data = toStoredJson(patch.data);
1548
- return this.mutateQueued({
1549
- kind: "setProfile",
1550
- patch: queued
1551
- }, () => {
2749
+ const queued = {
2750
+ ...patch.displayName === void 0 ? {} : { displayName: patch.displayName },
2751
+ ...patch.data === void 0 ? {} : { data: toStoredJson(patch.data) }
2752
+ };
2753
+ return this.mutateOffline(PROGRESS_KINDS.profile, queued, () => {
1552
2754
  const stored = this.store.setProfile(this.slug ?? "app", patch.displayName ?? null, queued.data ?? {});
1553
2755
  return { profile: {
1554
2756
  ...stored,
@@ -1570,8 +2772,7 @@ var HubProgress = class extends HubConnection {
1570
2772
  /** Create or overwrite a save slot. `saveData` is any JSON up to 64 KB. */
1571
2773
  putSave(slot, saveData, label) {
1572
2774
  const stored = toStoredJson(saveData);
1573
- return this.mutateQueued({
1574
- kind: "putSave",
2775
+ return this.mutateOffline(PROGRESS_KINDS.save, {
1575
2776
  slot,
1576
2777
  data: stored,
1577
2778
  label: label ?? null
@@ -1582,9 +2783,9 @@ var HubProgress = class extends HubConnection {
1582
2783
  }
1583
2784
  /** Delete a save slot. */
1584
2785
  deleteSave(slot) {
1585
- return this.mutateQueued({
1586
- kind: "deleteSave",
1587
- slot
2786
+ return this.mutateOffline(PROGRESS_KINDS.save, {
2787
+ slot,
2788
+ deleted: true
1588
2789
  }, () => {
1589
2790
  this.store.deleteSave(slot);
1590
2791
  return { ok: true };
@@ -1599,9 +2800,9 @@ var HubProgress = class extends HubConnection {
1599
2800
  }
1600
2801
  /** Overwrite a stat. */
1601
2802
  setStat(key, value) {
1602
- return this.mutateQueued({
1603
- kind: "setStat",
2803
+ return this.mutateOffline(PROGRESS_KINDS.stat, {
1604
2804
  key,
2805
+ mode: "set",
1605
2806
  value
1606
2807
  }, () => ({
1607
2808
  key,
@@ -1610,10 +2811,10 @@ var HubProgress = class extends HubConnection {
1610
2811
  }
1611
2812
  /** Add to a stat (creating it at `amount` when absent). */
1612
2813
  incrStat(key, amount = 1) {
1613
- return this.mutateQueued({
1614
- kind: "incrStat",
2814
+ return this.mutateOffline(PROGRESS_KINDS.stat, {
1615
2815
  key,
1616
- amount
2816
+ mode: "incr",
2817
+ value: amount
1617
2818
  }, () => ({
1618
2819
  key,
1619
2820
  value: this.store.updateStat(key, amount, "incr")
@@ -1621,9 +2822,9 @@ var HubProgress = class extends HubConnection {
1621
2822
  }
1622
2823
  /** Keep the larger of the stored and the given value. */
1623
2824
  maxStat(key, value) {
1624
- return this.mutateQueued({
1625
- kind: "maxStat",
2825
+ return this.mutateOffline(PROGRESS_KINDS.stat, {
1626
2826
  key,
2827
+ mode: "max",
1627
2828
  value
1628
2829
  }, () => ({
1629
2830
  key,
@@ -1637,8 +2838,7 @@ var HubProgress = class extends HubConnection {
1637
2838
  ...opts.sort === void 0 ? {} : { sort: opts.sort },
1638
2839
  ...opts.meta === void 0 ? {} : { meta: toStoredJson(opts.meta) }
1639
2840
  };
1640
- return this.mutateQueued({
1641
- kind: "submitScore",
2841
+ return this.mutateOffline(PROGRESS_KINDS.score, {
1642
2842
  key: boardKey,
1643
2843
  score,
1644
2844
  opts: queued
@@ -1682,31 +2882,20 @@ var HubProgress = class extends HubConnection {
1682
2882
  }
1683
2883
  };
1684
2884
  //#endregion
1685
- //#region src/core/offline/favorites.ts
1686
- /**
1687
- * Starred games are site-wide, not per game, so they live under one global
1688
- * key (`hub:favorites`, the v1 client's) rather than in a game's cache. The
1689
- * list mirrors the hub's so the menu still shows it offline.
1690
- */
1691
- /** The key of the mirrored favorites list. */
1692
- const FAVORITES_KEY = "hub:favorites";
1693
- /** The mirrored favorite slugs (empty when none or unreadable). */
1694
- function readLocalFavorites() {
1695
- const text = createPageStore().read(FAVORITES_KEY);
1696
- return text === null ? [] : stringsIn(parseJsonText(text));
2885
+ //#region src/core/hub-economy.ts
2886
+ /** Goods as queued: `null` parts left out. */
2887
+ function goodsOf(bundle) {
2888
+ const items = bundle?.items ?? void 0;
2889
+ const coins = bundle?.coins ?? void 0;
2890
+ return {
2891
+ ...items === void 0 ? {} : { items },
2892
+ ...coins === void 0 ? {} : { coins }
2893
+ };
1697
2894
  }
1698
- /** Mirror the hub's favorites list. */
1699
- function writeLocalFavorites(games) {
1700
- createPageStore().write(FAVORITES_KEY, JSON.stringify(games));
2895
+ /** The holdings in a grant or exchange reply, or `undefined` when it doesn't read as one. */
2896
+ function inventoryReply(result) {
2897
+ return readInventory(result) ?? void 0;
1701
2898
  }
1702
- //#endregion
1703
- //#region src/core/hub-economy.ts
1704
- /**
1705
- * Analytics, the catalog, favorites, records, inventory and trades. Analytics
1706
- * calls are fire-and-forget and never throw (telemetry must not break a game);
1707
- * offline they are buffered and sent when the hub is back. Method names are
1708
- * the v1 client's, unchanged.
1709
- */
1710
2899
  /** Analytics, catalog, favorites, records, inventory and trades. */
1711
2900
  var HubEconomy = class extends HubProgress {
1712
2901
  /** Record a "play". Call once when the game starts. */
@@ -1776,14 +2965,17 @@ var HubEconomy = class extends HubProgress {
1776
2965
  return reply;
1777
2966
  }, () => ({ games: readLocalFavorites() }));
1778
2967
  }
1779
- /** Star (`isOn = true`) or unstar a game. Needs a connection. */
1780
- async setFavorite(slug, isOn) {
1781
- const reply = await this.rest.call("setGameFavorite", {
2968
+ /** Star (`isOn = true`) or unstar a game. Offline, the local list changes at once and the hub's when it is back. */
2969
+ setFavorite(slug, isOn) {
2970
+ return this.mutateOffline(ECONOMY_KINDS.favorite, {
1782
2971
  slug,
1783
2972
  on: isOn
1784
- });
1785
- writeLocalFavorites(reply.games);
1786
- return reply;
2973
+ }, () => {
2974
+ const others = readLocalFavorites().filter((starred) => starred !== slug);
2975
+ const games = isOn ? [...others, slug] : others;
2976
+ writeLocalFavorites(games);
2977
+ return { games };
2978
+ }, (result) => isRecord(result) ? { games: stringsIn(result.games) } : void 0);
1787
2979
  }
1788
2980
  /** The player's leaderboard standings, grouped by game (every game, or one via `forSlug`). Empty offline or signed out. */
1789
2981
  myRecords(forSlug) {
@@ -1791,10 +2983,7 @@ var HubEconomy = class extends HubProgress {
1791
2983
  }
1792
2984
  /** This game's inventory and coin balance for the current user. */
1793
2985
  getInventory() {
1794
- return this.withFallback(() => this.rest.call("getMyInventory", this.gameFields()), () => ({
1795
- items: [],
1796
- coins: 0
1797
- }));
2986
+ return this.withFallback(async () => this.store.setInventory(await this.rest.call("getMyInventory", this.gameFields())), () => this.store.getInventory());
1798
2987
  }
1799
2988
  /** Another player's available holdings (to compose a trade). */
1800
2989
  playerInventory(userId) {
@@ -1806,20 +2995,32 @@ var HubEconomy = class extends HubProgress {
1806
2995
  coins: 0
1807
2996
  }));
1808
2997
  }
1809
- /** Add goods to the player (gameplay rewards); answers the new holdings. */
2998
+ /**
2999
+ * Add goods to the player (gameplay rewards); answers the new holdings.
3000
+ * Offline, the cached holdings change at once and the grant replays later.
3001
+ */
1810
3002
  grant(bundle) {
1811
- return this.rest.call("grantInventoryItems", {
1812
- ...this.gameFields(),
1813
- ...bundle
1814
- });
3003
+ const goods = goodsOf(bundle);
3004
+ return this.mutateOffline(ECONOMY_KINDS.grant, goods, () => this.store.setInventory(withGoods(this.store.getInventory(), goods)), inventoryReply);
1815
3005
  }
1816
- /** An atomic swap with the house: remove `take`, add `give`. */
3006
+ /**
3007
+ * An atomic swap with the house: remove `take`, add `give`. Offline it is
3008
+ * checked against the cached holdings (409 when they can't cover `take`),
3009
+ * applied to them at once, and replayed later; the hub has the last word.
3010
+ */
1817
3011
  exchange(take, give) {
1818
- return this.rest.call("exchangeInventoryItems", {
1819
- ...this.gameFields(),
1820
- ...take === void 0 ? {} : { take },
1821
- ...give === void 0 ? {} : { give }
1822
- });
3012
+ const payload = {
3013
+ ...take === void 0 ? {} : { take: goodsOf(take) },
3014
+ ...give === void 0 ? {} : { give: goodsOf(give) }
3015
+ };
3016
+ return this.mutateOffline(ECONOMY_KINDS.exchange, payload, () => {
3017
+ const remaining = withoutGoods(this.store.getInventory(), payload.take ?? {});
3018
+ if (typeof remaining === "string") {
3019
+ if (this.isOffline) throw new HubError(remaining, 409);
3020
+ return this.store.getInventory();
3021
+ }
3022
+ return this.store.setInventory(withGoods(remaining, payload.give ?? {}));
3023
+ }, inventoryReply);
1823
3024
  }
1824
3025
  /** Sell items to the house for coins. */
1825
3026
  sell(items, coins) {
@@ -2135,12 +3336,11 @@ var Hub = class extends HubEconomy {
2135
3336
  ...this.gameFields(),
2136
3337
  day
2137
3338
  }),
2138
- complete: (day, token, meta) => this.rest.call("completeDailyChallenge", {
2139
- ...this.gameFields(),
3339
+ complete: (day, token, meta) => this.mutateOffline(DAILY_KIND, {
2140
3340
  day,
2141
3341
  token,
2142
3342
  meta
2143
- }),
3343
+ }, () => null, (result) => readDailyCompletion(result)),
2144
3344
  emit: (eventName, detail) => {
2145
3345
  emitWindowEvent(eventName, detail);
2146
3346
  }
@@ -2159,6 +3359,6 @@ function createHub(options = {}) {
2159
3359
  return new Hub(options);
2160
3360
  }
2161
3361
  //#endregion
2162
- export { ContentRejectedError as a, UNSUPPORTED_BY_HUB as c, CLIENT_VERSION_HEADER as i, createHub as n, HubError as o, hub as r, OfflineError as s, Hub as t };
3362
+ export { OUTBOX_FULL_CODE as a, OfflineApi as c, OfflineError as d, UNSUPPORTED_BY_HUB as f, CLIENT_VERSION_HEADER as i, ContentRejectedError as l, createHub as n, OUTBOX_MAX_CHARS as o, hub as r, OUTBOX_MAX_ENTRIES as s, Hub as t, HubError as u };
2163
3363
 
2164
3364
  //# sourceMappingURL=src.js.map