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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"rooms.js","names":[],"sources":["../../src/realtime/rooms-forwarder.ts"],"mappings":";;;;AAUA,IAAM,gBAAN,MAAuC;CAGrC,YAAY,MAAe;EAFV,gBAAA,MAAA,QAAA,KAAA,CAAA;EAGf,KAAK,OAAO;CACd;CAEA,IAAI,OAAiB;EACnB,OAAO,KAAK,KAAK;CACnB;CAEA,IAAI,QAAmB;EACrB,OAAO,KAAK,KAAK;CACnB;CAEA,IAAI,KAAe;EACjB,OAAO,KAAK,KAAK;CACnB;CAEA,QAAoB;EAClB,OAAO,KAAK,KAAK,KAAK;CACxB;CAEA,GAAkC,OAAa,UAA2D;EACxG,OAAO,KAAK,KAAK,GAAG,OAAO,QAAQ;CACrC;CAEA,MAAM,SAAiC;EACrC,OAAO,kBAAkB,KAAK,KAAK,MAAM,OAAO,CAAC;CACnD;CAEA,KAAK,UAA+B;EAClC,OAAO,kBAAkB,KAAK,KAAK,KAAK,QAAQ,CAAC;CACnD;CAEA,QAAuB;EACrB,OAAO,kBAAkB,KAAK,KAAK,MAAM,CAAC;CAC5C;CAEA,KAAK,SAAe,SAA0C;EAC5D,OAAO,kBAAkB,KAAK,KAAK,KAAK,SAAS,OAAO,CAAC;CAC3D;CAEA,SAAS,OAAkB,SAA2C;EACpE,OAAO,kBAAkB,KAAK,KAAK,SAAS,OAAO,OAAO,CAAC;CAC7D;CAEA,KAAK,MAA+B;EAClC,OAAO,kBAAkB,KAAK,KAAK,KAAK,IAAI,CAAC;CAC/C;CAEA,MAAM,UAAiC;EACrC,OAAO,kBAAkB,KAAK,KAAK,MAAM,QAAQ,CAAC;CACpD;CAEA,KAAK,QAA+B;EAClC,OAAO,kBAAkB,KAAK,KAAK,KAAK,MAAM,CAAC;CACjD;CAEA,QAAuB;EACrB,OAAO,kBAAkB,KAAK,KAAK,MAAM,CAAC;CAC5C;CAEA,IAAI,SAA+B;EACjC,OAAO,kBAAkB,KAAK,KAAK,IAAI,OAAO,CAAC;CACjD;AACF;;AAGA,MAAa,iBAA8B;CACzC,SAAS,YAAY,WAAW,KAAK,OAAO,aAAa,IAAI,cAAc,MAAM,SAAS,MAAM,OAAO,OAAO,CAAC,CAAC;CAChH,OAAO,UAAU,YAAY,WAAW,KAAK,OAAO,aAAa,IAAI,cAAc,MAAM,SAAS,MAAM,KAAK,UAAU,OAAO,CAAC,CAAC;CAChI,OAAO,YAAY,WAAW,MAAM,aAAa,SAAS,MAAM,KAAK,OAAO,CAAC;CAC7E,eAAe,WAAW,KAAK,OAAO,aAAa;EACjD,IAAI,SAAS,MAAM,YAAY,KAAA,GAC7B,MAAM,gBAAgB,oBAAoB;EAE5C,MAAM,OAAO,MAAM,SAAS,MAAM,QAAQ;EAC1C,OAAO,SAAS,OAAO,OAAO,IAAI,cAAc,IAAI;CACtD,CAAC;AACH"}
1
+ {"version":3,"file":"rooms.js","names":[],"sources":["../../src/realtime/rooms-forwarder.ts"],"mappings":";;;;AAYA,MAAM,4BAAY,IAAI,IAAa;;AAGnC,SAAgB,cAAuB;CACrC,OAAO,UAAU,OAAO;AAC1B;;AAGA,IAAM,gBAAN,MAAuC;CAGrC,YAAY,MAAe;EAFV,gBAAA,MAAA,QAAA,KAAA,CAAA;EAGf,KAAK,OAAO;EACZ,UAAU,IAAI,IAAI;EAClB,KAAK,GAAG,gBAAgB;GAAE,UAAU,OAAO,IAAI;EAAG,CAAC;CACrD;CAEA,IAAI,OAAiB;EACnB,OAAO,KAAK,KAAK;CACnB;CAEA,IAAI,QAAmB;EACrB,OAAO,KAAK,KAAK;CACnB;CAEA,IAAI,KAAe;EACjB,OAAO,KAAK,KAAK;CACnB;CAEA,QAAoB;EAClB,OAAO,KAAK,KAAK,KAAK;CACxB;CAEA,GAAkC,OAAa,UAA2D;EACxG,OAAO,KAAK,KAAK,GAAG,OAAO,QAAQ;CACrC;CAEA,MAAM,SAAiC;EACrC,OAAO,kBAAkB,KAAK,KAAK,MAAM,OAAO,CAAC;CACnD;CAEA,KAAK,UAA+B;EAClC,OAAO,kBAAkB,KAAK,KAAK,KAAK,QAAQ,CAAC;CACnD;CAEA,QAAuB;EACrB,OAAO,kBAAkB,KAAK,KAAK,MAAM,CAAC;CAC5C;CAEA,KAAK,SAAe,SAA0C;EAC5D,OAAO,kBAAkB,KAAK,KAAK,KAAK,SAAS,OAAO,CAAC;CAC3D;CAEA,SAAS,OAAkB,SAA2C;EACpE,OAAO,kBAAkB,KAAK,KAAK,SAAS,OAAO,OAAO,CAAC;CAC7D;CAEA,KAAK,MAA+B;EAClC,OAAO,kBAAkB,KAAK,KAAK,KAAK,IAAI,CAAC;CAC/C;CAEA,MAAM,UAAiC;EACrC,OAAO,kBAAkB,KAAK,KAAK,MAAM,QAAQ,CAAC;CACpD;CAEA,KAAK,QAA+B;EAClC,OAAO,kBAAkB,KAAK,KAAK,KAAK,MAAM,CAAC;CACjD;CAEA,QAAuB;EACrB,UAAU,OAAO,KAAK,IAAI;EAC1B,OAAO,kBAAkB,KAAK,KAAK,MAAM,CAAC;CAC5C;CAEA,IAAI,SAA+B;EACjC,OAAO,kBAAkB,KAAK,KAAK,IAAI,OAAO,CAAC;CACjD;AACF;;AAGA,MAAa,iBAA8B;CACzC,SAAS,YAAY,WAAW,KAAK,OAAO,aAAa,IAAI,cAAc,MAAM,SAAS,MAAM,OAAO,OAAO,CAAC,CAAC;CAChH,OAAO,UAAU,YAAY,WAAW,KAAK,OAAO,aAAa,IAAI,cAAc,MAAM,SAAS,MAAM,KAAK,UAAU,OAAO,CAAC,CAAC;CAChI,OAAO,YAAY,WAAW,MAAM,aAAa,SAAS,MAAM,KAAK,OAAO,CAAC;CAC7E,eAAe,WAAW,KAAK,OAAO,aAAa;EACjD,IAAI,SAAS,MAAM,YAAY,KAAA,GAC7B,MAAM,gBAAgB,oBAAoB;EAE5C,MAAM,OAAO,MAAM,SAAS,MAAM,QAAQ;EAC1C,OAAO,SAAS,OAAO,OAAO,IAAI,cAAc,IAAI;CACtD,CAAC;AACH"}
@@ -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. */
@@ -618,6 +618,94 @@ function progressKinds(game) {
618
618
  };
619
619
  }
620
620
  //#endregion
621
+ //#region src/core/offline/hub-kinds/resume-kind.ts
622
+ /** The kind name. */
623
+ const RESUME_KIND = "hub.resume";
624
+ function readResume(stored) {
625
+ if (!isRecord(stored)) return;
626
+ if (stored.action === "clear") {
627
+ const before = optionalNumber(stored.before);
628
+ return before === void 0 ? void 0 : {
629
+ action: "clear",
630
+ before
631
+ };
632
+ }
633
+ const savedAt = optionalNumber(stored.savedAt);
634
+ const label = optionalString(stored.label);
635
+ const version = optionalNumber(stored.version);
636
+ const fraction = stored.fraction === null ? null : optionalNumber(stored.fraction);
637
+ 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;
638
+ const base = {
639
+ action: "save",
640
+ savedAt,
641
+ label,
642
+ fraction,
643
+ title: stored.title,
644
+ version
645
+ };
646
+ return stored.state === void 0 ? base : {
647
+ ...base,
648
+ state: stored.state
649
+ };
650
+ }
651
+ /** 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. */
652
+ function runToSend(save, local, slug) {
653
+ const copy = local.read(slug);
654
+ if (copy?.status === "saved" && copy.at >= save.savedAt) return {
655
+ action: "save",
656
+ savedAt: copy.at,
657
+ label: copy.label,
658
+ fraction: copy.fraction,
659
+ title: copy.title,
660
+ version: copy.version,
661
+ state: copy.state
662
+ };
663
+ return save.state === void 0 ? void 0 : save;
664
+ }
665
+ /** Run `call`; on a lapsed session (401) start a guest session and run it once more. */
666
+ async function withSession(context, call) {
667
+ try {
668
+ return await call();
669
+ } catch (failure) {
670
+ if (!(failure instanceof HubError) || failure.status !== 401) throw failure;
671
+ await context.call("ensureGuestSession");
672
+ return call();
673
+ }
674
+ }
675
+ /** The resume kind, by name, over the device copies in `local`. */
676
+ function resumeKinds(game, local) {
677
+ return { [RESUME_KIND]: checkedKind(readResume, {
678
+ mergeKey: () => "resume",
679
+ replaces: true,
680
+ run: async (op, context) => {
681
+ const { slug } = game();
682
+ if (op.action === "clear") {
683
+ await withSession(context, () => context.call("clearResumeSlot", {
684
+ slug,
685
+ before: op.before
686
+ }));
687
+ local.markSynced(slug, op.before);
688
+ return;
689
+ }
690
+ const run = runToSend(op, local, slug);
691
+ if (run?.state === void 0) return;
692
+ const { state } = run;
693
+ await withSession(context, () => context.call("saveResumeSlot", {
694
+ slug,
695
+ state,
696
+ progress: {
697
+ label: run.label,
698
+ fraction: run.fraction
699
+ },
700
+ title: run.title,
701
+ version: run.version,
702
+ savedAt: run.savedAt
703
+ }));
704
+ local.markSynced(slug, run.savedAt);
705
+ }
706
+ }) };
707
+ }
708
+ //#endregion
621
709
  //#region src/core/offline/hub-kinds/legacy-ops.ts
622
710
  /** The outbox operation for a v1 queued write, or `undefined` when it no longer reads as one. */
623
711
  function legacyOperation(op) {
@@ -683,16 +771,13 @@ function legacyOperation(op) {
683
771
  }
684
772
  //#endregion
685
773
  //#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
774
  /** Register every hub kind on `outbox`. */
691
775
  function registerHubKinds(outbox, hooks) {
692
776
  const kinds = {
693
777
  ...progressKinds(hooks.game),
694
778
  ...dailyKinds(hooks.game, hooks.onDailyComplete),
695
- ...economyKinds(hooks.game, writeLocalFavorites)
779
+ ...economyKinds(hooks.game, writeLocalFavorites),
780
+ ...resumeKinds(hooks.game, hooks.resume)
696
781
  };
697
782
  for (const [kind, definition] of Object.entries(kinds)) outbox.define(kind, definition);
698
783
  }
@@ -1576,6 +1661,85 @@ function outboxFull() {
1576
1661
  return new HubError("Too many changes are waiting to sync. Reconnect to save more.", 507, { code: OUTBOX_FULL_CODE });
1577
1662
  }
1578
1663
  //#endregion
1664
+ //#region src/core/resume/local-resume.ts
1665
+ const KEY_PATTERN = /^hub:(.+):resume$/;
1666
+ function keyFor(slug) {
1667
+ return `hub:${slug}:resume`;
1668
+ }
1669
+ /** A stored record read back, or `null` when missing or malformed. */
1670
+ function readRecord(stored) {
1671
+ if (!isRecord(stored)) return null;
1672
+ const at = optionalNumber(stored.at);
1673
+ const userId = stored.userId === null ? null : optionalNumber(stored.userId);
1674
+ const isSynced = stored.isSynced === true;
1675
+ if (at === void 0 || userId === void 0) return null;
1676
+ if (stored.status === "cleared") return {
1677
+ status: "cleared",
1678
+ userId,
1679
+ at,
1680
+ isSynced
1681
+ };
1682
+ const label = optionalString(stored.label);
1683
+ const gameTitle = optionalString(stored.gameTitle);
1684
+ const version = optionalNumber(stored.version);
1685
+ const fraction = stored.fraction === null ? null : optionalNumber(stored.fraction);
1686
+ if (stored.status !== "saved" || label === void 0 || gameTitle === void 0 || version === void 0 || fraction === void 0 || !isNullableString(stored.title) || !isJson(stored.state)) return null;
1687
+ return {
1688
+ status: "saved",
1689
+ userId,
1690
+ at,
1691
+ label,
1692
+ fraction,
1693
+ title: stored.title,
1694
+ version,
1695
+ gameTitle,
1696
+ state: stored.state,
1697
+ isSynced
1698
+ };
1699
+ }
1700
+ /** The device copies of every game's slot. */
1701
+ var LocalResumeStore = class {
1702
+ constructor(store) {
1703
+ _defineProperty(this, "store", void 0);
1704
+ this.store = store;
1705
+ }
1706
+ /** `slug`'s copy, or `null`. */
1707
+ read(slug) {
1708
+ const text = this.store.read(keyFor(slug));
1709
+ return text === null ? null : readRecord(parseJsonText(text));
1710
+ }
1711
+ /** Store `slug`'s copy; answers whether it stuck (a full storage refuses a large state). */
1712
+ write(slug, record) {
1713
+ const text = JSON.stringify(record);
1714
+ this.store.write(keyFor(slug), text);
1715
+ return this.store.read(keyFor(slug)) === text;
1716
+ }
1717
+ /** Forget `slug`'s copy. */
1718
+ remove(slug) {
1719
+ this.store.remove(keyFor(slug));
1720
+ }
1721
+ /** The hub now has `slug`'s save or clear made at `at`: mark it synced (a clear is simply forgotten). */
1722
+ markSynced(slug, at) {
1723
+ const record = this.read(slug);
1724
+ if (record === null || record.at !== at) return;
1725
+ if (record.status === "cleared") this.remove(slug);
1726
+ else this.write(slug, {
1727
+ ...record,
1728
+ isSynced: true
1729
+ });
1730
+ }
1731
+ /** Every game's copy, by slug. */
1732
+ all() {
1733
+ const records = /* @__PURE__ */ new Map();
1734
+ for (const key of this.store.keys()) {
1735
+ const slug = KEY_PATTERN.exec(key)?.[1];
1736
+ const record = slug === void 0 ? null : this.read(slug);
1737
+ if (slug !== void 0 && record !== null) records.set(slug, record);
1738
+ }
1739
+ return records;
1740
+ }
1741
+ };
1742
+ //#endregion
1579
1743
  //#region src/core/page.ts
1580
1744
  /** Whether this code runs in a browser page. */
1581
1745
  function isBrowserPage() {
@@ -1591,6 +1755,13 @@ function injectedConfig() {
1591
1755
  ...apiBase === void 0 ? {} : { apiBase }
1592
1756
  };
1593
1757
  }
1758
+ /** The game's display title: the host's injected one, else the page's `<title>`, else `fallback`. */
1759
+ function detectGameTitle(fallback) {
1760
+ const injected = isBrowserPage() && isRecord(window.__HUB__) ? optionalString(window.__HUB__.title) : void 0;
1761
+ if (injected !== void 0 && injected !== "") return injected;
1762
+ const pageTitle = typeof document === "undefined" ? "" : document.title.trim();
1763
+ return pageTitle === "" ? fallback : pageTitle;
1764
+ }
1594
1765
  /** The game's slug: the injected one, else the first URL path segment, else `null`. */
1595
1766
  function detectSlug() {
1596
1767
  const injectedSlug = injectedConfig().slug;
@@ -2246,6 +2417,30 @@ const ROUTES = {
2246
2417
  path: "/version",
2247
2418
  query: [],
2248
2419
  offline: null
2420
+ },
2421
+ getResumeSlot: {
2422
+ method: "GET",
2423
+ path: "/games/:slug/resume",
2424
+ query: [],
2425
+ offline: null
2426
+ },
2427
+ saveResumeSlot: {
2428
+ method: "PUT",
2429
+ path: "/games/:slug/resume",
2430
+ query: [],
2431
+ offline: "replay"
2432
+ },
2433
+ clearResumeSlot: {
2434
+ method: "DELETE",
2435
+ path: "/games/:slug/resume",
2436
+ query: ["before"],
2437
+ offline: "replay"
2438
+ },
2439
+ listMyResumeSlots: {
2440
+ method: "GET",
2441
+ path: "/me/resume",
2442
+ query: [],
2443
+ offline: null
2249
2444
  }
2250
2445
  };
2251
2446
  /** The request header that carries an offline operation key (the contract's `OFFLINE_OP_HEADER`). */
@@ -2369,6 +2564,7 @@ var HubConnection = class {
2369
2564
  _defineProperty(this, "rest", void 0);
2370
2565
  _defineProperty(this, "store", void 0);
2371
2566
  _defineProperty(this, "outbox", void 0);
2567
+ _defineProperty(this, "resumeStore", void 0);
2372
2568
  _defineProperty(this, "offlineMode", void 0);
2373
2569
  _defineProperty(this, "isReachable", true);
2374
2570
  _defineProperty(this, "runningSync", null);
@@ -2390,6 +2586,7 @@ var HubConnection = class {
2390
2586
  });
2391
2587
  const pageStore = createPageStore();
2392
2588
  this.store = new LocalStore(this.slug ?? "app", pageStore);
2589
+ this.resumeStore = new LocalResumeStore(pageStore);
2393
2590
  this.outbox = new Outbox({
2394
2591
  slug: this.slug ?? "app",
2395
2592
  store: pageStore,
@@ -2414,7 +2611,8 @@ var HubConnection = class {
2414
2611
  rank: completion.rank,
2415
2612
  meta: payload.meta
2416
2613
  });
2417
- }
2614
+ },
2615
+ resume: this.resumeStore
2418
2616
  });
2419
2617
  this.importLegacyQueue();
2420
2618
  if (typeof navigator !== "undefined" && "onLine" in navigator) this.isReachable = navigator.onLine;
@@ -3120,6 +3318,292 @@ const hubMenu = {
3120
3318
  }
3121
3319
  };
3122
3320
  //#endregion
3321
+ //#region src/core/resume/resume-list.ts
3322
+ function fromHub(summary) {
3323
+ return {
3324
+ slug: summary.game,
3325
+ gameTitle: summary.gameTitle,
3326
+ path: summary.path,
3327
+ hasImage: summary.hasImage,
3328
+ progress: summary.progress,
3329
+ title: summary.title,
3330
+ savedAt: summary.updatedAt,
3331
+ isLocalOnly: false
3332
+ };
3333
+ }
3334
+ function fromDevice(slug, run, known) {
3335
+ return {
3336
+ slug,
3337
+ gameTitle: known?.gameTitle ?? run.gameTitle,
3338
+ path: known?.path ?? `/${slug}`,
3339
+ hasImage: known?.hasImage ?? false,
3340
+ progress: {
3341
+ label: run.label,
3342
+ fraction: run.fraction
3343
+ },
3344
+ title: run.title,
3345
+ savedAt: run.at,
3346
+ isLocalOnly: !run.isSynced
3347
+ };
3348
+ }
3349
+ /** One game's entry, from the hub's summary (if any) and the device copy (if the player's). */
3350
+ function mergeOne(slug, hub, copy, isHubKnown) {
3351
+ if (copy === void 0) return hub === void 0 ? null : fromHub(hub);
3352
+ if (copy.status === "cleared") return hub !== void 0 && hub.updatedAt > copy.at ? fromHub(hub) : null;
3353
+ if (hub !== void 0 && hub.updatedAt >= copy.at) return fromHub(hub);
3354
+ return !copy.isSynced || !isHubKnown || hub !== void 0 ? fromDevice(slug, copy, hub) : null;
3355
+ }
3356
+ /**
3357
+ * The runs to show, newest first. `hub` is the hub's list, or `undefined`
3358
+ * when it couldn't be reached; `copies` are the device's, by slug; only
3359
+ * those saved by `userId` count.
3360
+ */
3361
+ function mergeResumeLists(hub, copies, userId) {
3362
+ const hubBySlug = new Map((hub ?? []).map((summary) => [summary.game, summary]));
3363
+ const mine = new Map([...copies].filter(([, copy]) => copy.userId === userId));
3364
+ 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);
3365
+ }
3366
+ //#endregion
3367
+ //#region src/core/resume/resume-request.ts
3368
+ /**
3369
+ * Whether this page was opened from the home page's Resume card
3370
+ * (`/<slug>/?hub_resume=1`). The first client copy on the page to look takes
3371
+ * the parameter out of the address bar (so a reload or a shared link doesn't
3372
+ * resume again) and records the answer on `window`, so every other copy on
3373
+ * the page (a game's bundled client and the host's `/_hub/hub.js`) answers
3374
+ * the same, however late it asks.
3375
+ */
3376
+ /** The query parameter (the contract's `RESUME_REQUEST_PARAM`; the browser client carries no contract code). */
3377
+ const RESUME_REQUEST_PARAM = "hub_resume";
3378
+ /** Where the answer is kept for the page. */
3379
+ const PAGE_FLAG = "__HUB_RESUME_REQUESTED__";
3380
+ /** Take `hub_resume` out of the address bar, keeping the rest of the URL and the history state. */
3381
+ function stripParam(url) {
3382
+ url.searchParams.delete(RESUME_REQUEST_PARAM);
3383
+ try {
3384
+ history.replaceState(history.state, "", `${url.pathname}${url.search}${url.hash}`);
3385
+ } catch {}
3386
+ }
3387
+ /** Whether the address has `hub_resume`; it is taken out when it does. */
3388
+ function takeParam() {
3389
+ try {
3390
+ const url = new URL(location.href);
3391
+ if (!url.searchParams.has("hub_resume")) return false;
3392
+ stripParam(url);
3393
+ return true;
3394
+ } catch {
3395
+ return false;
3396
+ }
3397
+ }
3398
+ /** `true` when the page was opened to resume (synchronous, so a game can decide before it routes or draws). */
3399
+ function isResumeRequested() {
3400
+ if (!isBrowserPage()) return false;
3401
+ if (Reflect.has(window, PAGE_FLAG)) return Reflect.get(window, PAGE_FLAG) === true;
3402
+ const isRequested = takeParam();
3403
+ Reflect.set(window, PAGE_FLAG, isRequested);
3404
+ return isRequested;
3405
+ }
3406
+ //#endregion
3407
+ //#region src/core/resume/hub-resume.ts
3408
+ /** How long saves must be quiet before the latest is sent, ms. */
3409
+ const RESUME_DEBOUNCE_MS = 3e3;
3410
+ /** Largest state, bytes of JSON (the contract's `RESUME_MAX_STATE_BYTES`). */
3411
+ const RESUME_MAX_STATE_BYTES = 524288;
3412
+ /** What a game should do when a save is ignored, by blocker (logged once per page). */
3413
+ const BLOCKED_HINTS = {
3414
+ daily: "a daily challenge is active. When the player leaves a daily without finishing it, call hub.daily.cancel().",
3415
+ race: "a race is on: races are never resumed.",
3416
+ room: "the page is in a hub room: online games are never resumed."
3417
+ };
3418
+ /** Longest label or title kept (the contract's `RESUME_TEXT_MAX`). */
3419
+ const TEXT_MAX = 60;
3420
+ /** A label or title trimmed and clipped, or `null` when blank. */
3421
+ function shortText(text) {
3422
+ const trimmed = (text ?? "").trim();
3423
+ return trimmed === "" ? null : trimmed.slice(0, TEXT_MAX);
3424
+ }
3425
+ /** The UTF-8 size of `text`. */
3426
+ function byteSize(text) {
3427
+ return new TextEncoder().encode(text).length;
3428
+ }
3429
+ /** The device copy as a run read back. */
3430
+ function runFromCopy(copy) {
3431
+ return {
3432
+ state: toGameData(copy.state),
3433
+ progress: {
3434
+ label: copy.label,
3435
+ fraction: copy.fraction
3436
+ },
3437
+ title: copy.title,
3438
+ version: copy.version,
3439
+ savedAt: copy.at
3440
+ };
3441
+ }
3442
+ /** The hub's slot as a run read back. */
3443
+ function runFromSlot(slot) {
3444
+ return {
3445
+ state: toGameData(slot.state),
3446
+ progress: slot.progress,
3447
+ title: slot.title,
3448
+ version: slot.version,
3449
+ savedAt: slot.updatedAt
3450
+ };
3451
+ }
3452
+ /** `hub.resume`. */
3453
+ var HubResume = class {
3454
+ constructor(host) {
3455
+ _defineProperty(this, "host", void 0);
3456
+ _defineProperty(this, "timer", null);
3457
+ _defineProperty(this, "waiting", null);
3458
+ _defineProperty(this, "isListening", false);
3459
+ _defineProperty(this, "warnedBlockers", /* @__PURE__ */ new Set());
3460
+ this.host = host;
3461
+ }
3462
+ /**
3463
+ * `true` when the page was opened from the Resume card (`?hub_resume=1`,
3464
+ * taken out of the address bar). Synchronous, so a game can restore before
3465
+ * it routes or draws its title screen.
3466
+ */
3467
+ get requested() {
3468
+ return isResumeRequested();
3469
+ }
3470
+ /** Why a save would be ignored right now (`race`, `daily`, `room`), or `null`. */
3471
+ get blockedBy() {
3472
+ return this.host.blocker();
3473
+ }
3474
+ /**
3475
+ * Save the run (the latest save wins). Stored on the device at once; sent
3476
+ * after {@link RESUME_DEBOUNCE_MS} of quiet, or when the page is hidden.
3477
+ * Returns `false` (and saves nothing) in a race, a daily challenge or an
3478
+ * online room. Throws a `HubError` 413 over 512 KB, 400 without a label.
3479
+ */
3480
+ save(run) {
3481
+ const blocker = this.host.blocker();
3482
+ if (blocker !== null) {
3483
+ this.warnBlocked(blocker);
3484
+ return false;
3485
+ }
3486
+ const slug = this.requireSlug();
3487
+ const label = shortText(run.progress.label);
3488
+ if (label === null) throw new HubError("hub.resume.save: progress.label is required.", 400);
3489
+ const state = toStoredJson(run.state);
3490
+ if (byteSize(JSON.stringify(state)) > 524288) throw new HubError(`hub.resume.save: state exceeds ${String(RESUME_MAX_STATE_BYTES)} bytes.`, 413);
3491
+ const fraction = typeof run.progress.fraction === "number" && Number.isFinite(run.progress.fraction) ? Math.min(1, Math.max(0, run.progress.fraction)) : null;
3492
+ const copy = {
3493
+ status: "saved",
3494
+ userId: this.host.currentUserId(),
3495
+ at: Date.now(),
3496
+ label,
3497
+ fraction,
3498
+ title: shortText(run.title),
3499
+ version: Math.max(0, Math.floor(run.version ?? 0)),
3500
+ gameTitle: this.host.gameTitle(),
3501
+ state,
3502
+ isSynced: false
3503
+ };
3504
+ const isStored = this.host.local.write(slug, copy);
3505
+ this.schedule({
3506
+ action: "save",
3507
+ savedAt: copy.at,
3508
+ label,
3509
+ fraction,
3510
+ title: copy.title,
3511
+ version: copy.version,
3512
+ ...isStored ? {} : { state }
3513
+ });
3514
+ return true;
3515
+ }
3516
+ /** Send a waiting save now (the page does this itself when hidden or closed). */
3517
+ async flush() {
3518
+ if (this.timer !== null) {
3519
+ clearTimeout(this.timer);
3520
+ this.timer = null;
3521
+ }
3522
+ const payload = this.waiting;
3523
+ this.waiting = null;
3524
+ if (payload !== null) await this.host.send(payload);
3525
+ }
3526
+ /** Clear the run: call it when the run ends (won, lost or quit). Works offline. */
3527
+ async clear() {
3528
+ const slug = this.requireSlug();
3529
+ if (this.timer !== null) {
3530
+ clearTimeout(this.timer);
3531
+ this.timer = null;
3532
+ }
3533
+ this.waiting = null;
3534
+ const at = Date.now();
3535
+ this.host.local.write(slug, {
3536
+ status: "cleared",
3537
+ userId: this.host.currentUserId(),
3538
+ at,
3539
+ isSynced: false
3540
+ });
3541
+ await this.host.send({
3542
+ action: "clear",
3543
+ before: at
3544
+ });
3545
+ }
3546
+ /** The saved run with its progress and time, or `null`. The newer of the device's copy and the hub's wins. */
3547
+ async get(options = {}) {
3548
+ const slug = this.requireSlug();
3549
+ const copy = this.host.local.read(slug);
3550
+ const mine = copy !== null && copy.userId === this.host.currentUserId() ? copy : null;
3551
+ const slot = await this.host.fetchSlot();
3552
+ let run;
3553
+ if (slot !== void 0 && slot !== null && (mine === null || slot.updatedAt >= mine.at)) run = runFromSlot(slot);
3554
+ else {
3555
+ const isClearedElsewhere = slot === null && mine?.isSynced === true;
3556
+ run = mine?.status === "saved" && !isClearedElsewhere ? runFromCopy(mine) : null;
3557
+ }
3558
+ if (run === null || options.version === void 0 || run.version === options.version) return run;
3559
+ if (run.version < options.version) await this.clear();
3560
+ return null;
3561
+ }
3562
+ /** The saved state, or `null` (see {@link get} for the version rule). */
3563
+ async load(options = {}) {
3564
+ return (await this.get(options))?.state ?? null;
3565
+ }
3566
+ /** The player's saved runs in every resumable game, newest first (the home page's Resume card). */
3567
+ async list() {
3568
+ return mergeResumeLists(await this.host.fetchList(), this.host.local.all(), this.host.currentUserId());
3569
+ }
3570
+ /** Discard another game's run from the home page (online only: throws a `HubError` offline). */
3571
+ async discard(slug) {
3572
+ await this.host.clearOther(slug);
3573
+ this.host.local.remove(slug);
3574
+ }
3575
+ /** Say once per blocker why saves are ignored, so a game that forgot `hub.daily.cancel()` finds out. */
3576
+ warnBlocked(blocker) {
3577
+ if (this.warnedBlockers.has(blocker)) return;
3578
+ this.warnedBlockers.add(blocker);
3579
+ console.warn(`[hub] hub.resume.save ignored: ${BLOCKED_HINTS[blocker]}`);
3580
+ }
3581
+ requireSlug() {
3582
+ if (this.host.slug === null) throw new HubError("hub.resume: could not determine the game slug.");
3583
+ return this.host.slug;
3584
+ }
3585
+ schedule(payload) {
3586
+ this.waiting = payload;
3587
+ if (this.timer !== null) clearTimeout(this.timer);
3588
+ this.timer = setTimeout(() => {
3589
+ this.flush().catch(() => void 0);
3590
+ }, RESUME_DEBOUNCE_MS);
3591
+ this.listenToPage();
3592
+ }
3593
+ /** Send a waiting save as soon as the page is hidden or closed. */
3594
+ listenToPage() {
3595
+ if (this.isListening || !isBrowserPage()) return;
3596
+ this.isListening = true;
3597
+ const flushNow = () => {
3598
+ this.flush().catch(() => void 0);
3599
+ };
3600
+ window.addEventListener("pagehide", flushNow);
3601
+ if (typeof document !== "undefined") document.addEventListener("visibilitychange", () => {
3602
+ if (document.visibilityState === "hidden") flushNow();
3603
+ });
3604
+ }
3605
+ };
3606
+ //#endregion
3123
3607
  //#region src/core/ticket.ts
3124
3608
  /**
3125
3609
  * The own-server ticket request (`hub.mp.ticket()`): a 60-second identity
@@ -3261,6 +3745,7 @@ var Hub = class extends HubEconomy {
3261
3745
  constructor(..._args) {
3262
3746
  super(..._args);
3263
3747
  _defineProperty(this, "clientVersion", HUB_CLIENT_VERSION);
3748
+ _defineProperty(this, "resumeApi", void 0);
3264
3749
  }
3265
3750
  /**
3266
3751
  * The hub's version and the client versions it supports (`GET /_api/version`),
@@ -3326,6 +3811,43 @@ var Hub = class extends HubEconomy {
3326
3811
  get race() {
3327
3812
  return raceForwarder;
3328
3813
  }
3814
+ /**
3815
+ * Resume a saved game: `hub.resume.save({ state, progress })` during a long
3816
+ * single-player run, `clear()` when it ends, `load()` and `requested` to
3817
+ * pick it up again (docs: "Resume saved games"). Needs `game.resumable: true`.
3818
+ */
3819
+ get resume() {
3820
+ this.resumeApi ?? (this.resumeApi = new HubResume(this.resumeHost()));
3821
+ return this.resumeApi;
3822
+ }
3823
+ /** What `hub.resume` needs from this client. */
3824
+ resumeHost() {
3825
+ return {
3826
+ slug: this.slug,
3827
+ local: this.resumeStore,
3828
+ currentUserId: () => this.store.getUser()?.id ?? null,
3829
+ gameTitle: () => detectGameTitle(this.slug ?? ""),
3830
+ blocker: () => {
3831
+ if (raceForwarder.current() !== null) return "race";
3832
+ return this.daily.active === null ? isInHubRoom() ? "room" : null : "daily";
3833
+ },
3834
+ send: async (payload) => {
3835
+ await this.mutateOffline(RESUME_KIND, payload, () => void 0);
3836
+ },
3837
+ fetchSlot: () => this.withFallback(async () => {
3838
+ try {
3839
+ return (await this.rest.call("getResumeSlot", this.gameFields())).resume;
3840
+ } catch (failure) {
3841
+ if (failure instanceof HubError && failure.status === 401) return null;
3842
+ throw failure;
3843
+ }
3844
+ }, () => void 0),
3845
+ fetchList: () => this.withFallback(async () => (await this.rest.call("listMyResumeSlots")).games, () => void 0),
3846
+ clearOther: async (slug) => {
3847
+ await this.rest.call("clearResumeSlot", { slug });
3848
+ }
3849
+ };
3850
+ }
3329
3851
  /** What the daily system needs from this client: its REST calls and window events. */
3330
3852
  dailyHost() {
3331
3853
  return {
@@ -3354,11 +3876,12 @@ linkRealtimeToHub({
3354
3876
  });
3355
3877
  /** A ready-to-use client, with the slug and API base detected from the page. */
3356
3878
  const hub = new Hub();
3879
+ isResumeRequested();
3357
3880
  /** Create a client for another slug or API base, or with other offline behaviour. */
3358
3881
  function createHub(options = {}) {
3359
3882
  return new Hub(options);
3360
3883
  }
3361
3884
  //#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 };
3885
+ export { RESUME_DEBOUNCE_MS as a, OUTBOX_FULL_CODE as c, OfflineApi as d, ContentRejectedError as f, UNSUPPORTED_BY_HUB 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
3886
 
3364
3887
  //# sourceMappingURL=src.js.map