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

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 (245) hide show
  1. package/CHANGELOG.md +160 -6
  2. package/README.md +27 -15
  3. package/dist/auto.d.ts +8 -8
  4. package/dist/auto.js +3 -3
  5. package/dist/avatar-pixi.d.ts +165 -0
  6. package/dist/avatar-pixi.d.ts.map +1 -0
  7. package/dist/avatar-pixi.js +299 -0
  8. package/dist/avatar-pixi.js.map +1 -0
  9. package/dist/avatar-svg.d.ts +4 -0
  10. package/dist/avatar-svg.js +5 -0
  11. package/dist/avatar-three.d.ts +69 -0
  12. package/dist/avatar-three.d.ts.map +1 -0
  13. package/dist/avatar-three.js +49 -0
  14. package/dist/avatar-three.js.map +1 -0
  15. package/dist/avatar.d.ts +7 -0
  16. package/dist/avatar.js +500 -0
  17. package/dist/avatar.js.map +1 -0
  18. package/dist/daily.d.ts +2 -2
  19. package/dist/doctor/cli.mjs +71 -10
  20. package/dist/doctor/cli.mjs.map +1 -1
  21. package/dist/index.d.ts +8 -8
  22. package/dist/index.js +3 -3
  23. package/dist/input.d.ts +2 -2
  24. package/dist/input.js +2 -2
  25. package/dist/mp.d.ts +3 -3
  26. package/dist/race.d.ts +1 -1
  27. package/dist/rooms.d.ts +3 -3
  28. package/dist/rooms.js +1 -1
  29. package/dist/shared/animator.d.ts +104 -0
  30. package/dist/shared/animator.d.ts.map +1 -0
  31. package/dist/shared/art.js +1946 -0
  32. package/dist/shared/art.js.map +1 -0
  33. package/dist/shared/atlas.d.ts +138 -0
  34. package/dist/shared/atlas.d.ts.map +1 -0
  35. package/dist/shared/avatar.d.ts +123 -0
  36. package/dist/shared/avatar.d.ts.map +1 -0
  37. package/dist/shared/bridge.js +18 -4
  38. package/dist/shared/bridge.js.map +1 -1
  39. package/dist/shared/client-version.gen.js +1 -1
  40. package/dist/shared/daily.d.ts +145 -19
  41. package/dist/shared/daily.d.ts.map +1 -1
  42. package/dist/shared/daily.js +138 -25
  43. package/dist/shared/daily.js.map +1 -1
  44. package/dist/shared/defineProperty.js +39 -0
  45. package/dist/shared/describe.d.ts +32 -0
  46. package/dist/shared/describe.d.ts.map +1 -0
  47. package/dist/shared/describe.js +109 -0
  48. package/dist/shared/describe.js.map +1 -0
  49. package/dist/shared/globals.js +1 -39
  50. package/dist/shared/globals.js.map +1 -1
  51. package/dist/shared/index.d.ts +1122 -46
  52. package/dist/shared/index.d.ts.map +1 -1
  53. package/dist/shared/input.d.ts +74 -14
  54. package/dist/shared/input.d.ts.map +1 -1
  55. package/dist/shared/input.js +179 -25
  56. package/dist/shared/input.js.map +1 -1
  57. package/dist/shared/json-guards.js +48 -0
  58. package/dist/shared/json-guards.js.map +1 -0
  59. package/dist/shared/latest-state.js +1 -1
  60. package/dist/shared/live-canvas.js +62 -0
  61. package/dist/shared/live-canvas.js.map +1 -0
  62. package/dist/shared/mp.js +10 -2
  63. package/dist/shared/mp.js.map +1 -1
  64. package/dist/shared/multiplayer-types.d.ts +15 -2
  65. package/dist/shared/multiplayer-types.d.ts.map +1 -1
  66. package/dist/shared/overlays.js +800 -0
  67. package/dist/shared/overlays.js.map +1 -0
  68. package/dist/shared/page-stack.js +2 -1
  69. package/dist/shared/page-stack.js.map +1 -1
  70. package/dist/shared/pose-key.js +249 -0
  71. package/dist/shared/pose-key.js.map +1 -0
  72. package/dist/shared/race-types.d.ts +1 -1
  73. package/dist/shared/race.js +1 -1
  74. package/dist/shared/room-types.d.ts +28 -3
  75. package/dist/shared/room-types.d.ts.map +1 -1
  76. package/dist/shared/rooms.d.ts.map +1 -1
  77. package/dist/shared/rooms.js +23 -4
  78. package/dist/shared/rooms.js.map +1 -1
  79. package/dist/shared/runtime-opener.js +1 -1
  80. package/dist/shared/scene.d.ts +90 -0
  81. package/dist/shared/scene.d.ts.map +1 -0
  82. package/dist/shared/social.d.ts +90 -9
  83. package/dist/shared/social.d.ts.map +1 -1
  84. package/dist/shared/social.js +6 -1
  85. package/dist/shared/social.js.map +1 -1
  86. package/dist/shared/src.js +4563 -667
  87. package/dist/shared/src.js.map +1 -1
  88. package/dist/shared/svg-markup.js +2354 -0
  89. package/dist/shared/svg-markup.js.map +1 -0
  90. package/dist/shared/svg.js +27 -0
  91. package/dist/shared/svg.js.map +1 -0
  92. package/dist/shared/types.d.ts +174 -0
  93. package/dist/shared/types.d.ts.map +1 -0
  94. package/dist/shared/wire-types.d.ts +6 -2
  95. package/dist/shared/wire-types.d.ts.map +1 -1
  96. package/dist/social.d.ts +5 -5
  97. package/dist/social.js +2 -2
  98. package/dist/turns-rules.d.ts +2 -0
  99. package/dist/turns-rules.js +1 -0
  100. package/dist/turns.d.ts +61 -0
  101. package/dist/turns.d.ts.map +1 -0
  102. package/dist/turns.js +183 -0
  103. package/dist/turns.js.map +1 -0
  104. package/package.json +39 -3
  105. package/src/avatar/adapters/pixi-avatar.ts +73 -0
  106. package/src/avatar/adapters/pixi-crowd.ts +329 -0
  107. package/src/avatar/adapters/pixi-types.ts +84 -0
  108. package/src/avatar/adapters/three.ts +126 -0
  109. package/src/avatar/animator/animator.ts +285 -0
  110. package/src/avatar/animator/reactions.ts +44 -0
  111. package/src/avatar/animator/schedule.ts +73 -0
  112. package/src/avatar/art/beard.ts +49 -0
  113. package/src/avatar/art/glasses.ts +67 -0
  114. package/src/avatar/art/hair.ts +254 -0
  115. package/src/avatar/art/headwear.ts +75 -0
  116. package/src/avatar/art/index.ts +21 -0
  117. package/src/avatar/art/shape.ts +87 -0
  118. package/src/avatar/art-store.ts +36 -0
  119. package/src/avatar/atlas.ts +246 -0
  120. package/src/avatar/catalog-view.ts +14 -0
  121. package/src/avatar/colour.ts +109 -0
  122. package/src/avatar/describe.ts +79 -0
  123. package/src/avatar/face.ts +187 -0
  124. package/src/avatar/geom.ts +173 -0
  125. package/src/avatar/layout.ts +47 -0
  126. package/src/avatar/look-like.ts +52 -0
  127. package/src/avatar/outputs/bitmap.ts +75 -0
  128. package/src/avatar/outputs/canvas-paint.ts +177 -0
  129. package/src/avatar/outputs/canvas.ts +62 -0
  130. package/src/avatar/outputs/custom-element.ts +140 -0
  131. package/src/avatar/outputs/element.ts +58 -0
  132. package/src/avatar/outputs/live-canvas.ts +57 -0
  133. package/src/avatar/outputs/overlay-svg.ts +22 -0
  134. package/src/avatar/outputs/pose-key.ts +30 -0
  135. package/src/avatar/outputs/svg-markup.ts +113 -0
  136. package/src/avatar/outputs/svg.ts +41 -0
  137. package/src/avatar/overlays.ts +128 -0
  138. package/src/avatar/render/body.ts +116 -0
  139. package/src/avatar/render/colours.ts +81 -0
  140. package/src/avatar/render/compose.ts +320 -0
  141. package/src/avatar/render/context.ts +117 -0
  142. package/src/avatar/render/eyes.ts +78 -0
  143. package/src/avatar/render/eyewear.ts +118 -0
  144. package/src/avatar/render/frame.ts +73 -0
  145. package/src/avatar/render/head.ts +81 -0
  146. package/src/avatar/render/mouth.ts +86 -0
  147. package/src/avatar/render/props.ts +91 -0
  148. package/src/avatar/render/types.ts +93 -0
  149. package/src/avatar/render/walker-parts.ts +121 -0
  150. package/src/avatar/render/walker.ts +199 -0
  151. package/src/avatar/scene.ts +119 -0
  152. package/src/avatar-pixi.ts +20 -0
  153. package/src/avatar-svg.ts +21 -0
  154. package/src/avatar-three.ts +18 -0
  155. package/src/avatar.ts +63 -0
  156. package/src/core/avatar/avatar-cache.ts +95 -0
  157. package/src/core/avatar/avatar-ref.ts +34 -0
  158. package/src/core/avatar/avatar-types.ts +43 -0
  159. package/src/core/avatar/hub-avatar.ts +308 -0
  160. package/src/core/connection.ts +201 -92
  161. package/src/core/errors.ts +15 -2
  162. package/src/core/game-data.ts +11 -9
  163. package/src/core/hub-economy.ts +47 -10
  164. package/src/core/hub-progress.ts +94 -30
  165. package/src/core/hub.ts +228 -7
  166. package/src/core/offline/cloud-saves.ts +62 -0
  167. package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
  168. package/src/core/offline/hub-kinds/daily-kind.ts +68 -0
  169. package/src/core/offline/hub-kinds/economy-kinds.ts +86 -0
  170. package/src/core/offline/hub-kinds/game-fields.ts +2 -0
  171. package/src/core/offline/hub-kinds/index.ts +52 -0
  172. package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
  173. package/src/core/offline/hub-kinds/progress-kinds.ts +165 -0
  174. package/src/core/offline/hub-kinds/resume-kind.ts +108 -0
  175. package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
  176. package/src/core/offline/kind-adapters.ts +48 -0
  177. package/src/core/offline/local-inventory.ts +64 -0
  178. package/src/core/offline/local-store.ts +66 -23
  179. package/src/core/offline/offline-api.ts +65 -0
  180. package/src/core/offline/outbox-entry.ts +59 -0
  181. package/src/core/offline/outbox-kinds.ts +78 -0
  182. package/src/core/offline/outbox-merge.ts +39 -0
  183. package/src/core/offline/outbox-replayer.ts +141 -0
  184. package/src/core/offline/outbox-store.ts +85 -0
  185. package/src/core/offline/outbox.ts +232 -0
  186. package/src/core/offline/queue.ts +4 -49
  187. package/src/core/offline/replay-lease.ts +87 -0
  188. package/src/core/offline/replay-policy.ts +52 -0
  189. package/src/core/page-account.ts +121 -0
  190. package/src/core/page.ts +14 -1
  191. package/src/core/rest/rest-client.ts +14 -3
  192. package/src/core/rest/routes.gen.ts +144 -88
  193. package/src/core/resume/hub-resume.ts +286 -0
  194. package/src/core/resume/local-resume.ts +130 -0
  195. package/src/core/resume/resume-list.ts +65 -0
  196. package/src/core/resume/resume-request.ts +52 -0
  197. package/src/core/resume/resume-types.ts +64 -0
  198. package/src/core/saved/daily-saves.ts +196 -0
  199. package/src/core/saved/daily-seed.ts +16 -0
  200. package/src/core/saved/hub-saved.ts +221 -0
  201. package/src/core/saved/local-saved.ts +122 -0
  202. package/src/core/saved/saved-list.ts +129 -0
  203. package/src/core/saved/saved-open.ts +104 -0
  204. package/src/core/saved/saved-order.ts +49 -0
  205. package/src/core/saved/saved-types.ts +24 -0
  206. package/src/core/storage/json-guards.ts +6 -0
  207. package/src/core/turns/hub-turns.ts +161 -0
  208. package/src/core/turns/turn-game.ts +510 -0
  209. package/src/core/turns/your-turn-buzz.ts +69 -0
  210. package/src/core/version/client-version.gen.ts +2 -2
  211. package/src/daily/daily-system.ts +127 -12
  212. package/src/daily/page-daily.ts +2 -2
  213. package/src/daily/types.ts +49 -11
  214. package/src/doctor/checks.ts +12 -4
  215. package/src/doctor/cli.ts +5 -2
  216. package/src/doctor/hub-version-fetch.ts +43 -43
  217. package/src/doctor/world-check.ts +61 -0
  218. package/src/index.ts +20 -2
  219. package/src/input/focus-ring.ts +13 -0
  220. package/src/input/input-registry.ts +9 -2
  221. package/src/input/input-system.ts +14 -1
  222. package/src/input/modality.ts +122 -0
  223. package/src/input/nav-state.ts +13 -4
  224. package/src/input/navigator.ts +11 -0
  225. package/src/input/zero-config.ts +1 -14
  226. package/src/input.ts +4 -0
  227. package/src/mp.ts +1 -1
  228. package/src/overlay/overlay.ts +30 -4
  229. package/src/overlay/styles.ts +4 -3
  230. package/src/realtime/bridge.ts +5 -0
  231. package/src/realtime/multiplayer-forwarder.ts +13 -1
  232. package/src/realtime/multiplayer-types.ts +15 -1
  233. package/src/realtime/room-types.ts +28 -2
  234. package/src/realtime/rooms-forwarder.ts +27 -2
  235. package/src/realtime/runtime-api.ts +33 -8
  236. package/src/realtime/social-forwarder.ts +5 -0
  237. package/src/realtime/social-types.ts +8 -0
  238. package/src/realtime/types.ts +1 -0
  239. package/src/realtime/wire-types.ts +7 -0
  240. package/src/realtime/world-types.ts +20 -0
  241. package/src/rooms.ts +1 -1
  242. package/src/social.ts +1 -0
  243. package/src/turns-kit/check-rules.ts +281 -0
  244. package/src/turns-rules.ts +12 -0
  245. package/src/turns.ts +14 -0
@@ -1,36 +1,45 @@
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';
14
+ import { LocalResumeStore } from './resume/local-resume';
15
+ import { LocalDailyStore } from './saved/local-saved';
11
16
  import { absoluteApiBase, detectApiBase, detectSlug, emitWindowEvent, isBrowserPage } from './page';
12
17
  import { createRestClient, type RestClient } from './rest/rest-client';
18
+ import { createPageStore } from './storage/key-value';
13
19
  import type { HubOptions, HubStatus, SnapshotResponse } from './types';
14
20
 
15
21
  /** How long `prefetchAll()` waits between snapshots unless forced. */
16
22
  const PREFETCH_INTERVAL_MS = 30_000;
17
23
 
18
- /** A queued write the hub refused during a sync: dropped, with the hub's message. */
24
+ /** A queued op the hub refused during a sync (or that kept failing): dropped, with the reason. */
19
25
  export interface SyncConflict {
20
- /** The refused op. */
21
- readonly op: QueueOp;
22
- /** The hub's message. */
26
+ /** The dropped op. */
27
+ readonly op: OfflineOp;
28
+ /** The hub's (or the handler's) message. */
23
29
  readonly error?: string;
24
30
  }
25
31
 
26
32
  /** What one `sync()` did. */
27
33
  export interface SyncResult {
28
- /** Queued writes the hub accepted. */
34
+ /** Queued ops that landed. */
29
35
  readonly flushed: number;
30
- /** Queued writes the hub refused (and that were dropped). */
36
+ /** Queued ops that were dropped. */
31
37
  readonly conflicts: SyncConflict[];
32
38
  }
33
39
 
40
+ /** A sync that did nothing. */
41
+ const NOTHING_SYNCED: SyncResult = { flushed: 0, conflicts: [] };
42
+
34
43
  /** Whether a failure means "the hub can't be reached" (rather than a refusal). */
35
44
  // eslint-disable-next-line no-restricted-syntax -- trust boundary: whatever a call threw
36
45
  function isTransportFailure(failure: unknown): boolean {
@@ -51,12 +60,22 @@ export class HubConnection {
51
60
  readonly apiBase: string;
52
61
  /** Typed REST calls to {@link apiBase}. */
53
62
  protected readonly rest: RestClient;
54
- /** This game's offline cache, queue and event buffer. */
63
+ /** This game's offline cache and event buffer. */
55
64
  protected readonly store: LocalStore;
65
+ /** This game's queued operations. */
66
+ protected readonly outbox: Outbox;
67
+ /** The device copies of every game's resume slot (`hub.resume`). */
68
+ protected readonly resumeStore: LocalResumeStore;
69
+ /** The device copies of daily challenges in progress (every game's). */
70
+ protected readonly dailyStore: LocalDailyStore;
56
71
  private readonly offlineMode: NonNullable<HubOptions['offline']>;
57
72
  private isReachable = true;
58
- private isSyncing = false;
73
+ private runningSync: Promise<SyncResult> | null = null;
74
+ private followingSync: Promise<SyncResult> | null = null;
75
+ private isFollowingEager = false;
76
+ private directSend: Promise<Json | undefined> | null = null;
59
77
  private lastPrefetchAt = 0;
78
+ private offlineApi: OfflineApi | undefined;
60
79
  private readonly statusListeners = new Set<(status: HubStatus) => void>();
61
80
 
62
81
  /** A connection for `options` (slug and API base default to the page's). */
@@ -71,17 +90,43 @@ export class HubConnection {
71
90
  this.setReachable(true);
72
91
  },
73
92
  });
74
- this.store = new LocalStore(this.slug ?? 'app');
93
+ const pageStore = createPageStore();
94
+ this.store = new LocalStore(this.slug ?? 'app', pageStore);
95
+ this.resumeStore = new LocalResumeStore(pageStore);
96
+ this.dailyStore = new LocalDailyStore(pageStore);
97
+ this.outbox = new Outbox({
98
+ slug: this.slug ?? 'app',
99
+ store: pageStore,
100
+ rest: this.rest,
101
+ emit: emitWindowEvent,
102
+ onUnreachable: () => {
103
+ this.setReachable(false);
104
+ },
105
+ onChange: () => {
106
+ this.notifyStatus();
107
+ },
108
+ onRetryDue: () => {
109
+ void this.retrySync();
110
+ },
111
+ });
112
+ registerHubKinds(this.outbox, {
113
+ game: () => this.gameFields(),
114
+ onDailyComplete: (payload, completion) => {
115
+ if (this.slug !== null) {
116
+ this.dailyStore.remove(this.slug, payload.day);
117
+ }
118
+ emitWindowEvent('hub:daily-complete', { day: payload.day, count: completion.count, rank: completion.rank, meta: payload.meta });
119
+ },
120
+ resume: this.resumeStore,
121
+ dailies: this.dailyStore,
122
+ cloudSaves: this.store,
123
+ });
124
+ this.importLegacyQueue();
75
125
  if (typeof navigator !== 'undefined' && 'onLine' in navigator) {
76
126
  this.isReachable = navigator.onLine;
77
127
  }
78
128
  if (isBrowserPage()) {
79
- window.addEventListener('online', () => {
80
- this.setReachable(true);
81
- });
82
- window.addEventListener('offline', () => {
83
- this.setReachable(false);
84
- });
129
+ this.listenToPage();
85
130
  }
86
131
  }
87
132
 
@@ -95,9 +140,25 @@ export class HubConnection {
95
140
  return this.isReachable;
96
141
  }
97
142
 
98
- /** Queued writes plus buffered analytics events waiting to sync. */
143
+ /**
144
+ * The offline outbox: define your own kinds of operation, queue them, and
145
+ * hear how they replay (`hub.offline.define`, `enqueue`, `pending`, `on`).
146
+ */
147
+ get offline(): OfflineApi {
148
+ this.offlineApi ??= new OfflineApi({
149
+ outbox: this.outbox,
150
+ requestSync: () => {
151
+ if (!this.isOffline) {
152
+ void this.sync();
153
+ }
154
+ },
155
+ });
156
+ return this.offlineApi;
157
+ }
158
+
159
+ /** Queued operations plus buffered analytics events waiting to sync. */
99
160
  pending(): number {
100
- return this.store.queueLength() + this.store.getEvents().length;
161
+ return this.outbox.count() + this.store.getEvents().length;
101
162
  }
102
163
 
103
164
  /** Subscribe to connection and sync status changes; returns the unsubscribe function. */
@@ -121,7 +182,7 @@ export class HubConnection {
121
182
  emitWindowEvent('hub:status', status);
122
183
  }
123
184
 
124
- /** Record reachability; coming back online syncs the queue and re-warms the cache. */
185
+ /** Record reachability; coming back online replays the outbox and re-warms the cache. */
125
186
  protected setReachable(isReachableNow: boolean): void {
126
187
  if (isReachableNow === this.isReachable) {
127
188
  return;
@@ -130,8 +191,8 @@ export class HubConnection {
130
191
  emitWindowEvent(isReachableNow ? 'hub:online' : 'hub:offline', { pending: this.pending() });
131
192
  this.notifyStatus();
132
193
  if (isReachableNow) {
133
- void this.sync();
134
- void this.prefetchAll().catch(() => undefined);
194
+ // Replay first, so the snapshot that re-warms the cache already has the queued changes.
195
+ void this.replayAll(true).then(() => this.prefetchAll()).catch(() => undefined);
135
196
  }
136
197
  }
137
198
 
@@ -163,98 +224,105 @@ export class HubConnection {
163
224
  }
164
225
  }
165
226
 
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
227
  /**
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.
228
+ * Apply a write to the offline cache at once, then perform it as an op of
229
+ * `kind`: sent now when online with nothing waiting, else queued behind
230
+ * the others. Resolves with the local answer (or, sent now, with
231
+ * `replyAnswer`'s reading of the hub's reply). A real API error on the
232
+ * direct path reaches the caller and is not queued; a transport failure
233
+ * queues the op under the same id, so if the request did land, its replay
234
+ * is recognised by the hub and not applied twice.
199
235
  */
200
- protected async mutateQueued<Reply>(input: QueueOpInput, localAnswer: () => Reply): Promise<Reply> {
236
+ protected async mutateOffline<Reply>(kind: string, payload: Json, localAnswer: () => Reply, replyAnswer?: (result: Json | undefined) => Reply | undefined): Promise<Reply> {
201
237
  const answer = localAnswer();
202
- if (this.isOffline || this.store.queueLength() > 0) {
203
- this.store.enqueue(input);
204
- this.notifyStatus();
238
+ const op = this.outbox.stamp(kind, payload);
239
+ if (this.isOffline || this.outbox.count() > 0 || this.runningSync !== null || this.directSend !== null) {
240
+ this.outbox.add(op);
205
241
  if (!this.isOffline) {
206
242
  void this.sync();
207
243
  }
208
244
  return answer;
209
245
  }
246
+ const sending = this.outbox.runNow(op);
247
+ this.directSend = sending;
210
248
  try {
211
- await this.sendOp(input);
212
- return answer;
249
+ const result = await sending;
250
+ return replyAnswer?.(result) ?? answer;
213
251
  } catch (failure) {
214
252
  if (!isTransportFailure(failure)) {
215
253
  throw failure;
216
254
  }
217
255
  this.setReachable(false);
218
- this.store.enqueue(input);
219
- this.notifyStatus();
256
+ this.outbox.add({ ...op, attempts: 1 });
220
257
  return answer;
258
+ } finally {
259
+ if (this.directSend === sending) {
260
+ this.directSend = null;
261
+ }
221
262
  }
222
263
  }
223
264
 
224
265
  /**
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.
266
+ * Replay the outbox, then the buffered events, in order. Stops at the
267
+ * first transport failure (offline again: the rest stays queued); an op
268
+ * the hub refuses is dropped and reported as a conflict.
269
+ */
270
+ sync(): Promise<SyncResult> {
271
+ return this.isOffline ? Promise.resolve(NOTHING_SYNCED) : this.replayAll(false);
272
+ }
273
+
274
+ /** A retry came due: try again even when the hub was last unreachable (the try is the probe), unless the browser itself is offline. */
275
+ private retrySync(): Promise<SyncResult> {
276
+ const isBrowserOffline = typeof navigator !== 'undefined' && 'onLine' in navigator && !navigator.onLine;
277
+ return this.offlineMode === 'always' || isBrowserOffline ? Promise.resolve(NOTHING_SYNCED) : this.replayAll(false);
278
+ }
279
+
280
+ /**
281
+ * One replay at a time per client. A call during one queues a single
282
+ * follow-up replay (eager if any caller asked for that), so an op queued
283
+ * or a reconnect that happens mid-replay is never missed.
228
284
  */
229
- async sync(): Promise<SyncResult> {
230
- if (this.isSyncing || this.isOffline || this.slug === null) {
231
- return { flushed: 0, conflicts: [] };
285
+ private replayAll(isEager: boolean): Promise<SyncResult> {
286
+ if (this.slug === null || this.offlineMode === 'always') {
287
+ return Promise.resolve(NOTHING_SYNCED);
232
288
  }
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);
289
+ const running = this.runningSync;
290
+ if (running === null) {
291
+ return this.startReplay(isEager);
292
+ }
293
+ this.isFollowingEager ||= isEager;
294
+ this.followingSync ??= running.catch(() => NOTHING_SYNCED).then(() => {
295
+ const isFollowUpEager = this.isFollowingEager;
296
+ this.isFollowingEager = false;
297
+ this.followingSync = null;
298
+ return this.startReplay(isFollowUpEager);
299
+ });
300
+ return this.followingSync;
301
+ }
302
+
303
+ private startReplay(isEager: boolean): Promise<SyncResult> {
304
+ const run = this.runReplay(isEager).finally(() => {
305
+ if (this.runningSync === run) {
306
+ this.runningSync = null;
250
307
  }
308
+ });
309
+ this.runningSync = run;
310
+ return run;
311
+ }
312
+
313
+ private async runReplay(isEager: boolean): Promise<SyncResult> {
314
+ if (this.directSend !== null) {
315
+ await this.directSend.catch(() => undefined);
316
+ }
317
+ const report = await this.outbox.replay(isEager);
318
+ const conflicts = report.failed.map((failure) => ({ op: failure.entry, error: failure.error }));
319
+ if (report.stoppedBy !== 'connection') {
251
320
  await this.flushEvents();
252
- } finally {
253
- this.isSyncing = false;
254
321
  }
255
- emitWindowEvent('hub:sync', { pending: this.pending(), flushed, conflicts });
322
+ const result: SyncResult = { flushed: report.replayed.length, conflicts };
323
+ emitWindowEvent('hub:sync', { pending: this.pending(), ...result });
256
324
  this.notifyStatus();
257
- return { flushed, conflicts };
325
+ return result;
258
326
  }
259
327
 
260
328
  /** Send the buffered analytics events; a refused one is dropped (telemetry never blocks). */
@@ -273,6 +341,43 @@ export class HubConnection {
273
341
  }
274
342
  }
275
343
 
344
+ /** Move a v1 copy's queued writes into the outbox, in their order. */
345
+ private importLegacyQueue(): void {
346
+ if (this.slug === null) {
347
+ return;
348
+ }
349
+ try {
350
+ for (const legacy of this.store.takeLegacyQueue()) {
351
+ const operation = legacyOperation(legacy);
352
+ if (operation !== undefined) {
353
+ this.outbox.add(this.outbox.stamp(operation.kind, operation.payload));
354
+ }
355
+ }
356
+ } catch {
357
+ // A full outbox keeps what fitted; the client must still start.
358
+ }
359
+ }
360
+
361
+ /** Follow the browser's connection, other tabs' outbox changes, and replay what an earlier page left queued. */
362
+ private listenToPage(): void {
363
+ window.addEventListener('online', () => {
364
+ this.setReachable(true);
365
+ });
366
+ window.addEventListener('offline', () => {
367
+ this.setReachable(false);
368
+ });
369
+ window.addEventListener('storage', (event) => {
370
+ if (event instanceof StorageEvent && this.outbox.entries.owns(event.key)) {
371
+ this.notifyStatus();
372
+ }
373
+ });
374
+ if (this.outbox.count() > 0) {
375
+ setTimeout(() => {
376
+ void this.sync();
377
+ }, 0);
378
+ }
379
+ }
380
+
276
381
  /**
277
382
  * Warm the offline cache for every game the player has touched, in one
278
383
  * request, so going offline later still shows complete progress.
@@ -292,6 +397,10 @@ export class HubConnection {
292
397
  throw failure;
293
398
  }
294
399
  this.lastPrefetchAt = Date.now();
400
+ if (this.outbox.count() > 0) {
401
+ // Changes are still waiting to sync: the cache already holds them, and the snapshot doesn't yet.
402
+ return snapshot;
403
+ }
295
404
  this.store.hydrate(snapshot);
296
405
  emitWindowEvent('hub:prefetch', { games: Object.keys(snapshot.games).length });
297
406
  return snapshot;
@@ -15,12 +15,19 @@ export const UNSUPPORTED_BY_HUB = 'unsupported_by_hub';
15
15
  /** The code the social runtime gives a call it could not send (no connection). */
16
16
  export const OFFLINE_CODE = 'offline';
17
17
 
18
+ /**
19
+ * The code `getSave` rejects with when the hub can't be reached and this
20
+ * device has no copy of the slot it can trust (a fresh device or cleared
21
+ * browser): the slot may well hold a save, so it is not reported empty.
22
+ */
23
+ export const SAVE_UNKNOWN_CODE = 'save_unknown';
24
+
18
25
  /**
19
26
  * A machine-readable error code: one of the contract's public codes
20
27
  * (`content_rejected`, `rate_limited`, `suspended`, `claim_required`, ...),
21
28
  * {@link UNSUPPORTED_BY_HUB}, or a code a newer hub added.
22
29
  */
23
- export type HubErrorCode = ErrorCode | typeof UNSUPPORTED_BY_HUB | typeof OFFLINE_CODE | (string & Record<never, never>);
30
+ export type HubErrorCode = ErrorCode | typeof UNSUPPORTED_BY_HUB | typeof OFFLINE_CODE | typeof SAVE_UNKNOWN_CODE | (string & Record<never, never>);
24
31
 
25
32
  /** The machine-readable extras the hub attaches to an error body. */
26
33
  export interface HubErrorDetails {
@@ -34,6 +41,8 @@ export interface HubErrorDetails {
34
41
  readonly retryAfterMs?: number | undefined;
35
42
  /** When a suspension or mute ends (epoch ms). */
36
43
  readonly until?: number | undefined;
44
+ /** An online game's rules refused a move (`illegal`): the rules' own short code. */
45
+ readonly rule?: string | undefined;
37
46
  }
38
47
 
39
48
  /**
@@ -55,6 +64,8 @@ export class HubError extends Error {
55
64
  readonly retryAfterMs: number | undefined;
56
65
  /** When a suspension or mute ends (epoch ms). */
57
66
  readonly until: number | undefined;
67
+ /** An online game's rules refused a move (`code` is `illegal`): the rules' own short code, such as `cell_taken`. */
68
+ readonly rule: string | undefined;
58
69
 
59
70
  /** An error with a player-safe `message`, the HTTP `status` (if any) and the body's extras. */
60
71
  constructor(message: string, status?: number, details: HubErrorDetails = {}) {
@@ -66,6 +77,7 @@ export class HubError extends Error {
66
77
  this.hint = details.hint;
67
78
  this.retryAfterMs = details.retryAfterMs;
68
79
  this.until = details.until;
80
+ this.rule = details.rule;
69
81
  }
70
82
 
71
83
  /** `true` when the failure was a network or transport error, not an HTTP status. */
@@ -115,12 +127,13 @@ export function readErrorFields(body: unknown): ErrorBodyFields {
115
127
  hint: optionalString(body.hint),
116
128
  retryAfterMs: optionalNumber(body.retryAfterMs),
117
129
  until: optionalNumber(body.until),
130
+ rule: optionalString(body.rule),
118
131
  };
119
132
  }
120
133
 
121
134
  /** The right error class for a failure: a 422 or `content_rejected` is a {@link ContentRejectedError}. */
122
135
  export function errorFor(message: string, status: number | undefined, details: HubErrorDetails): HubError {
123
- return status === 422 || details.code === 'content_rejected'
136
+ return (status === 422 && details.code !== 'illegal') || details.code === 'content_rejected'
124
137
  ? new ContentRejectedError(message, status, details)
125
138
  : new HubError(message, status, details);
126
139
  }
@@ -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. */