@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.
- package/CHANGELOG.md +19 -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 +237 -6
- 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 +696 -31
- package/dist/shared/src.js.map +1 -1
- package/package.json +2 -2
- package/src/core/connection.ts +6 -0
- package/src/core/errors.ts +8 -1
- package/src/core/hub-progress.ts +44 -8
- package/src/core/hub.ts +55 -4
- package/src/core/offline/cloud-saves.ts +62 -0
- package/src/core/offline/hub-kinds/index.ts +11 -3
- package/src/core/offline/hub-kinds/progress-kinds.ts +21 -4
- package/src/core/offline/hub-kinds/resume-kind.ts +108 -0
- package/src/core/offline/local-store.ts +46 -1
- 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 +4 -1
- package/src/realtime/rooms-forwarder.ts +14 -1
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. */
|
|
@@ -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
|
-
/**
|
|
559
|
-
|
|
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)
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
/**
|
|
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(
|
|
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
|
-
/**
|
|
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 () =>
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
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 {
|
|
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
|