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

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 (40) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/auto.d.ts +2 -2
  3. package/dist/auto.js +2 -2
  4. package/dist/doctor/cli.mjs +1 -1
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.js +2 -2
  7. package/dist/rooms.js +1 -1
  8. package/dist/shared/client-version.gen.js +1 -1
  9. package/dist/shared/daily.d.ts +13 -4
  10. package/dist/shared/daily.d.ts.map +1 -1
  11. package/dist/shared/daily.js +15 -6
  12. package/dist/shared/daily.js.map +1 -1
  13. package/dist/shared/index.d.ts +237 -6
  14. package/dist/shared/index.d.ts.map +1 -1
  15. package/dist/shared/rooms.d.ts.map +1 -1
  16. package/dist/shared/rooms.js +12 -1
  17. package/dist/shared/rooms.js.map +1 -1
  18. package/dist/shared/src.js +696 -31
  19. package/dist/shared/src.js.map +1 -1
  20. package/package.json +2 -2
  21. package/src/core/connection.ts +6 -0
  22. package/src/core/errors.ts +8 -1
  23. package/src/core/hub-progress.ts +44 -8
  24. package/src/core/hub.ts +55 -4
  25. package/src/core/offline/cloud-saves.ts +62 -0
  26. package/src/core/offline/hub-kinds/index.ts +11 -3
  27. package/src/core/offline/hub-kinds/progress-kinds.ts +21 -4
  28. package/src/core/offline/hub-kinds/resume-kind.ts +108 -0
  29. package/src/core/offline/local-store.ts +46 -1
  30. package/src/core/page.ts +10 -0
  31. package/src/core/rest/routes.gen.ts +4 -0
  32. package/src/core/resume/hub-resume.ts +252 -0
  33. package/src/core/resume/local-resume.ts +130 -0
  34. package/src/core/resume/resume-list.ts +65 -0
  35. package/src/core/resume/resume-request.ts +52 -0
  36. package/src/core/resume/resume-types.ts +64 -0
  37. package/src/core/version/client-version.gen.ts +1 -1
  38. package/src/daily/daily-system.ts +14 -5
  39. package/src/index.ts +4 -1
  40. package/src/realtime/rooms-forwarder.ts +14 -1
@@ -6,7 +6,7 @@ import { t as getUiStack } from "./page-stack.js";
6
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
- import { t as roomsForwarder } from "./rooms.js";
9
+ import { n as roomsForwarder, t as isInHubRoom } from "./rooms.js";
10
10
  import { n as presenceForwarder, r as socialForwarder, t as notifyForwarder } from "./social.js";
11
11
  //#region src/core/storage/json-guards.ts
12
12
  /** Whether `candidate` is a non-null, non-array object. */
@@ -60,6 +60,12 @@ function isJsonObject(candidate) {
60
60
  */
61
61
  const UNSUPPORTED_BY_HUB = "unsupported_by_hub";
62
62
  /**
63
+ * The code `getSave` rejects with when the hub can't be reached and this
64
+ * device has no copy of the slot it can trust (a fresh device or cleared
65
+ * browser): the slot may well hold a save, so it is not reported empty.
66
+ */
67
+ const SAVE_UNKNOWN_CODE = "save_unknown";
68
+ /**
63
69
  * Thrown by every hub call that fails. `status` is the HTTP status when the
64
70
  * hub answered (401 = not signed in, 404 = unknown game or board, 413 = too
65
71
  * large), or `undefined` for a transport failure (offline, no host). Social
@@ -555,23 +561,36 @@ function mergeStats(waiting, incoming) {
555
561
  case "set": return incoming;
556
562
  }
557
563
  }
558
- /** The progress kinds, by name. */
559
- function progressKinds(game) {
564
+ /**
565
+ * The progress kinds, by name. A save write replayed from the queue sends
566
+ * the hub's `updatedAt` this device last saw for the slot (`null` when it
567
+ * saw it empty or never saw it), so the hub refuses it (409 `save_conflict`,
568
+ * dropped and reported as a conflict) when another device saved since. A
569
+ * write sent at once, online, always lands, as before.
570
+ */
571
+ function progressKinds(game, cloudSaves) {
560
572
  return {
561
573
  [PROGRESS_KINDS.save]: checkedKind(readSave$1, {
562
574
  mergeKey: (save) => save.slot,
563
575
  replaces: true,
564
576
  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", {
577
+ if ("deleted" in save) {
578
+ await context.call("deleteSave", {
579
+ ...game(),
580
+ slot: save.slot
581
+ });
582
+ cloudSaves.rememberCloudSave(save.slot, null);
583
+ return;
584
+ }
585
+ const base = context.isReplay ? { base: cloudSaves.cloudSave(save.slot) ?? null } : {};
586
+ const written = await context.call("writeSave", {
570
587
  ...game(),
571
588
  slot: save.slot,
572
589
  data: save.data,
573
- label: save.label
590
+ label: save.label,
591
+ ...base
574
592
  });
593
+ cloudSaves.rememberCloudSave(save.slot, written.updatedAt);
575
594
  }
576
595
  }),
577
596
  [PROGRESS_KINDS.stat]: checkedKind(readStat, {
@@ -618,6 +637,94 @@ function progressKinds(game) {
618
637
  };
619
638
  }
620
639
  //#endregion
640
+ //#region src/core/offline/hub-kinds/resume-kind.ts
641
+ /** The kind name. */
642
+ const RESUME_KIND = "hub.resume";
643
+ function readResume(stored) {
644
+ if (!isRecord(stored)) return;
645
+ if (stored.action === "clear") {
646
+ const before = optionalNumber(stored.before);
647
+ return before === void 0 ? void 0 : {
648
+ action: "clear",
649
+ before
650
+ };
651
+ }
652
+ const savedAt = optionalNumber(stored.savedAt);
653
+ const label = optionalString(stored.label);
654
+ const version = optionalNumber(stored.version);
655
+ const fraction = stored.fraction === null ? null : optionalNumber(stored.fraction);
656
+ if (stored.action !== "save" || savedAt === void 0 || label === void 0 || version === void 0 || fraction === void 0 || !isNullableString(stored.title) || stored.state !== void 0 && !isJson(stored.state)) return;
657
+ const base = {
658
+ action: "save",
659
+ savedAt,
660
+ label,
661
+ fraction,
662
+ title: stored.title,
663
+ version
664
+ };
665
+ return stored.state === void 0 ? base : {
666
+ ...base,
667
+ state: stored.state
668
+ };
669
+ }
670
+ /** The run to send: the device copy when it is at least as new as the op, else the op's own state; `undefined` when there is none. */
671
+ function runToSend(save, local, slug) {
672
+ const copy = local.read(slug);
673
+ if (copy?.status === "saved" && copy.at >= save.savedAt) return {
674
+ action: "save",
675
+ savedAt: copy.at,
676
+ label: copy.label,
677
+ fraction: copy.fraction,
678
+ title: copy.title,
679
+ version: copy.version,
680
+ state: copy.state
681
+ };
682
+ return save.state === void 0 ? void 0 : save;
683
+ }
684
+ /** Run `call`; on a lapsed session (401) start a guest session and run it once more. */
685
+ async function withSession(context, call) {
686
+ try {
687
+ return await call();
688
+ } catch (failure) {
689
+ if (!(failure instanceof HubError) || failure.status !== 401) throw failure;
690
+ await context.call("ensureGuestSession");
691
+ return call();
692
+ }
693
+ }
694
+ /** The resume kind, by name, over the device copies in `local`. */
695
+ function resumeKinds(game, local) {
696
+ return { [RESUME_KIND]: checkedKind(readResume, {
697
+ mergeKey: () => "resume",
698
+ replaces: true,
699
+ run: async (op, context) => {
700
+ const { slug } = game();
701
+ if (op.action === "clear") {
702
+ await withSession(context, () => context.call("clearResumeSlot", {
703
+ slug,
704
+ before: op.before
705
+ }));
706
+ local.markSynced(slug, op.before);
707
+ return;
708
+ }
709
+ const run = runToSend(op, local, slug);
710
+ if (run?.state === void 0) return;
711
+ const { state } = run;
712
+ await withSession(context, () => context.call("saveResumeSlot", {
713
+ slug,
714
+ state,
715
+ progress: {
716
+ label: run.label,
717
+ fraction: run.fraction
718
+ },
719
+ title: run.title,
720
+ version: run.version,
721
+ savedAt: run.savedAt
722
+ }));
723
+ local.markSynced(slug, run.savedAt);
724
+ }
725
+ }) };
726
+ }
727
+ //#endregion
621
728
  //#region src/core/offline/hub-kinds/legacy-ops.ts
622
729
  /** The outbox operation for a v1 queued write, or `undefined` when it no longer reads as one. */
623
730
  function legacyOperation(op) {
@@ -683,19 +790,71 @@ function legacyOperation(op) {
683
790
  }
684
791
  //#endregion
685
792
  //#region src/core/offline/hub-kinds/index.ts
686
- /**
687
- * Every kind the hub itself queues offline (`hub.*`), registered on a
688
- * client's outbox. The docs' "Offline and replay" table lists them.
689
- */
690
793
  /** Register every hub kind on `outbox`. */
691
794
  function registerHubKinds(outbox, hooks) {
692
795
  const kinds = {
693
- ...progressKinds(hooks.game),
796
+ ...progressKinds(hooks.game, hooks.cloudSaves),
694
797
  ...dailyKinds(hooks.game, hooks.onDailyComplete),
695
- ...economyKinds(hooks.game, writeLocalFavorites)
798
+ ...economyKinds(hooks.game, writeLocalFavorites),
799
+ ...resumeKinds(hooks.game, hooks.resume)
696
800
  };
697
801
  for (const [kind, definition] of Object.entries(kinds)) outbox.define(kind, definition);
698
802
  }
803
+ //#endregion
804
+ //#region src/core/offline/cloud-saves.ts
805
+ /**
806
+ * What this device last saw of a game's save slots on the hub, per player:
807
+ * each slot's `updatedAt` there, or `null` for a slot known to be empty. It
808
+ * decides two things offline. `getSave` answers from the cache only when
809
+ * the device knows the hub's copy of that slot (so a fresh device or cleared
810
+ * browser never reports "no save" for a slot it simply hasn't seen), and a
811
+ * queued save write sends the hub's `updatedAt` it was built on, so the hub
812
+ * refuses it when the slot has changed since.
813
+ *
814
+ * A snapshot gives the full list (`complete`: any other slot is empty); a
815
+ * single read or write adds that slot to a partial list.
816
+ */
817
+ /** A stored index, or `null` when missing or malformed. */
818
+ function readCloudSaveIndex(stored) {
819
+ if (!isRecord(stored) || typeof stored.complete !== "boolean" || !isRecord(stored.saves)) return null;
820
+ const userId = optionalNumber(stored.userId);
821
+ if (userId === void 0) return null;
822
+ const saves = {};
823
+ for (const [slot, updatedAt] of Object.entries(stored.saves)) {
824
+ const known = updatedAt === null ? null : optionalNumber(updatedAt);
825
+ if (known === void 0) return null;
826
+ saves[slot] = known;
827
+ }
828
+ return {
829
+ userId,
830
+ complete: stored.complete,
831
+ saves
832
+ };
833
+ }
834
+ /**
835
+ * The hub's `updatedAt` for `slot` as `userId` last saw it: a number, `null`
836
+ * when the slot was empty, or `undefined` when this device doesn't know.
837
+ */
838
+ function cloudSaveOf(index, userId, slot) {
839
+ if (index?.userId !== userId) return;
840
+ const updatedAt = index.saves[slot];
841
+ return updatedAt === void 0 && index.complete ? null : updatedAt;
842
+ }
843
+ /** `index` with `slot` seen at `updatedAt` (`null`: empty) by `userId`; another player's index starts over. */
844
+ function withCloudSave(index, userId, slot, updatedAt) {
845
+ const base = index?.userId === userId ? index : {
846
+ userId,
847
+ complete: false,
848
+ saves: {}
849
+ };
850
+ return {
851
+ ...base,
852
+ saves: {
853
+ ...base.saves,
854
+ [slot]: updatedAt
855
+ }
856
+ };
857
+ }
699
858
  /** The well-formed events of a stored buffer. */
700
859
  function readEvents(stored) {
701
860
  if (!Array.isArray(stored)) return [];
@@ -909,6 +1068,28 @@ var LocalStore = class {
909
1068
  deleteSave(slot) {
910
1069
  this.store.remove(`${this.prefix}save:${slot}`);
911
1070
  }
1071
+ /**
1072
+ * The hub's `updatedAt` for `slot` as the current user last saw it (`null`:
1073
+ * the slot was empty), or `undefined` when this device doesn't know.
1074
+ */
1075
+ cloudSave(slot) {
1076
+ const user = this.getUser();
1077
+ return user === null ? void 0 : cloudSaveOf(this.readCloudSaves(this.prefix), user.id, slot);
1078
+ }
1079
+ /** Note the hub's copy of `slot` (just read or written) for the current user: its `updatedAt`, or `null` when empty. */
1080
+ rememberCloudSave(slot, updatedAt) {
1081
+ const user = this.getUser();
1082
+ if (user !== null) this.writeStored(`${this.prefix}cloud-saves`, withCloudSave(this.readCloudSaves(this.prefix), user.id, slot, updatedAt));
1083
+ }
1084
+ /** Cache a slot as read from the hub (forgetting it when the hub has none), and note the hub's copy. */
1085
+ cacheCloudSave(slot, save) {
1086
+ if (save === null) this.deleteSave(slot);
1087
+ else this.writeStored(`${this.prefix}save:${slot}`, save);
1088
+ this.rememberCloudSave(slot, save?.updatedAt ?? null);
1089
+ }
1090
+ readCloudSaves(gamePrefix) {
1091
+ return readCloudSaveIndex(this.readStored(`${gamePrefix}cloud-saves`));
1092
+ }
912
1093
  /** This game's cached stats. */
913
1094
  getStats() {
914
1095
  const stored = this.readStored(`${this.prefix}stats`);
@@ -985,13 +1166,32 @@ var LocalStore = class {
985
1166
  ts: Date.now()
986
1167
  }));
987
1168
  }
988
- /** Fan a `/me/snapshot` reply out across every game's cache keys. */
1169
+ /**
1170
+ * Fan a `/me/snapshot` reply out across every game's cache keys. It lists
1171
+ * every save slot of every game the player has played, so each game's
1172
+ * slots on the hub become known (and this game's are all empty when it
1173
+ * isn't listed).
1174
+ */
989
1175
  hydrate(snapshot) {
990
1176
  if (snapshot.user !== null) this.writeStored(USER_KEY, snapshot.user);
1177
+ const userId = snapshot.user?.id;
1178
+ if (userId !== void 0 && !Object.keys(snapshot.games).some((slug) => `hub:${slug}:` === this.prefix)) this.writeStored(`${this.prefix}cloud-saves`, {
1179
+ userId,
1180
+ complete: true,
1181
+ saves: {}
1182
+ });
991
1183
  for (const [slug, game] of Object.entries(snapshot.games)) {
992
1184
  const gamePrefix = `hub:${slug}:`;
993
1185
  if (game.profile !== null) this.writeStored(`${gamePrefix}profile`, game.profile);
994
1186
  for (const save of game.saves) this.writeStored(`${gamePrefix}save:${save.slot}`, save);
1187
+ if (userId !== void 0) {
1188
+ const saves = Object.fromEntries(game.saves.map((save) => [save.slot, save.updatedAt]));
1189
+ this.writeStored(`${gamePrefix}cloud-saves`, {
1190
+ userId,
1191
+ complete: true,
1192
+ saves
1193
+ });
1194
+ }
995
1195
  this.writeStored(`${gamePrefix}stats`, game.stats);
996
1196
  this.writeStored(`${gamePrefix}inventory`, game.inventory);
997
1197
  }
@@ -1576,6 +1776,85 @@ function outboxFull() {
1576
1776
  return new HubError("Too many changes are waiting to sync. Reconnect to save more.", 507, { code: OUTBOX_FULL_CODE });
1577
1777
  }
1578
1778
  //#endregion
1779
+ //#region src/core/resume/local-resume.ts
1780
+ const KEY_PATTERN = /^hub:(.+):resume$/;
1781
+ function keyFor(slug) {
1782
+ return `hub:${slug}:resume`;
1783
+ }
1784
+ /** A stored record read back, or `null` when missing or malformed. */
1785
+ function readRecord(stored) {
1786
+ if (!isRecord(stored)) return null;
1787
+ const at = optionalNumber(stored.at);
1788
+ const userId = stored.userId === null ? null : optionalNumber(stored.userId);
1789
+ const isSynced = stored.isSynced === true;
1790
+ if (at === void 0 || userId === void 0) return null;
1791
+ if (stored.status === "cleared") return {
1792
+ status: "cleared",
1793
+ userId,
1794
+ at,
1795
+ isSynced
1796
+ };
1797
+ const label = optionalString(stored.label);
1798
+ const gameTitle = optionalString(stored.gameTitle);
1799
+ const version = optionalNumber(stored.version);
1800
+ const fraction = stored.fraction === null ? null : optionalNumber(stored.fraction);
1801
+ if (stored.status !== "saved" || label === void 0 || gameTitle === void 0 || version === void 0 || fraction === void 0 || !isNullableString(stored.title) || !isJson(stored.state)) return null;
1802
+ return {
1803
+ status: "saved",
1804
+ userId,
1805
+ at,
1806
+ label,
1807
+ fraction,
1808
+ title: stored.title,
1809
+ version,
1810
+ gameTitle,
1811
+ state: stored.state,
1812
+ isSynced
1813
+ };
1814
+ }
1815
+ /** The device copies of every game's slot. */
1816
+ var LocalResumeStore = class {
1817
+ constructor(store) {
1818
+ _defineProperty(this, "store", void 0);
1819
+ this.store = store;
1820
+ }
1821
+ /** `slug`'s copy, or `null`. */
1822
+ read(slug) {
1823
+ const text = this.store.read(keyFor(slug));
1824
+ return text === null ? null : readRecord(parseJsonText(text));
1825
+ }
1826
+ /** Store `slug`'s copy; answers whether it stuck (a full storage refuses a large state). */
1827
+ write(slug, record) {
1828
+ const text = JSON.stringify(record);
1829
+ this.store.write(keyFor(slug), text);
1830
+ return this.store.read(keyFor(slug)) === text;
1831
+ }
1832
+ /** Forget `slug`'s copy. */
1833
+ remove(slug) {
1834
+ this.store.remove(keyFor(slug));
1835
+ }
1836
+ /** The hub now has `slug`'s save or clear made at `at`: mark it synced (a clear is simply forgotten). */
1837
+ markSynced(slug, at) {
1838
+ const record = this.read(slug);
1839
+ if (record === null || record.at !== at) return;
1840
+ if (record.status === "cleared") this.remove(slug);
1841
+ else this.write(slug, {
1842
+ ...record,
1843
+ isSynced: true
1844
+ });
1845
+ }
1846
+ /** Every game's copy, by slug. */
1847
+ all() {
1848
+ const records = /* @__PURE__ */ new Map();
1849
+ for (const key of this.store.keys()) {
1850
+ const slug = KEY_PATTERN.exec(key)?.[1];
1851
+ const record = slug === void 0 ? null : this.read(slug);
1852
+ if (slug !== void 0 && record !== null) records.set(slug, record);
1853
+ }
1854
+ return records;
1855
+ }
1856
+ };
1857
+ //#endregion
1579
1858
  //#region src/core/page.ts
1580
1859
  /** Whether this code runs in a browser page. */
1581
1860
  function isBrowserPage() {
@@ -1591,6 +1870,13 @@ function injectedConfig() {
1591
1870
  ...apiBase === void 0 ? {} : { apiBase }
1592
1871
  };
1593
1872
  }
1873
+ /** The game's display title: the host's injected one, else the page's `<title>`, else `fallback`. */
1874
+ function detectGameTitle(fallback) {
1875
+ const injected = isBrowserPage() && isRecord(window.__HUB__) ? optionalString(window.__HUB__.title) : void 0;
1876
+ if (injected !== void 0 && injected !== "") return injected;
1877
+ const pageTitle = typeof document === "undefined" ? "" : document.title.trim();
1878
+ return pageTitle === "" ? fallback : pageTitle;
1879
+ }
1594
1880
  /** The game's slug: the injected one, else the first URL path segment, else `null`. */
1595
1881
  function detectSlug() {
1596
1882
  const injectedSlug = injectedConfig().slug;
@@ -2246,6 +2532,30 @@ const ROUTES = {
2246
2532
  path: "/version",
2247
2533
  query: [],
2248
2534
  offline: null
2535
+ },
2536
+ getResumeSlot: {
2537
+ method: "GET",
2538
+ path: "/games/:slug/resume",
2539
+ query: [],
2540
+ offline: null
2541
+ },
2542
+ saveResumeSlot: {
2543
+ method: "PUT",
2544
+ path: "/games/:slug/resume",
2545
+ query: [],
2546
+ offline: "replay"
2547
+ },
2548
+ clearResumeSlot: {
2549
+ method: "DELETE",
2550
+ path: "/games/:slug/resume",
2551
+ query: ["before"],
2552
+ offline: "replay"
2553
+ },
2554
+ listMyResumeSlots: {
2555
+ method: "GET",
2556
+ path: "/me/resume",
2557
+ query: [],
2558
+ offline: null
2249
2559
  }
2250
2560
  };
2251
2561
  /** The request header that carries an offline operation key (the contract's `OFFLINE_OP_HEADER`). */
@@ -2369,6 +2679,7 @@ var HubConnection = class {
2369
2679
  _defineProperty(this, "rest", void 0);
2370
2680
  _defineProperty(this, "store", void 0);
2371
2681
  _defineProperty(this, "outbox", void 0);
2682
+ _defineProperty(this, "resumeStore", void 0);
2372
2683
  _defineProperty(this, "offlineMode", void 0);
2373
2684
  _defineProperty(this, "isReachable", true);
2374
2685
  _defineProperty(this, "runningSync", null);
@@ -2390,6 +2701,7 @@ var HubConnection = class {
2390
2701
  });
2391
2702
  const pageStore = createPageStore();
2392
2703
  this.store = new LocalStore(this.slug ?? "app", pageStore);
2704
+ this.resumeStore = new LocalResumeStore(pageStore);
2393
2705
  this.outbox = new Outbox({
2394
2706
  slug: this.slug ?? "app",
2395
2707
  store: pageStore,
@@ -2414,7 +2726,9 @@ var HubConnection = class {
2414
2726
  rank: completion.rank,
2415
2727
  meta: payload.meta
2416
2728
  });
2417
- }
2729
+ },
2730
+ resume: this.resumeStore,
2731
+ cloudSaves: this.store
2418
2732
  });
2419
2733
  this.importLegacyQueue();
2420
2734
  if (typeof navigator !== "undefined" && "onLine" in navigator) this.isReachable = navigator.onLine;
@@ -2682,7 +2996,7 @@ var HubProgress = class extends HubConnection {
2682
2996
  * sign-in. Idempotent. Offline, a local guest is used.
2683
2997
  */
2684
2998
  ensureGuest() {
2685
- return this.withFallback(() => this.rest.call("ensureGuestSession"), () => {
2999
+ return this.withFallback(async () => this.signedIn(await this.rest.call("ensureGuestSession")), () => {
2686
3000
  const known = this.store.getUser();
2687
3001
  if (known !== null) return { user: known };
2688
3002
  const guest = {
@@ -2700,10 +3014,10 @@ var HubProgress = class extends HubConnection {
2700
3014
  * the name is taken; the player should `login` instead.
2701
3015
  */
2702
3016
  claim(username, password) {
2703
- return this.withFallback(() => this.rest.call("claimGuestAccount", {
3017
+ return this.withFallback(async () => this.signedIn(await this.rest.call("claimGuestAccount", {
2704
3018
  username,
2705
3019
  password
2706
- }), () => this.signInLocally({
3020
+ })), () => this.signInLocally({
2707
3021
  id: 0,
2708
3022
  username,
2709
3023
  guest: false
@@ -2711,27 +3025,31 @@ var HubProgress = class extends HubConnection {
2711
3025
  }
2712
3026
  /** Create an account and sign in. (Offline: a local user is stored.) */
2713
3027
  signup(username, password) {
2714
- return this.withFallback(() => this.rest.call("signUpWithPassword", {
3028
+ return this.withFallback(async () => this.signedIn(await this.rest.call("signUpWithPassword", {
2715
3029
  username,
2716
3030
  password
2717
- }), () => this.signInLocally({
3031
+ })), () => this.signInLocally({
2718
3032
  id: 0,
2719
3033
  username
2720
3034
  }));
2721
3035
  }
2722
3036
  /** Sign in. (Offline: a local user is stored so signed-in flows work.) */
2723
3037
  login(username, password) {
2724
- return this.withFallback(() => this.rest.call("logInWithPassword", {
3038
+ return this.withFallback(async () => this.signedIn(await this.rest.call("logInWithPassword", {
2725
3039
  username,
2726
3040
  password
2727
- }), () => this.signInLocally({
3041
+ })), () => this.signInLocally({
2728
3042
  id: 0,
2729
3043
  username
2730
3044
  }));
2731
3045
  }
2732
3046
  /** Sign out (ends the session shared by every game). */
2733
3047
  logout() {
2734
- return this.withFallback(() => this.rest.call("logOut"), () => {
3048
+ return this.withFallback(async () => {
3049
+ const reply = await this.rest.call("logOut");
3050
+ this.store.clearUser();
3051
+ return reply;
3052
+ }, () => {
2735
3053
  this.store.clearUser();
2736
3054
  return { ok: true };
2737
3055
  });
@@ -2740,6 +3058,11 @@ var HubProgress = class extends HubConnection {
2740
3058
  this.store.setUser(user);
2741
3059
  return { user };
2742
3060
  }
3061
+ /** Remember who the hub signed in, so the cache's notes about the hub's copies are read for the right player. */
3062
+ signedIn(reply) {
3063
+ this.store.setUser(reply.user);
3064
+ return reply;
3065
+ }
2743
3066
  /** This game's profile for the current user, or `profile: null`. `TData` is the game's own data type. */
2744
3067
  getProfile() {
2745
3068
  return this.withFallback(async () => ({ profile: typedProfile((await this.rest.call("getGameProfile", this.gameFields())).profile) }), () => ({ profile: typedProfile(this.store.getProfile()) }));
@@ -2762,12 +3085,29 @@ var HubProgress = class extends HubConnection {
2762
3085
  listSaves() {
2763
3086
  return this.withFallback(() => this.rest.call("listSaves", this.gameFields()), () => ({ saves: this.store.listSaves() }));
2764
3087
  }
2765
- /** One slot's contents, or `save: null` when the slot is empty. `TData` is the game's own save type. */
3088
+ /**
3089
+ * One slot's contents, or `save: null` when the slot is empty. `TData` is
3090
+ * the game's own save type. Offline it answers from the device's copy, but
3091
+ * only when the device knows the hub's copy of the slot; otherwise (a fresh
3092
+ * device or cleared browser) it rejects with a {@link HubError} whose
3093
+ * `code` is `save_unknown` (`isOffline` is `true`), never a false "empty".
3094
+ */
2766
3095
  getSave(slot) {
2767
- return this.withFallback(async () => ({ save: typedSave((await this.rest.call("getSave", {
2768
- ...this.gameFields(),
2769
- slot
2770
- })).save) }), () => ({ save: typedSave(this.store.getSave(slot)) }));
3096
+ return this.withFallback(async () => {
3097
+ const { save } = await this.rest.call("getSave", {
3098
+ ...this.gameFields(),
3099
+ slot
3100
+ });
3101
+ if (this.outbox.count() === 0) this.store.cacheCloudSave(slot, save);
3102
+ return { save: typedSave(save) };
3103
+ }, () => ({ save: typedSave(this.offlineSave(slot)) }));
3104
+ }
3105
+ /** The device's copy of `slot`, when it can be trusted offline (see {@link getSave}). */
3106
+ offlineSave(slot) {
3107
+ const isLocalOnly = this.store.getUser()?.id === 0;
3108
+ const hasQueuedWrite = this.outbox.pending().some((op) => op.kind === PROGRESS_KINDS.save && isRecord(op.payload) && op.payload.slot === slot);
3109
+ if (!isLocalOnly && !hasQueuedWrite && this.store.cloudSave(slot) === void 0) throw new HubError(`Hub: offline, and this device hasn't seen save "${slot}" yet.`, void 0, { code: SAVE_UNKNOWN_CODE });
3110
+ return this.store.getSave(slot);
2771
3111
  }
2772
3112
  /** Create or overwrite a save slot. `saveData` is any JSON up to 64 KB. */
2773
3113
  putSave(slot, saveData, label) {
@@ -3120,6 +3460,292 @@ const hubMenu = {
3120
3460
  }
3121
3461
  };
3122
3462
  //#endregion
3463
+ //#region src/core/resume/resume-list.ts
3464
+ function fromHub(summary) {
3465
+ return {
3466
+ slug: summary.game,
3467
+ gameTitle: summary.gameTitle,
3468
+ path: summary.path,
3469
+ hasImage: summary.hasImage,
3470
+ progress: summary.progress,
3471
+ title: summary.title,
3472
+ savedAt: summary.updatedAt,
3473
+ isLocalOnly: false
3474
+ };
3475
+ }
3476
+ function fromDevice(slug, run, known) {
3477
+ return {
3478
+ slug,
3479
+ gameTitle: known?.gameTitle ?? run.gameTitle,
3480
+ path: known?.path ?? `/${slug}`,
3481
+ hasImage: known?.hasImage ?? false,
3482
+ progress: {
3483
+ label: run.label,
3484
+ fraction: run.fraction
3485
+ },
3486
+ title: run.title,
3487
+ savedAt: run.at,
3488
+ isLocalOnly: !run.isSynced
3489
+ };
3490
+ }
3491
+ /** One game's entry, from the hub's summary (if any) and the device copy (if the player's). */
3492
+ function mergeOne(slug, hub, copy, isHubKnown) {
3493
+ if (copy === void 0) return hub === void 0 ? null : fromHub(hub);
3494
+ if (copy.status === "cleared") return hub !== void 0 && hub.updatedAt > copy.at ? fromHub(hub) : null;
3495
+ if (hub !== void 0 && hub.updatedAt >= copy.at) return fromHub(hub);
3496
+ return !copy.isSynced || !isHubKnown || hub !== void 0 ? fromDevice(slug, copy, hub) : null;
3497
+ }
3498
+ /**
3499
+ * The runs to show, newest first. `hub` is the hub's list, or `undefined`
3500
+ * when it couldn't be reached; `copies` are the device's, by slug; only
3501
+ * those saved by `userId` count.
3502
+ */
3503
+ function mergeResumeLists(hub, copies, userId) {
3504
+ const hubBySlug = new Map((hub ?? []).map((summary) => [summary.game, summary]));
3505
+ const mine = new Map([...copies].filter(([, copy]) => copy.userId === userId));
3506
+ return [.../* @__PURE__ */ new Set([...hubBySlug.keys(), ...mine.keys()])].map((slug) => mergeOne(slug, hubBySlug.get(slug), mine.get(slug), hub !== void 0)).filter((entry) => entry !== null).sort((a, b) => b.savedAt - a.savedAt);
3507
+ }
3508
+ //#endregion
3509
+ //#region src/core/resume/resume-request.ts
3510
+ /**
3511
+ * Whether this page was opened from the home page's Resume card
3512
+ * (`/<slug>/?hub_resume=1`). The first client copy on the page to look takes
3513
+ * the parameter out of the address bar (so a reload or a shared link doesn't
3514
+ * resume again) and records the answer on `window`, so every other copy on
3515
+ * the page (a game's bundled client and the host's `/_hub/hub.js`) answers
3516
+ * the same, however late it asks.
3517
+ */
3518
+ /** The query parameter (the contract's `RESUME_REQUEST_PARAM`; the browser client carries no contract code). */
3519
+ const RESUME_REQUEST_PARAM = "hub_resume";
3520
+ /** Where the answer is kept for the page. */
3521
+ const PAGE_FLAG = "__HUB_RESUME_REQUESTED__";
3522
+ /** Take `hub_resume` out of the address bar, keeping the rest of the URL and the history state. */
3523
+ function stripParam(url) {
3524
+ url.searchParams.delete(RESUME_REQUEST_PARAM);
3525
+ try {
3526
+ history.replaceState(history.state, "", `${url.pathname}${url.search}${url.hash}`);
3527
+ } catch {}
3528
+ }
3529
+ /** Whether the address has `hub_resume`; it is taken out when it does. */
3530
+ function takeParam() {
3531
+ try {
3532
+ const url = new URL(location.href);
3533
+ if (!url.searchParams.has("hub_resume")) return false;
3534
+ stripParam(url);
3535
+ return true;
3536
+ } catch {
3537
+ return false;
3538
+ }
3539
+ }
3540
+ /** `true` when the page was opened to resume (synchronous, so a game can decide before it routes or draws). */
3541
+ function isResumeRequested() {
3542
+ if (!isBrowserPage()) return false;
3543
+ if (Reflect.has(window, PAGE_FLAG)) return Reflect.get(window, PAGE_FLAG) === true;
3544
+ const isRequested = takeParam();
3545
+ Reflect.set(window, PAGE_FLAG, isRequested);
3546
+ return isRequested;
3547
+ }
3548
+ //#endregion
3549
+ //#region src/core/resume/hub-resume.ts
3550
+ /** How long saves must be quiet before the latest is sent, ms. */
3551
+ const RESUME_DEBOUNCE_MS = 3e3;
3552
+ /** Largest state, bytes of JSON (the contract's `RESUME_MAX_STATE_BYTES`). */
3553
+ const RESUME_MAX_STATE_BYTES = 524288;
3554
+ /** What a game should do when a save is ignored, by blocker (logged once per page). */
3555
+ const BLOCKED_HINTS = {
3556
+ daily: "a daily challenge is active. When the player leaves a daily without finishing it, call hub.daily.cancel().",
3557
+ race: "a race is on: races are never resumed.",
3558
+ room: "the page is in a hub room: online games are never resumed."
3559
+ };
3560
+ /** Longest label or title kept (the contract's `RESUME_TEXT_MAX`). */
3561
+ const TEXT_MAX = 60;
3562
+ /** A label or title trimmed and clipped, or `null` when blank. */
3563
+ function shortText(text) {
3564
+ const trimmed = (text ?? "").trim();
3565
+ return trimmed === "" ? null : trimmed.slice(0, TEXT_MAX);
3566
+ }
3567
+ /** The UTF-8 size of `text`. */
3568
+ function byteSize(text) {
3569
+ return new TextEncoder().encode(text).length;
3570
+ }
3571
+ /** The device copy as a run read back. */
3572
+ function runFromCopy(copy) {
3573
+ return {
3574
+ state: toGameData(copy.state),
3575
+ progress: {
3576
+ label: copy.label,
3577
+ fraction: copy.fraction
3578
+ },
3579
+ title: copy.title,
3580
+ version: copy.version,
3581
+ savedAt: copy.at
3582
+ };
3583
+ }
3584
+ /** The hub's slot as a run read back. */
3585
+ function runFromSlot(slot) {
3586
+ return {
3587
+ state: toGameData(slot.state),
3588
+ progress: slot.progress,
3589
+ title: slot.title,
3590
+ version: slot.version,
3591
+ savedAt: slot.updatedAt
3592
+ };
3593
+ }
3594
+ /** `hub.resume`. */
3595
+ var HubResume = class {
3596
+ constructor(host) {
3597
+ _defineProperty(this, "host", void 0);
3598
+ _defineProperty(this, "timer", null);
3599
+ _defineProperty(this, "waiting", null);
3600
+ _defineProperty(this, "isListening", false);
3601
+ _defineProperty(this, "warnedBlockers", /* @__PURE__ */ new Set());
3602
+ this.host = host;
3603
+ }
3604
+ /**
3605
+ * `true` when the page was opened from the Resume card (`?hub_resume=1`,
3606
+ * taken out of the address bar). Synchronous, so a game can restore before
3607
+ * it routes or draws its title screen.
3608
+ */
3609
+ get requested() {
3610
+ return isResumeRequested();
3611
+ }
3612
+ /** Why a save would be ignored right now (`race`, `daily`, `room`), or `null`. */
3613
+ get blockedBy() {
3614
+ return this.host.blocker();
3615
+ }
3616
+ /**
3617
+ * Save the run (the latest save wins). Stored on the device at once; sent
3618
+ * after {@link RESUME_DEBOUNCE_MS} of quiet, or when the page is hidden.
3619
+ * Returns `false` (and saves nothing) in a race, a daily challenge or an
3620
+ * online room. Throws a `HubError` 413 over 512 KB, 400 without a label.
3621
+ */
3622
+ save(run) {
3623
+ const blocker = this.host.blocker();
3624
+ if (blocker !== null) {
3625
+ this.warnBlocked(blocker);
3626
+ return false;
3627
+ }
3628
+ const slug = this.requireSlug();
3629
+ const label = shortText(run.progress.label);
3630
+ if (label === null) throw new HubError("hub.resume.save: progress.label is required.", 400);
3631
+ const state = toStoredJson(run.state);
3632
+ if (byteSize(JSON.stringify(state)) > 524288) throw new HubError(`hub.resume.save: state exceeds ${String(RESUME_MAX_STATE_BYTES)} bytes.`, 413);
3633
+ const fraction = typeof run.progress.fraction === "number" && Number.isFinite(run.progress.fraction) ? Math.min(1, Math.max(0, run.progress.fraction)) : null;
3634
+ const copy = {
3635
+ status: "saved",
3636
+ userId: this.host.currentUserId(),
3637
+ at: Date.now(),
3638
+ label,
3639
+ fraction,
3640
+ title: shortText(run.title),
3641
+ version: Math.max(0, Math.floor(run.version ?? 0)),
3642
+ gameTitle: this.host.gameTitle(),
3643
+ state,
3644
+ isSynced: false
3645
+ };
3646
+ const isStored = this.host.local.write(slug, copy);
3647
+ this.schedule({
3648
+ action: "save",
3649
+ savedAt: copy.at,
3650
+ label,
3651
+ fraction,
3652
+ title: copy.title,
3653
+ version: copy.version,
3654
+ ...isStored ? {} : { state }
3655
+ });
3656
+ return true;
3657
+ }
3658
+ /** Send a waiting save now (the page does this itself when hidden or closed). */
3659
+ async flush() {
3660
+ if (this.timer !== null) {
3661
+ clearTimeout(this.timer);
3662
+ this.timer = null;
3663
+ }
3664
+ const payload = this.waiting;
3665
+ this.waiting = null;
3666
+ if (payload !== null) await this.host.send(payload);
3667
+ }
3668
+ /** Clear the run: call it when the run ends (won, lost or quit). Works offline. */
3669
+ async clear() {
3670
+ const slug = this.requireSlug();
3671
+ if (this.timer !== null) {
3672
+ clearTimeout(this.timer);
3673
+ this.timer = null;
3674
+ }
3675
+ this.waiting = null;
3676
+ const at = Date.now();
3677
+ this.host.local.write(slug, {
3678
+ status: "cleared",
3679
+ userId: this.host.currentUserId(),
3680
+ at,
3681
+ isSynced: false
3682
+ });
3683
+ await this.host.send({
3684
+ action: "clear",
3685
+ before: at
3686
+ });
3687
+ }
3688
+ /** The saved run with its progress and time, or `null`. The newer of the device's copy and the hub's wins. */
3689
+ async get(options = {}) {
3690
+ const slug = this.requireSlug();
3691
+ const copy = this.host.local.read(slug);
3692
+ const mine = copy !== null && copy.userId === this.host.currentUserId() ? copy : null;
3693
+ const slot = await this.host.fetchSlot();
3694
+ let run;
3695
+ if (slot !== void 0 && slot !== null && (mine === null || slot.updatedAt >= mine.at)) run = runFromSlot(slot);
3696
+ else {
3697
+ const isClearedElsewhere = slot === null && mine?.isSynced === true;
3698
+ run = mine?.status === "saved" && !isClearedElsewhere ? runFromCopy(mine) : null;
3699
+ }
3700
+ if (run === null || options.version === void 0 || run.version === options.version) return run;
3701
+ if (run.version < options.version) await this.clear();
3702
+ return null;
3703
+ }
3704
+ /** The saved state, or `null` (see {@link get} for the version rule). */
3705
+ async load(options = {}) {
3706
+ return (await this.get(options))?.state ?? null;
3707
+ }
3708
+ /** The player's saved runs in every resumable game, newest first (the home page's Resume card). */
3709
+ async list() {
3710
+ return mergeResumeLists(await this.host.fetchList(), this.host.local.all(), this.host.currentUserId());
3711
+ }
3712
+ /** Discard another game's run from the home page (online only: throws a `HubError` offline). */
3713
+ async discard(slug) {
3714
+ await this.host.clearOther(slug);
3715
+ this.host.local.remove(slug);
3716
+ }
3717
+ /** Say once per blocker why saves are ignored, so a game that forgot `hub.daily.cancel()` finds out. */
3718
+ warnBlocked(blocker) {
3719
+ if (this.warnedBlockers.has(blocker)) return;
3720
+ this.warnedBlockers.add(blocker);
3721
+ console.warn(`[hub] hub.resume.save ignored: ${BLOCKED_HINTS[blocker]}`);
3722
+ }
3723
+ requireSlug() {
3724
+ if (this.host.slug === null) throw new HubError("hub.resume: could not determine the game slug.");
3725
+ return this.host.slug;
3726
+ }
3727
+ schedule(payload) {
3728
+ this.waiting = payload;
3729
+ if (this.timer !== null) clearTimeout(this.timer);
3730
+ this.timer = setTimeout(() => {
3731
+ this.flush().catch(() => void 0);
3732
+ }, RESUME_DEBOUNCE_MS);
3733
+ this.listenToPage();
3734
+ }
3735
+ /** Send a waiting save as soon as the page is hidden or closed. */
3736
+ listenToPage() {
3737
+ if (this.isListening || !isBrowserPage()) return;
3738
+ this.isListening = true;
3739
+ const flushNow = () => {
3740
+ this.flush().catch(() => void 0);
3741
+ };
3742
+ window.addEventListener("pagehide", flushNow);
3743
+ if (typeof document !== "undefined") document.addEventListener("visibilitychange", () => {
3744
+ if (document.visibilityState === "hidden") flushNow();
3745
+ });
3746
+ }
3747
+ };
3748
+ //#endregion
3123
3749
  //#region src/core/ticket.ts
3124
3750
  /**
3125
3751
  * The own-server ticket request (`hub.mp.ticket()`): a 60-second identity
@@ -3261,6 +3887,7 @@ var Hub = class extends HubEconomy {
3261
3887
  constructor(..._args) {
3262
3888
  super(..._args);
3263
3889
  _defineProperty(this, "clientVersion", HUB_CLIENT_VERSION);
3890
+ _defineProperty(this, "resumeApi", void 0);
3264
3891
  }
3265
3892
  /**
3266
3893
  * The hub's version and the client versions it supports (`GET /_api/version`),
@@ -3326,6 +3953,43 @@ var Hub = class extends HubEconomy {
3326
3953
  get race() {
3327
3954
  return raceForwarder;
3328
3955
  }
3956
+ /**
3957
+ * Resume a saved game: `hub.resume.save({ state, progress })` during a long
3958
+ * single-player run, `clear()` when it ends, `load()` and `requested` to
3959
+ * pick it up again (docs: "Resume saved games"). Needs `game.resumable: true`.
3960
+ */
3961
+ get resume() {
3962
+ this.resumeApi ?? (this.resumeApi = new HubResume(this.resumeHost()));
3963
+ return this.resumeApi;
3964
+ }
3965
+ /** What `hub.resume` needs from this client. */
3966
+ resumeHost() {
3967
+ return {
3968
+ slug: this.slug,
3969
+ local: this.resumeStore,
3970
+ currentUserId: () => this.store.getUser()?.id ?? null,
3971
+ gameTitle: () => detectGameTitle(this.slug ?? ""),
3972
+ blocker: () => {
3973
+ if (raceForwarder.current() !== null) return "race";
3974
+ return this.daily.active === null ? isInHubRoom() ? "room" : null : "daily";
3975
+ },
3976
+ send: async (payload) => {
3977
+ await this.mutateOffline(RESUME_KIND, payload, () => void 0);
3978
+ },
3979
+ fetchSlot: () => this.withFallback(async () => {
3980
+ try {
3981
+ return (await this.rest.call("getResumeSlot", this.gameFields())).resume;
3982
+ } catch (failure) {
3983
+ if (failure instanceof HubError && failure.status === 401) return null;
3984
+ throw failure;
3985
+ }
3986
+ }, () => void 0),
3987
+ fetchList: () => this.withFallback(async () => (await this.rest.call("listMyResumeSlots")).games, () => void 0),
3988
+ clearOther: async (slug) => {
3989
+ await this.rest.call("clearResumeSlot", { slug });
3990
+ }
3991
+ };
3992
+ }
3329
3993
  /** What the daily system needs from this client: its REST calls and window events. */
3330
3994
  dailyHost() {
3331
3995
  return {
@@ -3354,11 +4018,12 @@ linkRealtimeToHub({
3354
4018
  });
3355
4019
  /** A ready-to-use client, with the slug and API base detected from the page. */
3356
4020
  const hub = new Hub();
4021
+ isResumeRequested();
3357
4022
  /** Create a client for another slug or API base, or with other offline behaviour. */
3358
4023
  function createHub(options = {}) {
3359
4024
  return new Hub(options);
3360
4025
  }
3361
4026
  //#endregion
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 };
4027
+ export { RESUME_DEBOUNCE_MS as a, OUTBOX_FULL_CODE as c, OfflineApi as d, ContentRejectedError as f, UNSUPPORTED_BY_HUB as g, SAVE_UNKNOWN_CODE as h, HubResume as i, OUTBOX_MAX_CHARS as l, OfflineError as m, createHub as n, RESUME_MAX_STATE_BYTES as o, HubError as p, hub as r, CLIENT_VERSION_HEADER as s, Hub as t, OUTBOX_MAX_ENTRIES as u };
3363
4028
 
3364
4029
  //# sourceMappingURL=src.js.map