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

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 (102) hide show
  1. package/CHANGELOG.md +40 -6
  2. package/README.md +25 -10
  3. package/dist/auto.d.ts +7 -7
  4. package/dist/auto.js +3 -3
  5. package/dist/doctor/cli.mjs +14 -7
  6. package/dist/doctor/cli.mjs.map +1 -1
  7. package/dist/index.d.ts +7 -7
  8. package/dist/index.js +3 -3
  9. package/dist/input.d.ts +2 -2
  10. package/dist/input.js +2 -2
  11. package/dist/mp.d.ts +3 -3
  12. package/dist/race.d.ts +1 -1
  13. package/dist/rooms.d.ts +3 -3
  14. package/dist/shared/bridge.js +9 -3
  15. package/dist/shared/bridge.js.map +1 -1
  16. package/dist/shared/client-version.gen.js +1 -1
  17. package/dist/shared/daily.d.ts +10 -4
  18. package/dist/shared/daily.d.ts.map +1 -1
  19. package/dist/shared/daily.js +12 -2
  20. package/dist/shared/daily.js.map +1 -1
  21. package/dist/shared/index.d.ts +315 -36
  22. package/dist/shared/index.d.ts.map +1 -1
  23. package/dist/shared/input.d.ts +47 -5
  24. package/dist/shared/input.d.ts.map +1 -1
  25. package/dist/shared/input.js +139 -23
  26. package/dist/shared/input.js.map +1 -1
  27. package/dist/shared/latest-state.js +1 -1
  28. package/dist/shared/mp.js +10 -2
  29. package/dist/shared/mp.js.map +1 -1
  30. package/dist/shared/multiplayer-types.d.ts +15 -2
  31. package/dist/shared/multiplayer-types.d.ts.map +1 -1
  32. package/dist/shared/race-types.d.ts +1 -1
  33. package/dist/shared/race.js +1 -1
  34. package/dist/shared/room-types.d.ts +28 -3
  35. package/dist/shared/room-types.d.ts.map +1 -1
  36. package/dist/shared/rooms.d.ts.map +1 -1
  37. package/dist/shared/rooms.js +10 -2
  38. package/dist/shared/rooms.js.map +1 -1
  39. package/dist/shared/runtime-opener.js +1 -1
  40. package/dist/shared/social.d.ts +23 -8
  41. package/dist/shared/social.d.ts.map +1 -1
  42. package/dist/shared/social.js +6 -1
  43. package/dist/shared/social.js.map +1 -1
  44. package/dist/shared/src.js +1588 -388
  45. package/dist/shared/src.js.map +1 -1
  46. package/dist/shared/wire-types.d.ts +6 -2
  47. package/dist/shared/wire-types.d.ts.map +1 -1
  48. package/dist/social.d.ts +5 -5
  49. package/dist/social.js +1 -1
  50. package/package.json +2 -2
  51. package/src/core/connection.ts +187 -92
  52. package/src/core/game-data.ts +11 -9
  53. package/src/core/hub-economy.ts +47 -10
  54. package/src/core/hub-progress.ts +13 -16
  55. package/src/core/hub.ts +4 -2
  56. package/src/core/offline/hub-kinds/daily-kind.ts +68 -0
  57. package/src/core/offline/hub-kinds/economy-kinds.ts +86 -0
  58. package/src/core/offline/hub-kinds/game-fields.ts +2 -0
  59. package/src/core/offline/hub-kinds/index.ts +35 -0
  60. package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
  61. package/src/core/offline/hub-kinds/progress-kinds.ts +148 -0
  62. package/src/core/offline/kind-adapters.ts +48 -0
  63. package/src/core/offline/local-inventory.ts +64 -0
  64. package/src/core/offline/local-store.ts +20 -22
  65. package/src/core/offline/offline-api.ts +65 -0
  66. package/src/core/offline/outbox-entry.ts +59 -0
  67. package/src/core/offline/outbox-kinds.ts +78 -0
  68. package/src/core/offline/outbox-merge.ts +39 -0
  69. package/src/core/offline/outbox-replayer.ts +141 -0
  70. package/src/core/offline/outbox-store.ts +85 -0
  71. package/src/core/offline/outbox.ts +232 -0
  72. package/src/core/offline/queue.ts +4 -49
  73. package/src/core/offline/replay-lease.ts +87 -0
  74. package/src/core/offline/replay-policy.ts +52 -0
  75. package/src/core/rest/rest-client.ts +14 -3
  76. package/src/core/rest/routes.gen.ts +108 -87
  77. package/src/core/storage/json-guards.ts +6 -0
  78. package/src/core/version/client-version.gen.ts +1 -1
  79. package/src/daily/daily-system.ts +8 -1
  80. package/src/daily/types.ts +3 -2
  81. package/src/doctor/checks.ts +10 -2
  82. package/src/index.ts +6 -1
  83. package/src/input/focus-ring.ts +13 -0
  84. package/src/input/input-registry.ts +9 -2
  85. package/src/input/input-system.ts +4 -0
  86. package/src/input/modality.ts +122 -0
  87. package/src/input/nav-state.ts +13 -4
  88. package/src/input/zero-config.ts +1 -14
  89. package/src/input.ts +3 -0
  90. package/src/mp.ts +1 -1
  91. package/src/overlay/overlay.ts +5 -3
  92. package/src/overlay/styles.ts +4 -3
  93. package/src/realtime/bridge.ts +5 -0
  94. package/src/realtime/multiplayer-forwarder.ts +13 -1
  95. package/src/realtime/multiplayer-types.ts +15 -1
  96. package/src/realtime/room-types.ts +28 -2
  97. package/src/realtime/rooms-forwarder.ts +13 -1
  98. package/src/realtime/runtime-api.ts +19 -8
  99. package/src/realtime/social-forwarder.ts +5 -0
  100. package/src/realtime/social-types.ts +8 -0
  101. package/src/realtime/wire-types.ts +7 -0
  102. package/src/rooms.ts +1 -1
@@ -1,36 +1,43 @@
1
1
  /**
2
2
  * The machinery under every `Hub` call: reaching the REST API, falling back
3
- * to the offline cache, queueing writes made offline and replaying them,
4
- * buffering analytics, warming the cache for every game, and reporting
5
- * online/offline/sync status (to `onStatus` listeners and as `hub:*` window
6
- * events, which the injected menu listens for).
3
+ * to the offline cache, queueing writes made offline in the outbox and
4
+ * replaying them, buffering analytics, warming the cache for every game, and
5
+ * reporting online/offline/sync status (to `onStatus` listeners and as
6
+ * `hub:*` window events, which the injected menu listens for).
7
7
  */
8
+ import type { Json } from '@diffenderfer-games/hub-contract';
8
9
  import { HubError } from './errors';
10
+ import { registerHubKinds, legacyOperation } from './offline/hub-kinds';
9
11
  import { LocalStore } from './offline/local-store';
10
- import type { QueueOp, QueueOpInput } from './offline/queue';
12
+ import { OfflineApi } from './offline/offline-api';
13
+ import { Outbox, type OfflineOp } from './offline/outbox';
11
14
  import { absoluteApiBase, detectApiBase, detectSlug, emitWindowEvent, isBrowserPage } from './page';
12
15
  import { createRestClient, type RestClient } from './rest/rest-client';
16
+ import { createPageStore } from './storage/key-value';
13
17
  import type { HubOptions, HubStatus, SnapshotResponse } from './types';
14
18
 
15
19
  /** How long `prefetchAll()` waits between snapshots unless forced. */
16
20
  const PREFETCH_INTERVAL_MS = 30_000;
17
21
 
18
- /** A queued write the hub refused during a sync: dropped, with the hub's message. */
22
+ /** A queued op the hub refused during a sync (or that kept failing): dropped, with the reason. */
19
23
  export interface SyncConflict {
20
- /** The refused op. */
21
- readonly op: QueueOp;
22
- /** The hub's message. */
24
+ /** The dropped op. */
25
+ readonly op: OfflineOp;
26
+ /** The hub's (or the handler's) message. */
23
27
  readonly error?: string;
24
28
  }
25
29
 
26
30
  /** What one `sync()` did. */
27
31
  export interface SyncResult {
28
- /** Queued writes the hub accepted. */
32
+ /** Queued ops that landed. */
29
33
  readonly flushed: number;
30
- /** Queued writes the hub refused (and that were dropped). */
34
+ /** Queued ops that were dropped. */
31
35
  readonly conflicts: SyncConflict[];
32
36
  }
33
37
 
38
+ /** A sync that did nothing. */
39
+ const NOTHING_SYNCED: SyncResult = { flushed: 0, conflicts: [] };
40
+
34
41
  /** Whether a failure means "the hub can't be reached" (rather than a refusal). */
35
42
  // eslint-disable-next-line no-restricted-syntax -- trust boundary: whatever a call threw
36
43
  function isTransportFailure(failure: unknown): boolean {
@@ -51,12 +58,18 @@ export class HubConnection {
51
58
  readonly apiBase: string;
52
59
  /** Typed REST calls to {@link apiBase}. */
53
60
  protected readonly rest: RestClient;
54
- /** This game's offline cache, queue and event buffer. */
61
+ /** This game's offline cache and event buffer. */
55
62
  protected readonly store: LocalStore;
63
+ /** This game's queued operations. */
64
+ protected readonly outbox: Outbox;
56
65
  private readonly offlineMode: NonNullable<HubOptions['offline']>;
57
66
  private isReachable = true;
58
- private isSyncing = false;
67
+ private runningSync: Promise<SyncResult> | null = null;
68
+ private followingSync: Promise<SyncResult> | null = null;
69
+ private isFollowingEager = false;
70
+ private directSend: Promise<Json | undefined> | null = null;
59
71
  private lastPrefetchAt = 0;
72
+ private offlineApi: OfflineApi | undefined;
60
73
  private readonly statusListeners = new Set<(status: HubStatus) => void>();
61
74
 
62
75
  /** A connection for `options` (slug and API base default to the page's). */
@@ -71,17 +84,35 @@ export class HubConnection {
71
84
  this.setReachable(true);
72
85
  },
73
86
  });
74
- this.store = new LocalStore(this.slug ?? 'app');
87
+ const pageStore = createPageStore();
88
+ this.store = new LocalStore(this.slug ?? 'app', pageStore);
89
+ this.outbox = new Outbox({
90
+ slug: this.slug ?? 'app',
91
+ store: pageStore,
92
+ rest: this.rest,
93
+ emit: emitWindowEvent,
94
+ onUnreachable: () => {
95
+ this.setReachable(false);
96
+ },
97
+ onChange: () => {
98
+ this.notifyStatus();
99
+ },
100
+ onRetryDue: () => {
101
+ void this.retrySync();
102
+ },
103
+ });
104
+ registerHubKinds(this.outbox, {
105
+ game: () => this.gameFields(),
106
+ onDailyComplete: (payload, completion) => {
107
+ emitWindowEvent('hub:daily-complete', { day: payload.day, count: completion.count, rank: completion.rank, meta: payload.meta });
108
+ },
109
+ });
110
+ this.importLegacyQueue();
75
111
  if (typeof navigator !== 'undefined' && 'onLine' in navigator) {
76
112
  this.isReachable = navigator.onLine;
77
113
  }
78
114
  if (isBrowserPage()) {
79
- window.addEventListener('online', () => {
80
- this.setReachable(true);
81
- });
82
- window.addEventListener('offline', () => {
83
- this.setReachable(false);
84
- });
115
+ this.listenToPage();
85
116
  }
86
117
  }
87
118
 
@@ -95,9 +126,25 @@ export class HubConnection {
95
126
  return this.isReachable;
96
127
  }
97
128
 
98
- /** Queued writes plus buffered analytics events waiting to sync. */
129
+ /**
130
+ * The offline outbox: define your own kinds of operation, queue them, and
131
+ * hear how they replay (`hub.offline.define`, `enqueue`, `pending`, `on`).
132
+ */
133
+ get offline(): OfflineApi {
134
+ this.offlineApi ??= new OfflineApi({
135
+ outbox: this.outbox,
136
+ requestSync: () => {
137
+ if (!this.isOffline) {
138
+ void this.sync();
139
+ }
140
+ },
141
+ });
142
+ return this.offlineApi;
143
+ }
144
+
145
+ /** Queued operations plus buffered analytics events waiting to sync. */
99
146
  pending(): number {
100
- return this.store.queueLength() + this.store.getEvents().length;
147
+ return this.outbox.count() + this.store.getEvents().length;
101
148
  }
102
149
 
103
150
  /** Subscribe to connection and sync status changes; returns the unsubscribe function. */
@@ -121,7 +168,7 @@ export class HubConnection {
121
168
  emitWindowEvent('hub:status', status);
122
169
  }
123
170
 
124
- /** Record reachability; coming back online syncs the queue and re-warms the cache. */
171
+ /** Record reachability; coming back online replays the outbox and re-warms the cache. */
125
172
  protected setReachable(isReachableNow: boolean): void {
126
173
  if (isReachableNow === this.isReachable) {
127
174
  return;
@@ -130,8 +177,8 @@ export class HubConnection {
130
177
  emitWindowEvent(isReachableNow ? 'hub:online' : 'hub:offline', { pending: this.pending() });
131
178
  this.notifyStatus();
132
179
  if (isReachableNow) {
133
- void this.sync();
134
- void this.prefetchAll().catch(() => undefined);
180
+ // Replay first, so the snapshot that re-warms the cache already has the queued changes.
181
+ void this.replayAll(true).then(() => this.prefetchAll()).catch(() => undefined);
135
182
  }
136
183
  }
137
184
 
@@ -163,98 +210,105 @@ export class HubConnection {
163
210
  }
164
211
  }
165
212
 
166
- /** Send one queued write. */
167
- private async sendOp(op: QueueOpInput): Promise<void> {
168
- const game = this.gameFields();
169
- switch (op.kind) {
170
- case 'putSave':
171
- await this.rest.call('writeSave', { ...game, slot: op.slot, data: op.data, label: op.label });
172
- return;
173
- case 'deleteSave':
174
- await this.rest.call('deleteSave', { ...game, slot: op.slot });
175
- return;
176
- case 'setStat':
177
- await this.rest.call('updateGameStat', { ...game, key: op.key, value: op.value, mode: 'set' });
178
- return;
179
- case 'maxStat':
180
- await this.rest.call('updateGameStat', { ...game, key: op.key, value: op.value, mode: 'max' });
181
- return;
182
- case 'incrStat':
183
- await this.rest.call('updateGameStat', { ...game, key: op.key, value: op.amount, mode: 'incr' });
184
- return;
185
- case 'submitScore':
186
- await this.rest.call('submitScore', { ...game, key: op.key, score: op.score, ...op.opts });
187
- return;
188
- case 'setProfile':
189
- await this.rest.call('setGameProfile', { ...game, ...op.patch });
190
- return;
191
- }
192
- }
193
-
194
213
  /**
195
- * Apply a write to the offline cache at once, then send it now (online
196
- * with an empty queue) or queue it behind the others. Resolves with the
197
- * local answer; a real API error on the direct path reaches the caller and
198
- * is not queued.
214
+ * Apply a write to the offline cache at once, then perform it as an op of
215
+ * `kind`: sent now when online with nothing waiting, else queued behind
216
+ * the others. Resolves with the local answer (or, sent now, with
217
+ * `replyAnswer`'s reading of the hub's reply). A real API error on the
218
+ * direct path reaches the caller and is not queued; a transport failure
219
+ * queues the op under the same id, so if the request did land, its replay
220
+ * is recognised by the hub and not applied twice.
199
221
  */
200
- protected async mutateQueued<Reply>(input: QueueOpInput, localAnswer: () => Reply): Promise<Reply> {
222
+ protected async mutateOffline<Reply>(kind: string, payload: Json, localAnswer: () => Reply, replyAnswer?: (result: Json | undefined) => Reply | undefined): Promise<Reply> {
201
223
  const answer = localAnswer();
202
- if (this.isOffline || this.store.queueLength() > 0) {
203
- this.store.enqueue(input);
204
- this.notifyStatus();
224
+ const op = this.outbox.stamp(kind, payload);
225
+ if (this.isOffline || this.outbox.count() > 0 || this.runningSync !== null || this.directSend !== null) {
226
+ this.outbox.add(op);
205
227
  if (!this.isOffline) {
206
228
  void this.sync();
207
229
  }
208
230
  return answer;
209
231
  }
232
+ const sending = this.outbox.runNow(op);
233
+ this.directSend = sending;
210
234
  try {
211
- await this.sendOp(input);
212
- return answer;
235
+ const result = await sending;
236
+ return replyAnswer?.(result) ?? answer;
213
237
  } catch (failure) {
214
238
  if (!isTransportFailure(failure)) {
215
239
  throw failure;
216
240
  }
217
241
  this.setReachable(false);
218
- this.store.enqueue(input);
219
- this.notifyStatus();
242
+ this.outbox.add({ ...op, attempts: 1 });
220
243
  return answer;
244
+ } finally {
245
+ if (this.directSend === sending) {
246
+ this.directSend = null;
247
+ }
221
248
  }
222
249
  }
223
250
 
224
251
  /**
225
- * Replay the queue, then the buffered events, in order. Stops at the first
226
- * transport failure (offline again: the rest stays queued); a write the hub
227
- * refuses is dropped and reported as a conflict.
252
+ * Replay the outbox, then the buffered events, in order. Stops at the
253
+ * first transport failure (offline again: the rest stays queued); an op
254
+ * the hub refuses is dropped and reported as a conflict.
228
255
  */
229
- async sync(): Promise<SyncResult> {
230
- if (this.isSyncing || this.isOffline || this.slug === null) {
231
- return { flushed: 0, conflicts: [] };
256
+ sync(): Promise<SyncResult> {
257
+ return this.isOffline ? Promise.resolve(NOTHING_SYNCED) : this.replayAll(false);
258
+ }
259
+
260
+ /** A retry came due: try again even when the hub was last unreachable (the try is the probe), unless the browser itself is offline. */
261
+ private retrySync(): Promise<SyncResult> {
262
+ const isBrowserOffline = typeof navigator !== 'undefined' && 'onLine' in navigator && !navigator.onLine;
263
+ return this.offlineMode === 'always' || isBrowserOffline ? Promise.resolve(NOTHING_SYNCED) : this.replayAll(false);
264
+ }
265
+
266
+ /**
267
+ * One replay at a time per client. A call during one queues a single
268
+ * follow-up replay (eager if any caller asked for that), so an op queued
269
+ * or a reconnect that happens mid-replay is never missed.
270
+ */
271
+ private replayAll(isEager: boolean): Promise<SyncResult> {
272
+ if (this.slug === null || this.offlineMode === 'always') {
273
+ return Promise.resolve(NOTHING_SYNCED);
232
274
  }
233
- this.isSyncing = true;
234
- let flushed = 0;
235
- const conflicts: SyncConflict[] = [];
236
- try {
237
- let queue = this.store.getQueue();
238
- for (let op = queue[0]; op !== undefined; op = queue[0]) {
239
- try {
240
- await this.sendOp(op);
241
- flushed += 1;
242
- } catch (failure) {
243
- if (isTransportFailure(failure)) {
244
- break;
245
- }
246
- conflicts.push({ op, error: failure instanceof Error ? failure.message : String(failure) });
247
- }
248
- queue = queue.slice(1);
249
- this.store.setQueue(queue);
275
+ const running = this.runningSync;
276
+ if (running === null) {
277
+ return this.startReplay(isEager);
278
+ }
279
+ this.isFollowingEager ||= isEager;
280
+ this.followingSync ??= running.catch(() => NOTHING_SYNCED).then(() => {
281
+ const isFollowUpEager = this.isFollowingEager;
282
+ this.isFollowingEager = false;
283
+ this.followingSync = null;
284
+ return this.startReplay(isFollowUpEager);
285
+ });
286
+ return this.followingSync;
287
+ }
288
+
289
+ private startReplay(isEager: boolean): Promise<SyncResult> {
290
+ const run = this.runReplay(isEager).finally(() => {
291
+ if (this.runningSync === run) {
292
+ this.runningSync = null;
250
293
  }
294
+ });
295
+ this.runningSync = run;
296
+ return run;
297
+ }
298
+
299
+ private async runReplay(isEager: boolean): Promise<SyncResult> {
300
+ if (this.directSend !== null) {
301
+ await this.directSend.catch(() => undefined);
302
+ }
303
+ const report = await this.outbox.replay(isEager);
304
+ const conflicts = report.failed.map((failure) => ({ op: failure.entry, error: failure.error }));
305
+ if (report.stoppedBy !== 'connection') {
251
306
  await this.flushEvents();
252
- } finally {
253
- this.isSyncing = false;
254
307
  }
255
- emitWindowEvent('hub:sync', { pending: this.pending(), flushed, conflicts });
308
+ const result: SyncResult = { flushed: report.replayed.length, conflicts };
309
+ emitWindowEvent('hub:sync', { pending: this.pending(), ...result });
256
310
  this.notifyStatus();
257
- return { flushed, conflicts };
311
+ return result;
258
312
  }
259
313
 
260
314
  /** Send the buffered analytics events; a refused one is dropped (telemetry never blocks). */
@@ -273,6 +327,43 @@ export class HubConnection {
273
327
  }
274
328
  }
275
329
 
330
+ /** Move a v1 copy's queued writes into the outbox, in their order. */
331
+ private importLegacyQueue(): void {
332
+ if (this.slug === null) {
333
+ return;
334
+ }
335
+ try {
336
+ for (const legacy of this.store.takeLegacyQueue()) {
337
+ const operation = legacyOperation(legacy);
338
+ if (operation !== undefined) {
339
+ this.outbox.add(this.outbox.stamp(operation.kind, operation.payload));
340
+ }
341
+ }
342
+ } catch {
343
+ // A full outbox keeps what fitted; the client must still start.
344
+ }
345
+ }
346
+
347
+ /** Follow the browser's connection, other tabs' outbox changes, and replay what an earlier page left queued. */
348
+ private listenToPage(): void {
349
+ window.addEventListener('online', () => {
350
+ this.setReachable(true);
351
+ });
352
+ window.addEventListener('offline', () => {
353
+ this.setReachable(false);
354
+ });
355
+ window.addEventListener('storage', (event) => {
356
+ if (event instanceof StorageEvent && this.outbox.entries.owns(event.key)) {
357
+ this.notifyStatus();
358
+ }
359
+ });
360
+ if (this.outbox.count() > 0) {
361
+ setTimeout(() => {
362
+ void this.sync();
363
+ }, 0);
364
+ }
365
+ }
366
+
276
367
  /**
277
368
  * Warm the offline cache for every game the player has touched, in one
278
369
  * request, so going offline later still shows complete progress.
@@ -292,6 +383,10 @@ export class HubConnection {
292
383
  throw failure;
293
384
  }
294
385
  this.lastPrefetchAt = Date.now();
386
+ if (this.outbox.count() > 0) {
387
+ // Changes are still waiting to sync: the cache already holds them, and the snapshot doesn't yet.
388
+ return snapshot;
389
+ }
295
390
  this.store.hydrate(snapshot);
296
391
  emitWindowEvent('hub:prefetch', { games: Object.keys(snapshot.games).length });
297
392
  return snapshot;
@@ -6,26 +6,28 @@
6
6
  * v1 client. These two functions are the only places that hand-over happens.
7
7
  */
8
8
  import type { Json } from '@diffenderfer-games/hub-contract';
9
+ import { isJson, parseJsonText } from './storage/json-guards';
9
10
 
10
11
  /** Anything a game may store as JSON: an object (interfaces included), an array, text, a number, a boolean or `null`. */
11
12
  export type GamePayload = object | string | number | boolean | null;
12
13
 
13
- /** Always true: a game's payload is sent as JSON (`JSON.stringify` drops what JSON can't hold). */
14
- function isSentAsJson(_payload: GamePayload): _payload is GamePayload & Json {
15
- return true;
16
- }
17
-
18
14
  /** Always true: JSON the game stored is read back as the game's type. */
19
15
  function isGameOwnData<TData>(_stored: Json): _stored is Json & TData {
20
16
  return true;
21
17
  }
22
18
 
23
- /** A game's payload as the JSON the hub stores. */
19
+ /**
20
+ * A game's payload as the JSON the hub stores: a copy of exactly what
21
+ * `JSON.stringify` sends, so an optional field left `undefined` is simply
22
+ * absent (as on the wire) and the outbox's payload checks accept it, and a
23
+ * later change to the game's object can't alter a queued write.
24
+ */
24
25
  export function toStoredJson(payload: GamePayload): Json {
25
- if (isSentAsJson(payload)) {
26
- return payload;
26
+ const stored = parseJsonText(JSON.stringify(payload));
27
+ if (isJson(stored)) {
28
+ return stored;
27
29
  }
28
- throw new TypeError('unreachable: every payload is sent as JSON');
30
+ throw new TypeError('This value cannot be stored as JSON.');
29
31
  }
30
32
 
31
33
  /** JSON the game stored, typed as the game's own type. */
@@ -4,9 +4,13 @@
4
4
  * offline they are buffered and sent when the hub is back. Method names are
5
5
  * the v1 client's, unchanged.
6
6
  */
7
+ import type { Json } from '@diffenderfer-games/hub-contract';
7
8
  import { HubProgress } from './hub-progress';
8
9
  import { readLocalFavorites, writeLocalFavorites } from './offline/favorites';
10
+ import { ECONOMY_KINDS, type ExchangePayload } from './offline/hub-kinds';
11
+ import { readInventory, withGoods, withoutGoods, type GoodsPayload } from './offline/local-inventory';
9
12
  import { HubError } from './errors';
13
+ import { isRecord, stringsIn } from './storage/json-guards';
10
14
  import type { EndpointFields, EndpointReply } from './rest/endpoint-types';
11
15
  import type { GameAnalytics, GameInfo, GlobalAnalytics, InventorySnapshot } from './types';
12
16
 
@@ -25,6 +29,18 @@ export interface TradeNoteOptions {
25
29
  readonly note?: string;
26
30
  }
27
31
 
32
+ /** Goods as queued: `null` parts left out. */
33
+ function goodsOf(bundle: TradeBundle | GrantBundle | undefined): GoodsPayload {
34
+ const items = bundle?.items ?? undefined;
35
+ const coins = bundle?.coins ?? undefined;
36
+ return { ...(items === undefined ? {} : { items }), ...(coins === undefined ? {} : { coins }) };
37
+ }
38
+
39
+ /** The holdings in a grant or exchange reply, or `undefined` when it doesn't read as one. */
40
+ function inventoryReply(result: Json | undefined): InventorySnapshot | undefined {
41
+ return readInventory(result) ?? undefined;
42
+ }
43
+
28
44
  /** Which trades `listTrades` lists. */
29
45
  export type TradeListOptions = Omit<EndpointFields<'listMyTrades'>, 'slug'>;
30
46
 
@@ -92,11 +108,14 @@ export class HubEconomy extends HubProgress {
92
108
  }, () => ({ games: readLocalFavorites() }));
93
109
  }
94
110
 
95
- /** Star (`isOn = true`) or unstar a game. Needs a connection. */
96
- async setFavorite(slug: string, isOn: boolean): Promise<{ games: string[] }> {
97
- const reply = await this.rest.call('setGameFavorite', { slug, on: isOn });
98
- writeLocalFavorites(reply.games);
99
- return reply;
111
+ /** Star (`isOn = true`) or unstar a game. Offline, the local list changes at once and the hub's when it is back. */
112
+ setFavorite(slug: string, isOn: boolean): Promise<{ games: string[] }> {
113
+ return this.mutateOffline(ECONOMY_KINDS.favorite, { slug, on: isOn }, () => {
114
+ const others = readLocalFavorites().filter((starred) => starred !== slug);
115
+ const games = isOn ? [...others, slug] : others;
116
+ writeLocalFavorites(games);
117
+ return { games };
118
+ }, (result) => (isRecord(result) ? { games: stringsIn(result.games) } : undefined));
100
119
  }
101
120
 
102
121
  /** The player's leaderboard standings, grouped by game (every game, or one via `forSlug`). Empty offline or signed out. */
@@ -109,7 +128,7 @@ export class HubEconomy extends HubProgress {
109
128
 
110
129
  /** This game's inventory and coin balance for the current user. */
111
130
  getInventory(): Promise<InventorySnapshot> {
112
- return this.withFallback(() => this.rest.call('getMyInventory', this.gameFields()), () => ({ items: [], coins: 0 }));
131
+ return this.withFallback(async () => this.store.setInventory(await this.rest.call('getMyInventory', this.gameFields())), () => this.store.getInventory());
113
132
  }
114
133
 
115
134
  /** Another player's available holdings (to compose a trade). */
@@ -117,14 +136,32 @@ export class HubEconomy extends HubProgress {
117
136
  return this.withFallback(() => this.rest.call('getPlayerInventory', { ...this.gameFields(), userId }), () => ({ items: [], coins: 0 }));
118
137
  }
119
138
 
120
- /** Add goods to the player (gameplay rewards); answers the new holdings. */
139
+ /**
140
+ * Add goods to the player (gameplay rewards); answers the new holdings.
141
+ * Offline, the cached holdings change at once and the grant replays later.
142
+ */
121
143
  grant(bundle: GrantBundle): Promise<EndpointReply<'grantInventoryItems'>> {
122
- return this.rest.call('grantInventoryItems', { ...this.gameFields(), ...bundle });
144
+ const goods = goodsOf(bundle);
145
+ return this.mutateOffline(ECONOMY_KINDS.grant, goods, () => this.store.setInventory(withGoods(this.store.getInventory(), goods)), inventoryReply);
123
146
  }
124
147
 
125
- /** An atomic swap with the house: remove `take`, add `give`. */
148
+ /**
149
+ * An atomic swap with the house: remove `take`, add `give`. Offline it is
150
+ * checked against the cached holdings (409 when they can't cover `take`),
151
+ * applied to them at once, and replayed later; the hub has the last word.
152
+ */
126
153
  exchange(take?: TradeBundle, give?: TradeBundle): Promise<EndpointReply<'exchangeInventoryItems'>> {
127
- return this.rest.call('exchangeInventoryItems', { ...this.gameFields(), ...(take === undefined ? {} : { take }), ...(give === undefined ? {} : { give }) });
154
+ const payload: ExchangePayload = { ...(take === undefined ? {} : { take: goodsOf(take) }), ...(give === undefined ? {} : { give: goodsOf(give) }) };
155
+ return this.mutateOffline(ECONOMY_KINDS.exchange, payload, () => {
156
+ const remaining = withoutGoods(this.store.getInventory(), payload.take ?? {});
157
+ if (typeof remaining === 'string') {
158
+ if (this.isOffline) {
159
+ throw new HubError(remaining, 409);
160
+ }
161
+ return this.store.getInventory();
162
+ }
163
+ return this.store.setInventory(withGoods(remaining, payload.give ?? {}));
164
+ }, inventoryReply);
128
165
  }
129
166
 
130
167
  /** Sell items to the house for coins. */
@@ -7,7 +7,7 @@
7
7
  import type { Board, Json, Stats } from '@diffenderfer-games/hub-contract';
8
8
  import { HubConnection } from './connection';
9
9
  import { toGameData, toStoredJson, type GamePayload } from './game-data';
10
- import type { ProfilePatch, QueuedScoreOptions } from './offline/queue';
10
+ import { PROGRESS_KINDS, type ProfilePayload, type ScorePayload } from './offline/hub-kinds';
11
11
  import type { LeaderboardResult, MeResponse, Profile, Save, SaveMeta, SubmitScoreOptions, SubmitScoreResult, User } from './types';
12
12
 
13
13
  /** A signed-in user, as the auth calls answer. */
@@ -111,14 +111,11 @@ export class HubProgress extends HubConnection {
111
111
 
112
112
  /** Create or update this game's profile for the current user. */
113
113
  setProfile<TData extends GamePayload = Json>(patch: ProfileUpdate<TData>): Promise<{ profile: Profile<TData> }> {
114
- const queued: ProfilePatch = {};
115
- if (patch.displayName !== undefined) {
116
- queued.displayName = patch.displayName;
117
- }
118
- if (patch.data !== undefined) {
119
- queued.data = toStoredJson(patch.data);
120
- }
121
- return this.mutateQueued({ kind: 'setProfile', patch: queued }, () => {
114
+ const queued: ProfilePayload = {
115
+ ...(patch.displayName === undefined ? {} : { displayName: patch.displayName }),
116
+ ...(patch.data === undefined ? {} : { data: toStoredJson(patch.data) }),
117
+ };
118
+ return this.mutateOffline(PROGRESS_KINDS.profile, queued, () => {
122
119
  const stored = this.store.setProfile(this.slug ?? 'app', patch.displayName ?? null, queued.data ?? {});
123
120
  return { profile: { ...stored, data: toGameData<TData>(stored.data) } };
124
121
  });
@@ -141,12 +138,12 @@ export class HubProgress extends HubConnection {
141
138
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- kept so v1 calls such as putSave<MySave>(...) still compile
142
139
  putSave<TData extends GamePayload = Json>(slot: string, saveData: TData, label?: string): Promise<{ ok: true; updatedAt: number }> {
143
140
  const stored = toStoredJson(saveData);
144
- return this.mutateQueued({ kind: 'putSave', slot, data: stored, label: label ?? null }, () => ({ ok: true as const, updatedAt: this.store.putSave(slot, stored, label ?? null) }));
141
+ return this.mutateOffline(PROGRESS_KINDS.save, { slot, data: stored, label: label ?? null }, () => ({ ok: true as const, updatedAt: this.store.putSave(slot, stored, label ?? null) }));
145
142
  }
146
143
 
147
144
  /** Delete a save slot. */
148
145
  deleteSave(slot: string): Promise<{ ok: true }> {
149
- return this.mutateQueued({ kind: 'deleteSave', slot }, () => {
146
+ return this.mutateOffline(PROGRESS_KINDS.save, { slot, deleted: true }, () => {
150
147
  this.store.deleteSave(slot);
151
148
  return { ok: true as const };
152
149
  });
@@ -162,27 +159,27 @@ export class HubProgress extends HubConnection {
162
159
 
163
160
  /** Overwrite a stat. */
164
161
  setStat(key: string, value: number): Promise<StatResult> {
165
- return this.mutateQueued({ kind: 'setStat', key, value }, () => ({ key, value: this.store.updateStat(key, value, 'set') }));
162
+ return this.mutateOffline(PROGRESS_KINDS.stat, { key, mode: 'set', value }, () => ({ key, value: this.store.updateStat(key, value, 'set') }));
166
163
  }
167
164
 
168
165
  /** Add to a stat (creating it at `amount` when absent). */
169
166
  incrStat(key: string, amount = 1): Promise<StatResult> {
170
- return this.mutateQueued({ kind: 'incrStat', key, amount }, () => ({ key, value: this.store.updateStat(key, amount, 'incr') }));
167
+ return this.mutateOffline(PROGRESS_KINDS.stat, { key, mode: 'incr', value: amount }, () => ({ key, value: this.store.updateStat(key, amount, 'incr') }));
171
168
  }
172
169
 
173
170
  /** Keep the larger of the stored and the given value. */
174
171
  maxStat(key: string, value: number): Promise<StatResult> {
175
- return this.mutateQueued({ kind: 'maxStat', key, value }, () => ({ key, value: this.store.updateStat(key, value, 'max') }));
172
+ return this.mutateOffline(PROGRESS_KINDS.stat, { key, mode: 'max', value }, () => ({ key, value: this.store.updateStat(key, value, 'max') }));
176
173
  }
177
174
 
178
175
  /** Submit a score. The board is created on first use; only a player's best score sticks. */
179
176
  submitScore<TMeta extends GamePayload = Json>(boardKey: string, score: number, opts: SubmitScoreOptions<TMeta> = {}): Promise<SubmitScoreResult> {
180
- const queued: QueuedScoreOptions = {
177
+ const queued: ScorePayload['opts'] = {
181
178
  ...(opts.title === undefined ? {} : { title: opts.title }),
182
179
  ...(opts.sort === undefined ? {} : { sort: opts.sort }),
183
180
  ...(opts.meta === undefined ? {} : { meta: toStoredJson(opts.meta) }),
184
181
  };
185
- return this.mutateQueued({ kind: 'submitScore', key: boardKey, score, opts: queued }, () => ({ ...this.store.submitScore(boardKey, score, queued), rank: 1 }));
182
+ return this.mutateOffline(PROGRESS_KINDS.score, { key: boardKey, score, opts: queued }, () => ({ ...this.store.submitScore(boardKey, score, queued), rank: 1 }));
186
183
  }
187
184
 
188
185
  /** One board's ranked entries (top `limit`, default 50). Offline: only the player's own best. */
package/src/core/hub.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * `hub.race`, ...), so v1 code such as `hub.input.define(...)` works unchanged.
6
6
  */
7
7
  import type { HubVersion } from '@diffenderfer-games/hub-contract';
8
- import { getDaily, type DailyHost, type DailySystem } from '../daily';
8
+ import { getDaily, type DailyCompleteResponse, type DailyHost, type DailySystem } from '../daily';
9
9
  import { getInput, getUiStack, type InputSystem } from '../input';
10
10
  import { mp } from '../mp';
11
11
  import { race } from '../race';
@@ -13,6 +13,8 @@ import { rooms } from '../rooms';
13
13
  import { linkRealtimeToHub, notify, presence, social } from '../social';
14
14
  import { OfflineError, toHubError } from './errors';
15
15
  import { HubEconomy } from './hub-economy';
16
+ import { DAILY_KIND } from './offline/hub-kinds';
17
+ import { readDailyCompletion } from './offline/hub-kinds/daily-kind';
16
18
  import { hubMenu, type HubMenuControl } from './menu-control';
17
19
  import { emitWindowEvent } from './page';
18
20
  import { fetchGameServerTicket } from './ticket';
@@ -108,7 +110,7 @@ export class Hub extends HubEconomy {
108
110
  status: () => this.rest.call('getGameDailyChallenges', this.gameFields()),
109
111
  all: () => this.rest.call('listDailyChallengesForAllGames'),
110
112
  start: (day) => this.rest.call('startDailyChallenge', { ...this.gameFields(), day }),
111
- complete: (day, token, meta) => this.rest.call('completeDailyChallenge', { ...this.gameFields(), day, token, meta }),
113
+ complete: (day, token, meta) => this.mutateOffline<DailyCompleteResponse | null>(DAILY_KIND, { day, token, meta }, () => null, (result) => readDailyCompletion(result)),
112
114
  emit: (eventName, detail) => {
113
115
  emitWindowEvent(eventName, detail);
114
116
  },