@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.
@@ -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, {
@@ -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
  }
@@ -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.4';
3
+ export const HUB_CLIENT_VERSION = '2.0.0-next.5';
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ export {
28
28
  ContentRejectedError,
29
29
  HubError,
30
30
  OfflineError,
31
+ SAVE_UNKNOWN_CODE,
31
32
  UNSUPPORTED_BY_HUB,
32
33
  type HubErrorCode,
33
34
  type HubErrorDetails,