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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/auto.d.ts +2 -2
  3. package/dist/auto.js +2 -2
  4. package/dist/doctor/cli.mjs +1 -1
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.js +2 -2
  7. package/dist/rooms.js +1 -1
  8. package/dist/shared/client-version.gen.js +1 -1
  9. package/dist/shared/daily.d.ts +13 -4
  10. package/dist/shared/daily.d.ts.map +1 -1
  11. package/dist/shared/daily.js +15 -6
  12. package/dist/shared/daily.js.map +1 -1
  13. package/dist/shared/index.d.ts +237 -6
  14. package/dist/shared/index.d.ts.map +1 -1
  15. package/dist/shared/rooms.d.ts.map +1 -1
  16. package/dist/shared/rooms.js +12 -1
  17. package/dist/shared/rooms.js.map +1 -1
  18. package/dist/shared/src.js +696 -31
  19. package/dist/shared/src.js.map +1 -1
  20. package/package.json +2 -2
  21. package/src/core/connection.ts +6 -0
  22. package/src/core/errors.ts +8 -1
  23. package/src/core/hub-progress.ts +44 -8
  24. package/src/core/hub.ts +55 -4
  25. package/src/core/offline/cloud-saves.ts +62 -0
  26. package/src/core/offline/hub-kinds/index.ts +11 -3
  27. package/src/core/offline/hub-kinds/progress-kinds.ts +21 -4
  28. package/src/core/offline/hub-kinds/resume-kind.ts +108 -0
  29. package/src/core/offline/local-store.ts +46 -1
  30. package/src/core/page.ts +10 -0
  31. package/src/core/rest/routes.gen.ts +4 -0
  32. package/src/core/resume/hub-resume.ts +252 -0
  33. package/src/core/resume/local-resume.ts +130 -0
  34. package/src/core/resume/resume-list.ts +65 -0
  35. package/src/core/resume/resume-request.ts +52 -0
  36. package/src/core/resume/resume-types.ts +64 -0
  37. package/src/core/version/client-version.gen.ts +1 -1
  38. package/src/daily/daily-system.ts +14 -5
  39. package/src/index.ts +4 -1
  40. package/src/realtime/rooms-forwarder.ts +14 -1
@@ -0,0 +1,62 @@
1
+ /**
2
+ * What this device last saw of a game's save slots on the hub, per player:
3
+ * each slot's `updatedAt` there, or `null` for a slot known to be empty. It
4
+ * decides two things offline. `getSave` answers from the cache only when
5
+ * the device knows the hub's copy of that slot (so a fresh device or cleared
6
+ * browser never reports "no save" for a slot it simply hasn't seen), and a
7
+ * queued save write sends the hub's `updatedAt` it was built on, so the hub
8
+ * refuses it when the slot has changed since.
9
+ *
10
+ * A snapshot gives the full list (`complete`: any other slot is empty); a
11
+ * single read or write adds that slot to a partial list.
12
+ */
13
+ import { isRecord, optionalNumber } from '../storage/json-guards';
14
+
15
+ /** The hub's save slots for one game, as this device last saw them. */
16
+ export interface CloudSaveIndex {
17
+ /** Whose slots these are. */
18
+ readonly userId: number;
19
+ /** `true` when every slot the hub had is listed (from a snapshot). */
20
+ readonly complete: boolean;
21
+ /** Each slot's `updatedAt` on the hub, or `null` when it was empty. */
22
+ readonly saves: Readonly<Record<string, number | null>>;
23
+ }
24
+
25
+ /** A stored index, or `null` when missing or malformed. */
26
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: an index read back from localStorage
27
+ export function readCloudSaveIndex(stored: unknown): CloudSaveIndex | null {
28
+ if (!isRecord(stored) || typeof stored.complete !== 'boolean' || !isRecord(stored.saves)) {
29
+ return null;
30
+ }
31
+ const userId = optionalNumber(stored.userId);
32
+ if (userId === undefined) {
33
+ return null;
34
+ }
35
+ const saves: Record<string, number | null> = {};
36
+ for (const [slot, updatedAt] of Object.entries(stored.saves)) {
37
+ const known = updatedAt === null ? null : optionalNumber(updatedAt);
38
+ if (known === undefined) {
39
+ return null;
40
+ }
41
+ saves[slot] = known;
42
+ }
43
+ return { userId, complete: stored.complete, saves };
44
+ }
45
+
46
+ /**
47
+ * The hub's `updatedAt` for `slot` as `userId` last saw it: a number, `null`
48
+ * when the slot was empty, or `undefined` when this device doesn't know.
49
+ */
50
+ export function cloudSaveOf(index: CloudSaveIndex | null, userId: number, slot: string): number | null | undefined {
51
+ if (index?.userId !== userId) {
52
+ return undefined;
53
+ }
54
+ const updatedAt = index.saves[slot];
55
+ return updatedAt === undefined && index.complete ? null : updatedAt;
56
+ }
57
+
58
+ /** `index` with `slot` seen at `updatedAt` (`null`: empty) by `userId`; another player's index starts over. */
59
+ export function withCloudSave(index: CloudSaveIndex | null, userId: number, slot: string, updatedAt: number | null): CloudSaveIndex {
60
+ const base = index?.userId === userId ? index : { userId, complete: false, saves: {} };
61
+ return { ...base, saves: { ...base.saves, [slot]: updatedAt } };
62
+ }
@@ -2,17 +2,20 @@
2
2
  * Every kind the hub itself queues offline (`hub.*`), registered on a
3
3
  * client's outbox. The docs' "Offline and replay" table lists them.
4
4
  */
5
+ import type { LocalResumeStore } from '../../resume/local-resume';
5
6
  import { writeLocalFavorites } from '../favorites';
6
7
  import type { Outbox } from '../outbox';
7
8
  import { dailyKinds, type DailyCompletion, type DailyPayload } from './daily-kind';
8
9
  import { economyKinds } from './economy-kinds';
9
10
  import type { GameFieldsSource } from './game-fields';
10
- import { progressKinds } from './progress-kinds';
11
+ import { progressKinds, type CloudSaveTracker } from './progress-kinds';
12
+ import { resumeKinds } from './resume-kind';
11
13
 
12
14
  export { DAILY_KIND, type DailyPayload } from './daily-kind';
13
15
  export { ECONOMY_KINDS, type ExchangePayload, type FavoritePayload } from './economy-kinds';
14
16
  export { legacyOperation } from './legacy-ops';
15
- export { PROGRESS_KINDS, type ProfilePayload, type SavePayload, type ScorePayload, type StatPayload } from './progress-kinds';
17
+ export { RESUME_KIND, type ResumePayload } from './resume-kind';
18
+ export { PROGRESS_KINDS, type CloudSaveTracker, type ProfilePayload, type SavePayload, type ScorePayload, type StatPayload } from './progress-kinds';
16
19
 
17
20
  /** What the hub kinds report back to their client. */
18
21
  export interface HubKindHooks {
@@ -20,14 +23,19 @@ export interface HubKindHooks {
20
23
  readonly game: GameFieldsSource;
21
24
  /** A daily completion landed (the menu's calendar listens for `hub:daily-complete`). */
22
25
  readonly onDailyComplete: (payload: DailyPayload, completion: DailyCompletion) => void;
26
+ /** The device copies of each game's resume slot (`hub.resume`). */
27
+ readonly resume: LocalResumeStore;
28
+ /** What this device last saw of the game's save slots on the hub. */
29
+ readonly cloudSaves: CloudSaveTracker;
23
30
  }
24
31
 
25
32
  /** Register every hub kind on `outbox`. */
26
33
  export function registerHubKinds(outbox: Outbox, hooks: HubKindHooks): void {
27
34
  const kinds = {
28
- ...progressKinds(hooks.game),
35
+ ...progressKinds(hooks.game, hooks.cloudSaves),
29
36
  ...dailyKinds(hooks.game, hooks.onDailyComplete),
30
37
  ...economyKinds(hooks.game, writeLocalFavorites),
38
+ ...resumeKinds(hooks.game, hooks.resume),
31
39
  };
32
40
  for (const [kind, definition] of Object.entries(kinds)) {
33
41
  outbox.define(kind, definition);
@@ -12,6 +12,14 @@ import type { RegisteredKind } from '../outbox-kinds';
12
12
  import { betterScore } from '../queue';
13
13
  import type { GameFieldsSource } from './game-fields';
14
14
 
15
+ /** What the save kind reads and records about the hub's copy of a slot (`LocalStore`). */
16
+ export interface CloudSaveTracker {
17
+ /** The hub's `updatedAt` for `slot` as this device last saw it (`null`: empty), or `undefined` when it doesn't know. */
18
+ cloudSave(slot: string): number | null | undefined;
19
+ /** Note the hub's copy of `slot` after a write landed: its `updatedAt`, or `null` once deleted. */
20
+ rememberCloudSave(slot: string, updatedAt: number | null): void;
21
+ }
22
+
15
23
  /** A save slot write: new contents, or a delete. */
16
24
  export type SavePayload =
17
25
  | { readonly slot: string; readonly data: Json; readonly label: string | null }
@@ -109,8 +117,14 @@ export function mergeStats(waiting: StatPayload, incoming: StatPayload): StatPay
109
117
  }
110
118
  }
111
119
 
112
- /** The progress kinds, by name. */
113
- export function progressKinds(game: GameFieldsSource): Record<string, RegisteredKind> {
120
+ /**
121
+ * The progress kinds, by name. A save write replayed from the queue sends
122
+ * the hub's `updatedAt` this device last saw for the slot (`null` when it
123
+ * saw it empty or never saw it), so the hub refuses it (409 `save_conflict`,
124
+ * dropped and reported as a conflict) when another device saved since. A
125
+ * write sent at once, online, always lands, as before.
126
+ */
127
+ export function progressKinds(game: GameFieldsSource, cloudSaves: CloudSaveTracker): Record<string, RegisteredKind> {
114
128
  return {
115
129
  [PROGRESS_KINDS.save]: checkedKind(readSave, {
116
130
  mergeKey: (save) => save.slot,
@@ -118,9 +132,12 @@ export function progressKinds(game: GameFieldsSource): Record<string, Registered
118
132
  run: async (save, context) => {
119
133
  if ('deleted' in save) {
120
134
  await context.call('deleteSave', { ...game(), slot: save.slot });
121
- } else {
122
- await context.call('writeSave', { ...game(), slot: save.slot, data: save.data, label: save.label });
135
+ cloudSaves.rememberCloudSave(save.slot, null);
136
+ return;
123
137
  }
138
+ const base = context.isReplay ? { base: cloudSaves.cloudSave(save.slot) ?? null } : {};
139
+ const written = await context.call('writeSave', { ...game(), slot: save.slot, data: save.data, label: save.label, ...base });
140
+ cloudSaves.rememberCloudSave(save.slot, written.updatedAt);
124
141
  },
125
142
  }),
126
143
  [PROGRESS_KINDS.stat]: checkedKind(readStat, {
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The hub's resume kind (`hub.resume`): send a game's saved run, or clear it.
3
+ * One slot per game, so a new op replaces any waiting one (a clear after a
4
+ * save drops the save, and the other way round).
5
+ *
6
+ * A save op carries only the run's facts and time: when it runs it sends
7
+ * the newest run in the device copy (`LocalResumeStore`), which keeps the
8
+ * outbox small. When the device copy couldn't be stored, the op carries the
9
+ * state itself. A session that lapsed (401) gets a guest session and tries
10
+ * once more, so a player's very first save can't be lost to it.
11
+ */
12
+ import type { Json } from '@diffenderfer-games/hub-contract';
13
+ import { HubError } from '../../errors';
14
+ import type { LocalResumeStore } from '../../resume/local-resume';
15
+ import { isJson, isNullableString, isRecord, optionalNumber, optionalString } from '../../storage/json-guards';
16
+ import { checkedKind } from '../kind-adapters';
17
+ import type { RegisteredKind, ReplayContext } from '../outbox-kinds';
18
+ import type { GameFieldsSource } from './game-fields';
19
+
20
+ /** The kind name. */
21
+ export const RESUME_KIND = 'hub.resume';
22
+
23
+ /** Send the run saved at `savedAt`; `state` only when the device copy couldn't hold it. */
24
+ export type ResumeSavePayload = {
25
+ readonly action: 'save';
26
+ readonly savedAt: number;
27
+ readonly label: string;
28
+ readonly fraction: number | null;
29
+ readonly title: string | null;
30
+ readonly version: number;
31
+ readonly state?: Json;
32
+ };
33
+
34
+ /** Clear the run, unless one was saved after `before`. */
35
+ export type ResumeClearPayload = { readonly action: 'clear'; readonly before: number };
36
+
37
+ /** One resume op. */
38
+ export type ResumePayload = ResumeSavePayload | ResumeClearPayload;
39
+
40
+ function readResume(stored: Json): ResumePayload | undefined {
41
+ if (!isRecord(stored)) {
42
+ return undefined;
43
+ }
44
+ if (stored.action === 'clear') {
45
+ const before = optionalNumber(stored.before);
46
+ return before === undefined ? undefined : { action: 'clear', before };
47
+ }
48
+ const savedAt = optionalNumber(stored.savedAt);
49
+ const label = optionalString(stored.label);
50
+ const version = optionalNumber(stored.version);
51
+ const fraction = stored.fraction === null ? null : optionalNumber(stored.fraction);
52
+ if (stored.action !== 'save' || savedAt === undefined || label === undefined || version === undefined || fraction === undefined
53
+ || !isNullableString(stored.title) || (stored.state !== undefined && !isJson(stored.state))) {
54
+ return undefined;
55
+ }
56
+ const base = { action: 'save', savedAt, label, fraction, title: stored.title, version } as const;
57
+ return stored.state === undefined ? base : { ...base, state: stored.state };
58
+ }
59
+
60
+ /** 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. */
61
+ function runToSend(save: ResumeSavePayload, local: LocalResumeStore, slug: string): ResumeSavePayload | undefined {
62
+ const copy = local.read(slug);
63
+ if (copy?.status === 'saved' && copy.at >= save.savedAt) {
64
+ return { action: 'save', savedAt: copy.at, label: copy.label, fraction: copy.fraction, title: copy.title, version: copy.version, state: copy.state };
65
+ }
66
+ return save.state === undefined ? undefined : save;
67
+ }
68
+
69
+ /** Run `call`; on a lapsed session (401) start a guest session and run it once more. */
70
+ async function withSession<Reply>(context: ReplayContext, call: () => Promise<Reply>): Promise<Reply> {
71
+ try {
72
+ return await call();
73
+ } catch (failure) {
74
+ if (!(failure instanceof HubError) || failure.status !== 401) {
75
+ throw failure;
76
+ }
77
+ await context.call('ensureGuestSession');
78
+ return call();
79
+ }
80
+ }
81
+
82
+ /** The resume kind, by name, over the device copies in `local`. */
83
+ export function resumeKinds(game: GameFieldsSource, local: LocalResumeStore): Record<string, RegisteredKind> {
84
+ return {
85
+ [RESUME_KIND]: checkedKind(readResume, {
86
+ mergeKey: () => 'resume',
87
+ replaces: true,
88
+ run: async (op, context) => {
89
+ const { slug } = game();
90
+ if (op.action === 'clear') {
91
+ await withSession(context, () => context.call('clearResumeSlot', { slug, before: op.before }));
92
+ local.markSynced(slug, op.before);
93
+ return undefined;
94
+ }
95
+ const run = runToSend(op, local, slug);
96
+ if (run?.state === undefined) {
97
+ return undefined;
98
+ }
99
+ const { state } = run;
100
+ await withSession(context, () => context.call('saveResumeSlot', {
101
+ slug, state, progress: { label: run.label, fraction: run.fraction }, title: run.title, version: run.version, savedAt: run.savedAt,
102
+ }));
103
+ local.markSynced(slug, run.savedAt);
104
+ return undefined;
105
+ },
106
+ }),
107
+ };
108
+ }
@@ -10,6 +10,7 @@ import type { Board, Inventory, Json, SortDir, Stats } from '@diffenderfer-games
10
10
  import { isJson, isNullableString, isNumberRecord, isRecord, optionalNumber, optionalString, parseJsonText } from '../storage/json-guards';
11
11
  import { createPageStore, type KeyValueStore } from '../storage/key-value';
12
12
  import type { Profile, Save, SaveMeta, SnapshotResponse, User } from '../types';
13
+ import { cloudSaveOf, readCloudSaveIndex, withCloudSave, type CloudSaveIndex } from './cloud-saves';
13
14
  import { appendEvent, readEvents, type BufferedEvent } from './events';
14
15
  import { EMPTY_INVENTORY, readInventory } from './local-inventory';
15
16
  import { betterScore, readQueue, type QueueOp, type QueuedScoreOptions } from './queue';
@@ -159,6 +160,37 @@ export class LocalStore {
159
160
  this.store.remove(`${this.prefix}save:${slot}`);
160
161
  }
161
162
 
163
+ /**
164
+ * The hub's `updatedAt` for `slot` as the current user last saw it (`null`:
165
+ * the slot was empty), or `undefined` when this device doesn't know.
166
+ */
167
+ cloudSave(slot: string): number | null | undefined {
168
+ const user = this.getUser();
169
+ return user === null ? undefined : cloudSaveOf(this.readCloudSaves(this.prefix), user.id, slot);
170
+ }
171
+
172
+ /** Note the hub's copy of `slot` (just read or written) for the current user: its `updatedAt`, or `null` when empty. */
173
+ rememberCloudSave(slot: string, updatedAt: number | null): void {
174
+ const user = this.getUser();
175
+ if (user !== null) {
176
+ this.writeStored(`${this.prefix}cloud-saves`, withCloudSave(this.readCloudSaves(this.prefix), user.id, slot, updatedAt));
177
+ }
178
+ }
179
+
180
+ /** Cache a slot as read from the hub (forgetting it when the hub has none), and note the hub's copy. */
181
+ cacheCloudSave(slot: string, save: Save | null): void {
182
+ if (save === null) {
183
+ this.deleteSave(slot);
184
+ } else {
185
+ this.writeStored(`${this.prefix}save:${slot}`, save);
186
+ }
187
+ this.rememberCloudSave(slot, save?.updatedAt ?? null);
188
+ }
189
+
190
+ private readCloudSaves(gamePrefix: string): CloudSaveIndex | null {
191
+ return readCloudSaveIndex(this.readStored(`${gamePrefix}cloud-saves`));
192
+ }
193
+
162
194
  /** This game's cached stats. */
163
195
  getStats(): Stats {
164
196
  const stored = this.readStored(`${this.prefix}stats`);
@@ -232,11 +264,20 @@ export class LocalStore {
232
264
  this.setEvents(appendEvent(this.getEvents(), { type, ts: Date.now() }));
233
265
  }
234
266
 
235
- /** Fan a `/me/snapshot` reply out across every game's cache keys. */
267
+ /**
268
+ * Fan a `/me/snapshot` reply out across every game's cache keys. It lists
269
+ * every save slot of every game the player has played, so each game's
270
+ * slots on the hub become known (and this game's are all empty when it
271
+ * isn't listed).
272
+ */
236
273
  hydrate(snapshot: SnapshotResponse): void {
237
274
  if (snapshot.user !== null) {
238
275
  this.writeStored(USER_KEY, snapshot.user);
239
276
  }
277
+ const userId = snapshot.user?.id;
278
+ if (userId !== undefined && !Object.keys(snapshot.games).some((slug) => `hub:${slug}:` === this.prefix)) {
279
+ this.writeStored(`${this.prefix}cloud-saves`, { userId, complete: true, saves: {} } satisfies CloudSaveIndex);
280
+ }
240
281
  for (const [slug, game] of Object.entries(snapshot.games)) {
241
282
  const gamePrefix = `hub:${slug}:`;
242
283
  if (game.profile !== null) {
@@ -245,6 +286,10 @@ export class LocalStore {
245
286
  for (const save of game.saves) {
246
287
  this.writeStored(`${gamePrefix}save:${save.slot}`, save);
247
288
  }
289
+ if (userId !== undefined) {
290
+ const saves = Object.fromEntries(game.saves.map((save) => [save.slot, save.updatedAt]));
291
+ this.writeStored(`${gamePrefix}cloud-saves`, { userId, complete: true, saves } satisfies CloudSaveIndex);
292
+ }
248
293
  this.writeStored(`${gamePrefix}stats`, game.stats);
249
294
  this.writeStored(`${gamePrefix}inventory`, game.inventory);
250
295
  }
package/src/core/page.ts CHANGED
@@ -37,6 +37,16 @@ export function injectedConfig(): InjectedConfig {
37
37
  return { ...(slug === undefined ? {} : { slug }), ...(apiBase === undefined ? {} : { apiBase }) };
38
38
  }
39
39
 
40
+ /** The game's display title: the host's injected one, else the page's `<title>`, else `fallback`. */
41
+ export function detectGameTitle(fallback: string): string {
42
+ const injected = isBrowserPage() && isRecord(window.__HUB__) ? optionalString(window.__HUB__.title) : undefined;
43
+ if (injected !== undefined && injected !== '') {
44
+ return injected;
45
+ }
46
+ const pageTitle = typeof document === 'undefined' ? '' : document.title.trim();
47
+ return pageTitle === '' ? fallback : pageTitle;
48
+ }
49
+
40
50
  /** The game's slug: the injected one, else the first URL path segment, else `null`. */
41
51
  export function detectSlug(): string | null {
42
52
  const injectedSlug = injectedConfig().slug;
@@ -105,6 +105,10 @@ export const ROUTES = {
105
105
  launchTournamentMatch: { method: 'POST', path: '/tournaments/:id/matches/:match/launch', query: [], offline: null },
106
106
  getPlayOnline: { method: 'GET', path: '/play-online', query: [], offline: null },
107
107
  getHubVersion: { method: 'GET', path: '/version', query: [], offline: null },
108
+ getResumeSlot: { method: 'GET', path: '/games/:slug/resume', query: [], offline: null },
109
+ saveResumeSlot: { method: 'PUT', path: '/games/:slug/resume', query: [], offline: 'replay' },
110
+ clearResumeSlot: { method: 'DELETE', path: '/games/:slug/resume', query: ['before'], offline: 'replay' },
111
+ listMyResumeSlots: { method: 'GET', path: '/me/resume', query: [], offline: null },
108
112
  } as const;
109
113
 
110
114
  /** The request header that carries an offline operation key (the contract's `OFFLINE_OP_HEADER`). */
@@ -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
+ }