@diffenderfer-games/hub 2.0.0-next.3 → 2.0.0-next.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/dist/auto.d.ts +2 -2
- package/dist/auto.js +2 -2
- package/dist/doctor/cli.mjs +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/rooms.js +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.d.ts +13 -4
- package/dist/shared/daily.d.ts.map +1 -1
- package/dist/shared/daily.js +15 -6
- package/dist/shared/daily.js.map +1 -1
- package/dist/shared/index.d.ts +203 -3
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/rooms.d.ts.map +1 -1
- package/dist/shared/rooms.js +12 -1
- package/dist/shared/rooms.js.map +1 -1
- package/dist/shared/src.js +531 -8
- package/dist/shared/src.js.map +1 -1
- package/package.json +2 -2
- package/src/core/connection.ts +5 -0
- package/src/core/hub.ts +55 -4
- package/src/core/offline/hub-kinds/index.ts +6 -0
- package/src/core/offline/hub-kinds/resume-kind.ts +108 -0
- package/src/core/page.ts +10 -0
- package/src/core/rest/routes.gen.ts +4 -0
- package/src/core/resume/hub-resume.ts +252 -0
- package/src/core/resume/local-resume.ts +130 -0
- package/src/core/resume/resume-list.ts +65 -0
- package/src/core/resume/resume-request.ts +52 -0
- package/src/core/resume/resume-types.ts +64 -0
- package/src/core/version/client-version.gen.ts +1 -1
- package/src/daily/daily-system.ts +14 -5
- package/src/index.ts +3 -1
- package/src/realtime/rooms-forwarder.ts +14 -1
|
@@ -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
|
+
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
|
|
46
|
-
*
|
|
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
|
-
/**
|
|
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
|
|