@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.
@@ -0,0 +1,252 @@
1
+ /**
2
+ * `hub.resume`: auto-save a long single-player run so the player can pick it
3
+ * up again from the home page's Resume card, on any device. One slot per
4
+ * player per game; the latest save wins.
5
+ *
6
+ * - `save(run)` stores the run on the device at once and sends it to the hub
7
+ * after a short quiet spell (debounced), straight away when the page is
8
+ * hidden or closed, and through the offline outbox (`hub.resume`), so it
9
+ * works offline and lands once.
10
+ * - `clear()` when the run ends (won, lost or quit).
11
+ * - `load()` / `get()` read it back; `requested` says the page was opened to
12
+ * resume, so the game restores without asking.
13
+ *
14
+ * Saves are ignored while the page is in a race, a daily challenge or an
15
+ * online room: those runs are never resumed.
16
+ */
17
+ import type { ResumeSlot, ResumeSummary } from '@diffenderfer-games/hub-contract';
18
+ import { HubError } from '../errors';
19
+ import { toGameData, toStoredJson, type GamePayload } from '../game-data';
20
+ import type { ResumePayload } from '../offline/hub-kinds';
21
+ import { isBrowserPage } from '../page';
22
+ import type { LocalResumeStore, LocalRun } from './local-resume';
23
+ import { mergeResumeLists } from './resume-list';
24
+ import { isResumeRequested } from './resume-request';
25
+ import type { ResumeBlocker, ResumeEntry, ResumeLoadOptions, ResumeRun, ResumeSaveInput } from './resume-types';
26
+
27
+ /** How long saves must be quiet before the latest is sent, ms. */
28
+ export const RESUME_DEBOUNCE_MS = 3000;
29
+
30
+ /** Largest state, bytes of JSON (the contract's `RESUME_MAX_STATE_BYTES`). */
31
+ export const RESUME_MAX_STATE_BYTES = 512 * 1024;
32
+
33
+ /** What a game should do when a save is ignored, by blocker (logged once per page). */
34
+ const BLOCKED_HINTS: Readonly<Record<ResumeBlocker, string>> = {
35
+ daily: 'a daily challenge is active. When the player leaves a daily without finishing it, call hub.daily.cancel().',
36
+ race: 'a race is on: races are never resumed.',
37
+ room: 'the page is in a hub room: online games are never resumed.',
38
+ };
39
+
40
+ /** Longest label or title kept (the contract's `RESUME_TEXT_MAX`). */
41
+ const TEXT_MAX = 60;
42
+
43
+ /** What `hub.resume` needs from its client. */
44
+ export interface ResumeHost {
45
+ /** This game's slug; `null` on the home page. */
46
+ readonly slug: string | null;
47
+ readonly local: LocalResumeStore;
48
+ /** The signed-in player's id as the device knows it, or `null`. */
49
+ currentUserId(): number | null;
50
+ /** The game's display title. */
51
+ gameTitle(): string;
52
+ /** Why saving is off right now, or `null`. */
53
+ blocker(): ResumeBlocker | null;
54
+ /** Perform a resume op: at once when online with nothing waiting, else queued. */
55
+ send(payload: ResumePayload): Promise<void>;
56
+ /** This game's run on the hub, or `undefined` when the hub can't be reached. */
57
+ fetchSlot(): Promise<ResumeSlot | null | undefined>;
58
+ /** The player's runs on the hub, or `undefined` when it can't be reached. */
59
+ fetchList(): Promise<ResumeSummary[] | undefined>;
60
+ /** Clear another game's run on the hub (the home page's discard); throws offline. */
61
+ clearOther(slug: string): Promise<void>;
62
+ }
63
+
64
+ /** A label or title trimmed and clipped, or `null` when blank. */
65
+ function shortText(text: string | null | undefined): string | null {
66
+ const trimmed = (text ?? '').trim();
67
+ return trimmed === '' ? null : trimmed.slice(0, TEXT_MAX);
68
+ }
69
+
70
+ /** The UTF-8 size of `text`. */
71
+ function byteSize(text: string): number {
72
+ return new TextEncoder().encode(text).length;
73
+ }
74
+
75
+ /** The device copy as a run read back. */
76
+ function runFromCopy<TState>(copy: LocalRun): ResumeRun<TState> {
77
+ return { state: toGameData<TState>(copy.state), progress: { label: copy.label, fraction: copy.fraction }, title: copy.title, version: copy.version, savedAt: copy.at };
78
+ }
79
+
80
+ /** The hub's slot as a run read back. */
81
+ function runFromSlot<TState>(slot: ResumeSlot): ResumeRun<TState> {
82
+ return { state: toGameData<TState>(slot.state), progress: slot.progress, title: slot.title, version: slot.version, savedAt: slot.updatedAt };
83
+ }
84
+
85
+ /** `hub.resume`. */
86
+ export class HubResume {
87
+ private readonly host: ResumeHost;
88
+ private timer: ReturnType<typeof setTimeout> | null = null;
89
+ private waiting: ResumePayload | null = null;
90
+ private isListening = false;
91
+ private readonly warnedBlockers = new Set<ResumeBlocker>();
92
+
93
+ constructor(host: ResumeHost) {
94
+ this.host = host;
95
+ }
96
+
97
+ /**
98
+ * `true` when the page was opened from the Resume card (`?hub_resume=1`,
99
+ * taken out of the address bar). Synchronous, so a game can restore before
100
+ * it routes or draws its title screen.
101
+ */
102
+ get requested(): boolean {
103
+ return isResumeRequested();
104
+ }
105
+
106
+ /** Why a save would be ignored right now (`race`, `daily`, `room`), or `null`. */
107
+ get blockedBy(): ResumeBlocker | null {
108
+ return this.host.blocker();
109
+ }
110
+
111
+ /**
112
+ * Save the run (the latest save wins). Stored on the device at once; sent
113
+ * after {@link RESUME_DEBOUNCE_MS} of quiet, or when the page is hidden.
114
+ * Returns `false` (and saves nothing) in a race, a daily challenge or an
115
+ * online room. Throws a `HubError` 413 over 512 KB, 400 without a label.
116
+ */
117
+ save<TState extends GamePayload>(run: ResumeSaveInput<TState>): boolean {
118
+ const blocker = this.host.blocker();
119
+ if (blocker !== null) {
120
+ this.warnBlocked(blocker);
121
+ return false;
122
+ }
123
+ const slug = this.requireSlug();
124
+ const label = shortText(run.progress.label);
125
+ if (label === null) {
126
+ throw new HubError('hub.resume.save: progress.label is required.', 400);
127
+ }
128
+ const state = toStoredJson(run.state);
129
+ if (byteSize(JSON.stringify(state)) > RESUME_MAX_STATE_BYTES) {
130
+ throw new HubError(`hub.resume.save: state exceeds ${String(RESUME_MAX_STATE_BYTES)} bytes.`, 413);
131
+ }
132
+ const fraction = typeof run.progress.fraction === 'number' && Number.isFinite(run.progress.fraction) ? Math.min(1, Math.max(0, run.progress.fraction)) : null;
133
+ const copy: LocalRun = {
134
+ status: 'saved', userId: this.host.currentUserId(), at: Date.now(), label, fraction, title: shortText(run.title),
135
+ version: Math.max(0, Math.floor(run.version ?? 0)), gameTitle: this.host.gameTitle(), state, isSynced: false,
136
+ };
137
+ const isStored = this.host.local.write(slug, copy);
138
+ this.schedule({
139
+ action: 'save', savedAt: copy.at, label, fraction, title: copy.title, version: copy.version,
140
+ ...(isStored ? {} : { state }),
141
+ });
142
+ return true;
143
+ }
144
+
145
+ /** Send a waiting save now (the page does this itself when hidden or closed). */
146
+ async flush(): Promise<void> {
147
+ if (this.timer !== null) {
148
+ clearTimeout(this.timer);
149
+ this.timer = null;
150
+ }
151
+ const payload = this.waiting;
152
+ this.waiting = null;
153
+ if (payload !== null) {
154
+ await this.host.send(payload);
155
+ }
156
+ }
157
+
158
+ /** Clear the run: call it when the run ends (won, lost or quit). Works offline. */
159
+ async clear(): Promise<void> {
160
+ const slug = this.requireSlug();
161
+ if (this.timer !== null) {
162
+ clearTimeout(this.timer);
163
+ this.timer = null;
164
+ }
165
+ this.waiting = null;
166
+ const at = Date.now();
167
+ this.host.local.write(slug, { status: 'cleared', userId: this.host.currentUserId(), at, isSynced: false });
168
+ await this.host.send({ action: 'clear', before: at });
169
+ }
170
+
171
+ /** The saved run with its progress and time, or `null`. The newer of the device's copy and the hub's wins. */
172
+ async get<TState = GamePayload>(options: ResumeLoadOptions = {}): Promise<ResumeRun<TState> | null> {
173
+ const slug = this.requireSlug();
174
+ const copy = this.host.local.read(slug);
175
+ const mine = copy !== null && copy.userId === this.host.currentUserId() ? copy : null;
176
+ const slot = await this.host.fetchSlot();
177
+ let run: ResumeRun<TState> | null;
178
+ if (slot !== undefined && slot !== null && (mine === null || slot.updatedAt >= mine.at)) {
179
+ run = runFromSlot<TState>(slot);
180
+ } else {
181
+ // The device's run, unless the hub was asked, has none, and already had this one (it was cleared elsewhere).
182
+ const isClearedElsewhere = slot === null && mine?.isSynced === true;
183
+ run = mine?.status === 'saved' && !isClearedElsewhere ? runFromCopy<TState>(mine) : null;
184
+ }
185
+ if (run === null || options.version === undefined || run.version === options.version) {
186
+ return run;
187
+ }
188
+ if (run.version < options.version) {
189
+ await this.clear();
190
+ }
191
+ return null;
192
+ }
193
+
194
+ /** The saved state, or `null` (see {@link get} for the version rule). */
195
+ async load<TState = GamePayload>(options: ResumeLoadOptions = {}): Promise<TState | null> {
196
+ return (await this.get<TState>(options))?.state ?? null;
197
+ }
198
+
199
+ /** The player's saved runs in every resumable game, newest first (the home page's Resume card). */
200
+ async list(): Promise<ResumeEntry[]> {
201
+ return mergeResumeLists(await this.host.fetchList(), this.host.local.all(), this.host.currentUserId());
202
+ }
203
+
204
+ /** Discard another game's run from the home page (online only: throws a `HubError` offline). */
205
+ async discard(slug: string): Promise<void> {
206
+ await this.host.clearOther(slug);
207
+ this.host.local.remove(slug);
208
+ }
209
+
210
+ /** Say once per blocker why saves are ignored, so a game that forgot `hub.daily.cancel()` finds out. */
211
+ private warnBlocked(blocker: ResumeBlocker): void {
212
+ if (this.warnedBlockers.has(blocker)) {
213
+ return;
214
+ }
215
+ this.warnedBlockers.add(blocker);
216
+ // eslint-disable-next-line no-console -- a one-time hint to the game developer, with the fix
217
+ console.warn(`[hub] hub.resume.save ignored: ${BLOCKED_HINTS[blocker]}`);
218
+ }
219
+
220
+ private requireSlug(): string {
221
+ if (this.host.slug === null) {
222
+ throw new HubError('hub.resume: could not determine the game slug.');
223
+ }
224
+ return this.host.slug;
225
+ }
226
+
227
+ private schedule(payload: ResumePayload): void {
228
+ this.waiting = payload;
229
+ if (this.timer !== null) {
230
+ clearTimeout(this.timer);
231
+ }
232
+ this.timer = setTimeout(() => { void this.flush().catch(() => undefined); }, RESUME_DEBOUNCE_MS);
233
+ this.listenToPage();
234
+ }
235
+
236
+ /** Send a waiting save as soon as the page is hidden or closed. */
237
+ private listenToPage(): void {
238
+ if (this.isListening || !isBrowserPage()) {
239
+ return;
240
+ }
241
+ this.isListening = true;
242
+ const flushNow = (): void => { void this.flush().catch(() => undefined); };
243
+ window.addEventListener('pagehide', flushNow);
244
+ if (typeof document !== 'undefined') {
245
+ document.addEventListener('visibilitychange', () => {
246
+ if (document.visibilityState === 'hidden') {
247
+ flushNow();
248
+ }
249
+ });
250
+ }
251
+ }
252
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The device's copy of each game's resume slot (`hub:<slug>:resume`), in the
3
+ * browser storage every game on the site shares. It is written the moment a
4
+ * game saves, before the debounced send, so:
5
+ * - `hub.resume.load()` works offline and right after a reload;
6
+ * - the queued send reads the newest state from here, so the outbox entry
7
+ * stays small (it carries the state itself only when this copy couldn't
8
+ * be stored);
9
+ * - the home page's Resume card can show a run the hub hasn't heard of yet
10
+ * (saved offline, or the tab closed before the send), and hide one cleared
11
+ * offline.
12
+ *
13
+ * A record belongs to the player who saved it (`userId`), so another account
14
+ * on the same device never sees it. `isSynced` turns true once the hub has it.
15
+ */
16
+ import type { Json } from '@diffenderfer-games/hub-contract';
17
+ import { isJson, isNullableString, isRecord, optionalNumber, optionalString, parseJsonText } from '../storage/json-guards';
18
+ import type { KeyValueStore } from '../storage/key-value';
19
+
20
+ /** A run as this device saved it. */
21
+ export interface LocalRun {
22
+ readonly status: 'saved';
23
+ readonly userId: number | null;
24
+ /** When the game saved it, epoch ms. */
25
+ readonly at: number;
26
+ readonly label: string;
27
+ readonly fraction: number | null;
28
+ readonly title: string | null;
29
+ readonly version: number;
30
+ /** The game's title, for the Resume card before the hub knows the run. */
31
+ readonly gameTitle: string;
32
+ readonly state: Json;
33
+ readonly isSynced: boolean;
34
+ }
35
+
36
+ /** A run this device cleared (kept until the hub has the clear). */
37
+ export interface LocalClear {
38
+ readonly status: 'cleared';
39
+ readonly userId: number | null;
40
+ readonly at: number;
41
+ readonly isSynced: boolean;
42
+ }
43
+
44
+ /** A device copy of one game's slot. */
45
+ export type LocalResume = LocalRun | LocalClear;
46
+
47
+ const KEY_PATTERN = /^hub:(.+):resume$/;
48
+
49
+ function keyFor(slug: string): string {
50
+ return `hub:${slug}:resume`;
51
+ }
52
+
53
+ /** A stored record read back, or `null` when missing or malformed. */
54
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: a record read back from localStorage
55
+ function readRecord(stored: unknown): LocalResume | null {
56
+ if (!isRecord(stored)) {
57
+ return null;
58
+ }
59
+ const at = optionalNumber(stored.at);
60
+ const userId = stored.userId === null ? null : optionalNumber(stored.userId);
61
+ const isSynced = stored.isSynced === true;
62
+ if (at === undefined || userId === undefined) {
63
+ return null;
64
+ }
65
+ if (stored.status === 'cleared') {
66
+ return { status: 'cleared', userId, at, isSynced };
67
+ }
68
+ const label = optionalString(stored.label);
69
+ const gameTitle = optionalString(stored.gameTitle);
70
+ const version = optionalNumber(stored.version);
71
+ const fraction = stored.fraction === null ? null : optionalNumber(stored.fraction);
72
+ if (stored.status !== 'saved' || label === undefined || gameTitle === undefined || version === undefined
73
+ || fraction === undefined || !isNullableString(stored.title) || !isJson(stored.state)) {
74
+ return null;
75
+ }
76
+ return { status: 'saved', userId, at, label, fraction, title: stored.title, version, gameTitle, state: stored.state, isSynced };
77
+ }
78
+
79
+ /** The device copies of every game's slot. */
80
+ export class LocalResumeStore {
81
+ private readonly store: KeyValueStore;
82
+
83
+ constructor(store: KeyValueStore) {
84
+ this.store = store;
85
+ }
86
+
87
+ /** `slug`'s copy, or `null`. */
88
+ read(slug: string): LocalResume | null {
89
+ const text = this.store.read(keyFor(slug));
90
+ return text === null ? null : readRecord(parseJsonText(text));
91
+ }
92
+
93
+ /** Store `slug`'s copy; answers whether it stuck (a full storage refuses a large state). */
94
+ write(slug: string, record: LocalResume): boolean {
95
+ const text = JSON.stringify(record);
96
+ this.store.write(keyFor(slug), text);
97
+ return this.store.read(keyFor(slug)) === text;
98
+ }
99
+
100
+ /** Forget `slug`'s copy. */
101
+ remove(slug: string): void {
102
+ this.store.remove(keyFor(slug));
103
+ }
104
+
105
+ /** The hub now has `slug`'s save or clear made at `at`: mark it synced (a clear is simply forgotten). */
106
+ markSynced(slug: string, at: number): void {
107
+ const record = this.read(slug);
108
+ if (record === null || record.at !== at) {
109
+ return;
110
+ }
111
+ if (record.status === 'cleared') {
112
+ this.remove(slug);
113
+ } else {
114
+ this.write(slug, { ...record, isSynced: true });
115
+ }
116
+ }
117
+
118
+ /** Every game's copy, by slug. */
119
+ all(): Map<string, LocalResume> {
120
+ const records = new Map<string, LocalResume>();
121
+ for (const key of this.store.keys()) {
122
+ const slug = KEY_PATTERN.exec(key)?.[1];
123
+ const record = slug === undefined ? null : this.read(slug);
124
+ if (slug !== undefined && record !== null) {
125
+ records.set(slug, record);
126
+ }
127
+ }
128
+ return records;
129
+ }
130
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The player's saved runs: the hub's list merged with this device's copies,
3
+ * so a run saved offline (or just before the tab closed) shows at once and a
4
+ * run cleared offline disappears at once. Pure.
5
+ */
6
+ import type { ResumeSummary } from '@diffenderfer-games/hub-contract';
7
+ import type { LocalResume, LocalRun } from './local-resume';
8
+ import type { ResumeEntry } from './resume-types';
9
+
10
+ function fromHub(summary: ResumeSummary): ResumeEntry {
11
+ return {
12
+ slug: summary.game,
13
+ gameTitle: summary.gameTitle,
14
+ path: summary.path,
15
+ hasImage: summary.hasImage,
16
+ progress: summary.progress,
17
+ title: summary.title,
18
+ savedAt: summary.updatedAt,
19
+ isLocalOnly: false,
20
+ };
21
+ }
22
+
23
+ function fromDevice(slug: string, run: LocalRun, known: ResumeSummary | undefined): ResumeEntry {
24
+ return {
25
+ slug,
26
+ gameTitle: known?.gameTitle ?? run.gameTitle,
27
+ path: known?.path ?? `/${slug}`,
28
+ hasImage: known?.hasImage ?? false,
29
+ progress: { label: run.label, fraction: run.fraction },
30
+ title: run.title,
31
+ savedAt: run.at,
32
+ isLocalOnly: !run.isSynced,
33
+ };
34
+ }
35
+
36
+ /** One game's entry, from the hub's summary (if any) and the device copy (if the player's). */
37
+ function mergeOne(slug: string, hub: ResumeSummary | undefined, copy: LocalResume | undefined, isHubKnown: boolean): ResumeEntry | null {
38
+ if (copy === undefined) {
39
+ return hub === undefined ? null : fromHub(hub);
40
+ }
41
+ if (copy.status === 'cleared') {
42
+ return hub !== undefined && hub.updatedAt > copy.at ? fromHub(hub) : null;
43
+ }
44
+ if (hub !== undefined && hub.updatedAt >= copy.at) {
45
+ return fromHub(hub);
46
+ }
47
+ // The hub hasn't got this run: show it while it waits to be sent, or when the hub can't be asked.
48
+ // A synced copy the hub no longer lists was cleared or pruned elsewhere.
49
+ return !copy.isSynced || !isHubKnown || hub !== undefined ? fromDevice(slug, copy, hub) : null;
50
+ }
51
+
52
+ /**
53
+ * The runs to show, newest first. `hub` is the hub's list, or `undefined`
54
+ * when it couldn't be reached; `copies` are the device's, by slug; only
55
+ * those saved by `userId` count.
56
+ */
57
+ export function mergeResumeLists(hub: readonly ResumeSummary[] | undefined, copies: ReadonlyMap<string, LocalResume>, userId: number | null): ResumeEntry[] {
58
+ const hubBySlug = new Map((hub ?? []).map((summary) => [summary.game, summary] as const));
59
+ const mine = new Map([...copies].filter(([, copy]) => copy.userId === userId));
60
+ const slugs = new Set([...hubBySlug.keys(), ...mine.keys()]);
61
+ return [...slugs]
62
+ .map((slug) => mergeOne(slug, hubBySlug.get(slug), mine.get(slug), hub !== undefined))
63
+ .filter((entry): entry is ResumeEntry => entry !== null)
64
+ .sort((a, b) => b.savedAt - a.savedAt);
65
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Whether this page was opened from the home page's Resume card
3
+ * (`/<slug>/?hub_resume=1`). The first client copy on the page to look takes
4
+ * the parameter out of the address bar (so a reload or a shared link doesn't
5
+ * resume again) and records the answer on `window`, so every other copy on
6
+ * the page (a game's bundled client and the host's `/_hub/hub.js`) answers
7
+ * the same, however late it asks.
8
+ */
9
+ import { isBrowserPage } from '../page';
10
+
11
+ /** The query parameter (the contract's `RESUME_REQUEST_PARAM`; the browser client carries no contract code). */
12
+ export const RESUME_REQUEST_PARAM = 'hub_resume';
13
+
14
+ /** Where the answer is kept for the page. */
15
+ const PAGE_FLAG = '__HUB_RESUME_REQUESTED__';
16
+
17
+ /** Take `hub_resume` out of the address bar, keeping the rest of the URL and the history state. */
18
+ function stripParam(url: URL): void {
19
+ url.searchParams.delete(RESUME_REQUEST_PARAM);
20
+ try {
21
+ history.replaceState(history.state, '', `${url.pathname}${url.search}${url.hash}`);
22
+ } catch {
23
+ // A sandboxed page that refuses replaceState keeps the parameter; the answer is still recorded.
24
+ }
25
+ }
26
+
27
+ /** Whether the address has `hub_resume`; it is taken out when it does. */
28
+ function takeParam(): boolean {
29
+ try {
30
+ const url = new URL(location.href);
31
+ if (!url.searchParams.has(RESUME_REQUEST_PARAM)) {
32
+ return false;
33
+ }
34
+ stripParam(url);
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ /** `true` when the page was opened to resume (synchronous, so a game can decide before it routes or draws). */
42
+ export function isResumeRequested(): boolean {
43
+ if (!isBrowserPage()) {
44
+ return false;
45
+ }
46
+ if (Reflect.has(window, PAGE_FLAG)) {
47
+ return Reflect.get(window, PAGE_FLAG) === true;
48
+ }
49
+ const isRequested = takeParam();
50
+ Reflect.set(window, PAGE_FLAG, isRequested);
51
+ return isRequested;
52
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `hub.resume`'s public types: what a game saves, what it loads back, and
3
+ * the player's list of saved runs the home page shows.
4
+ */
5
+ import type { GamePayload } from '../game-data';
6
+
7
+ /** How far along a run is. */
8
+ export interface ResumeProgress {
9
+ /** A short line the game writes, such as "Level 12" or "Round 3 of 5" (up to 60 characters). */
10
+ readonly label: string;
11
+ /** 0 to 1, drawn as a progress bar; leave it out when the game can't say. */
12
+ readonly fraction?: number | null;
13
+ }
14
+
15
+ /** A run to save. `TState` is the game's own JSON-safe state type. */
16
+ export interface ResumeSaveInput<TState extends GamePayload> {
17
+ /** Everything the game needs to pick the run up again (up to 512 KB as JSON). */
18
+ readonly state: TState;
19
+ readonly progress: ResumeProgress;
20
+ /** An optional name for the run ("Klondike, draw 3"), shown under the game's name. */
21
+ readonly title?: string | null;
22
+ /** The game's state version (default 0). Bump it when `state` changes shape; see `load({ version })`. */
23
+ readonly version?: number;
24
+ }
25
+
26
+ /** A saved run, read back. */
27
+ export interface ResumeRun<TState> {
28
+ readonly state: TState;
29
+ readonly progress: { readonly label: string; readonly fraction: number | null };
30
+ readonly title: string | null;
31
+ readonly version: number;
32
+ /** When the game saved it, epoch ms. */
33
+ readonly savedAt: number;
34
+ }
35
+
36
+ /** Options for reading the run back. */
37
+ export interface ResumeLoadOptions {
38
+ /**
39
+ * The state version this code reads. A run saved with an older version is
40
+ * dropped (cleared) and reads as `null`; a newer one (from a newer copy of
41
+ * the game in another tab) reads as `null` but is kept.
42
+ */
43
+ readonly version?: number;
44
+ }
45
+
46
+ /** One saved run in the player's list (the home page's Resume card). */
47
+ export interface ResumeEntry {
48
+ /** The game's slug. */
49
+ readonly slug: string;
50
+ readonly gameTitle: string;
51
+ /** The game's URL path, like `/solitaire`. */
52
+ readonly path: string;
53
+ /** The game has a tile image (`/_tiles/<slug>`). */
54
+ readonly hasImage: boolean;
55
+ readonly progress: { readonly label: string; readonly fraction: number | null };
56
+ readonly title: string | null;
57
+ /** When it was saved, epoch ms. */
58
+ readonly savedAt: number;
59
+ /** Saved on this device and not yet sent to the hub (offline, or the tab closed first). */
60
+ readonly isLocalOnly: boolean;
61
+ }
62
+
63
+ /** Why a save was ignored: the page is in a race, a daily challenge or an online room. */
64
+ export type ResumeBlocker = 'race' | 'daily' | 'room';
@@ -1,3 +1,3 @@
1
1
  // Generated by tools/hub-client/generate.ts. Do not edit; run `npm run gen:hub-client` in next/.
2
2
  /** This client's package version, sent as `x-hub-client` on REST calls and `client` in `hello`. */
3
- export const HUB_CLIENT_VERSION = '2.0.0-next.3';
3
+ export const HUB_CLIENT_VERSION = '2.0.0-next.4';
@@ -42,8 +42,12 @@ function messageOf(error: unknown): string {
42
42
  /** Daily challenges (`hub.daily`). */
43
43
  export class DailySystem {
44
44
  /**
45
- * The challenge in progress (set when a day starts, cleared on complete).
46
- * A game's win handler checks it: `if (hub.daily.active) hub.daily.complete(…)`.
45
+ * The challenge in progress: set when a day starts; cleared the moment the
46
+ * game calls `complete` (whatever the hub answers, and before it answers)
47
+ * or `cancel`. A game's win handler checks it:
48
+ * `if (hub.daily.active) hub.daily.complete(…)`. While it is set the page
49
+ * is in a daily run, so `hub.resume.save` saves nothing: a game whose
50
+ * player leaves a daily unfinished must call `cancel()`.
47
51
  */
48
52
  active: DailyChallenge | null = null;
49
53
  private readonly host: DailyHost;
@@ -115,16 +119,17 @@ export class DailySystem {
115
119
  * Resolves with the new completion count and rank, or `null` when no
116
120
  * challenge is active, the hub refused, or the hub can't be reached: then
117
121
  * the completion is queued (`hub:daily-queued`) and replays when it is
118
- * back (`hub:daily-complete` follows).
122
+ * back (`hub:daily-complete` follows). The daily run ends at the call,
123
+ * so `active` is `null` at once, even if the hub then refuses it.
119
124
  */
120
125
  async complete(meta: DailyCompletionMeta = {}): Promise<DailyCompleteResponse | null> {
121
126
  const challenge = this.active;
122
127
  if (challenge === null) {
123
128
  return null;
124
129
  }
130
+ this.active = null;
125
131
  try {
126
132
  const completed = await this.host.complete(challenge.day, challenge.token, meta);
127
- this.active = null;
128
133
  if (completed === null) {
129
134
  // Queued offline: `hub:daily-complete` follows when it replays.
130
135
  this.host.emit('hub:daily-queued', { day: challenge.day, queued: true, meta });
@@ -151,7 +156,11 @@ export class DailySystem {
151
156
  }
152
157
  }
153
158
 
154
- /** Abandon the active challenge without recording it (e.g. the player quit). */
159
+ /**
160
+ * Abandon the active challenge without recording it: the player quit, or
161
+ * left the daily for normal play (another level, a new game). Until then
162
+ * the page counts as in a daily run and `hub.resume.save` saves nothing.
163
+ */
155
164
  cancel(): void {
156
165
  this.active = null;
157
166
  }
package/src/index.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  * queue their own operations with `hub.offline` (docs: "Offline and replay").
18
18
  *
19
19
  * The page-wide namespaces hang off `hub` as lazy getters (`hub.input`,
20
- * `hub.daily`, `hub.social`, `hub.mp`, `hub.rooms`, `hub.race`), and each has
20
+ * `hub.daily`, `hub.resume`, `hub.social`, `hub.mp`, `hub.rooms`, `hub.race`), and each has
21
21
  * its own entry (`@diffenderfer-games/hub/input`, ...) for games that want
22
22
  * only that part.
23
23
  *
@@ -36,6 +36,8 @@ export type { SyncConflict, SyncResult } from './core/connection';
36
36
  export type { AuthResponse, ProfileUpdate, StatResult } from './core/hub-progress';
37
37
  export type { GrantBundle, TradeBundle, TradeId, TradeListOptions, TradeNoteOptions } from './core/hub-economy';
38
38
  export type { HubMenuControl } from './core/menu-control';
39
+ export { HubResume, RESUME_DEBOUNCE_MS, RESUME_MAX_STATE_BYTES } from './core/resume/hub-resume';
40
+ export type { ResumeBlocker, ResumeEntry, ResumeLoadOptions, ResumeProgress, ResumeRun, ResumeSaveInput } from './core/resume/resume-types';
39
41
  export type { QueueOp, QueueOpInput, QueuedScoreOptions, ProfilePatch } from './core/offline/queue';
40
42
  export { OfflineApi } from './core/offline/offline-api';
41
43
  export { OUTBOX_FULL_CODE, type OfflineEventDetails, type OfflineEventName, type OfflineListener, type OfflineOp } from './core/offline/outbox';
@@ -1,18 +1,30 @@
1
1
  /**
2
2
  * `hub.rooms`: hub relay rooms, forwarded to the social runtime. Each room is
3
- * wrapped so its rejections use the core client's error classes.
3
+ * wrapped so its rejections use the core client's error classes, and the
4
+ * page's open rooms are tracked (`isInHubRoom`), so `hub.resume` can refuse
5
+ * to save an online run.
4
6
  */
5
7
  import type { Json, RoomInfo } from '@diffenderfer-games/hub-contract';
6
8
  import { outdatedRuntime, pageBridge, rethrowAsHubError } from './bridge';
7
9
  import type { HubRoom, HubRoomsApi, RoomEvents, RoomSelf, RoomSendOptions, RoomState, RoomStateOptions } from './room-types';
8
10
  import type { RoomSeat } from './wire-types';
9
11
 
12
+ /** The rooms this page is in (created, joined or reclaimed, and not yet left or closed). */
13
+ const openRooms = new Set<HubRoom>();
14
+
15
+ /** Whether this page is in a hub room right now. */
16
+ export function isInHubRoom(): boolean {
17
+ return openRooms.size > 0;
18
+ }
19
+
10
20
  /** A runtime room whose rejections are re-created as the core client's errors. */
11
21
  class ForwardedRoom implements HubRoom {
12
22
  private readonly room: HubRoom;
13
23
 
14
24
  constructor(room: HubRoom) {
15
25
  this.room = room;
26
+ openRooms.add(room);
27
+ room.on('closed', () => { openRooms.delete(room); });
16
28
  }
17
29
 
18
30
  get info(): RoomInfo {
@@ -68,6 +80,7 @@ class ForwardedRoom implements HubRoom {
68
80
  }
69
81
 
70
82
  leave(): Promise<void> {
83
+ openRooms.delete(this.room);
71
84
  return rethrowAsHubError(this.room.leave());
72
85
  }
73
86