@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.
- package/CHANGELOG.md +11 -0
- package/dist/auto.d.ts +2 -2
- package/dist/auto.js +2 -2
- package/dist/doctor/cli.mjs +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/rooms.js +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.d.ts +13 -4
- package/dist/shared/daily.d.ts.map +1 -1
- package/dist/shared/daily.js +15 -6
- package/dist/shared/daily.js.map +1 -1
- package/dist/shared/index.d.ts +203 -3
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/rooms.d.ts.map +1 -1
- package/dist/shared/rooms.js +12 -1
- package/dist/shared/rooms.js.map +1 -1
- package/dist/shared/src.js +531 -8
- package/dist/shared/src.js.map +1 -1
- package/package.json +2 -2
- package/src/core/connection.ts +5 -0
- package/src/core/hub.ts +55 -4
- package/src/core/offline/hub-kinds/index.ts +6 -0
- package/src/core/offline/hub-kinds/resume-kind.ts +108 -0
- package/src/core/page.ts +10 -0
- package/src/core/rest/routes.gen.ts +4 -0
- package/src/core/resume/hub-resume.ts +252 -0
- package/src/core/resume/local-resume.ts +130 -0
- package/src/core/resume/resume-list.ts +65 -0
- package/src/core/resume/resume-request.ts +52 -0
- package/src/core/resume/resume-types.ts +64 -0
- package/src/core/version/client-version.gen.ts +1 -1
- package/src/daily/daily-system.ts +14 -5
- package/src/index.ts +3 -1
- package/src/realtime/rooms-forwarder.ts +14 -1
package/dist/shared/rooms.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rooms.js","names":[],"sources":["../../src/realtime/rooms-forwarder.ts"],"mappings":";;;;
|
|
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"}
|
package/dist/shared/src.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|