@diffenderfer-games/hub 2.0.0-next.4 → 2.0.0-next.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -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/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.js +1 -1
- package/dist/shared/index.d.ts +36 -5
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/src.js +167 -25
- package/dist/shared/src.js.map +1 -1
- package/package.json +2 -2
- package/src/core/connection.ts +1 -0
- package/src/core/errors.ts +8 -1
- package/src/core/hub-progress.ts +44 -8
- package/src/core/offline/cloud-saves.ts +62 -0
- package/src/core/offline/hub-kinds/index.ts +5 -3
- package/src/core/offline/hub-kinds/progress-kinds.ts +21 -4
- package/src/core/offline/local-store.ts +46 -1
- package/src/core/version/client-version.gen.ts +1 -1
- package/src/index.ts +1 -0
|
@@ -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
|
-
/**
|
|
113
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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, {
|
|
@@ -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
|
-
/**
|
|
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
|
}
|
|
@@ -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.5';
|