@diffenderfer-games/hub 2.0.0-next.4 → 2.0.0-next.6

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 (173) hide show
  1. package/CHANGELOG.md +54 -11
  2. package/README.md +3 -6
  3. package/dist/auto.d.ts +5 -5
  4. package/dist/auto.js +2 -2
  5. package/dist/avatar-pixi.d.ts +147 -0
  6. package/dist/avatar-pixi.d.ts.map +1 -0
  7. package/dist/avatar-pixi.js +280 -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 +487 -0
  17. package/dist/avatar.js.map +1 -0
  18. package/dist/daily.d.ts +2 -2
  19. package/dist/doctor/cli.mjs +1 -1
  20. package/dist/index.d.ts +5 -5
  21. package/dist/index.js +2 -2
  22. package/dist/input.d.ts +2 -2
  23. package/dist/shared/animator.d.ts +104 -0
  24. package/dist/shared/animator.d.ts.map +1 -0
  25. package/dist/shared/art.js +1804 -0
  26. package/dist/shared/art.js.map +1 -0
  27. package/dist/shared/atlas.d.ts +134 -0
  28. package/dist/shared/atlas.d.ts.map +1 -0
  29. package/dist/shared/avatar.d.ts +117 -0
  30. package/dist/shared/avatar.d.ts.map +1 -0
  31. package/dist/shared/bridge.js +13 -5
  32. package/dist/shared/bridge.js.map +1 -1
  33. package/dist/shared/client-version.gen.js +1 -1
  34. package/dist/shared/daily.d.ts +129 -18
  35. package/dist/shared/daily.d.ts.map +1 -1
  36. package/dist/shared/daily.js +120 -26
  37. package/dist/shared/daily.js.map +1 -1
  38. package/dist/shared/defineProperty.js +39 -0
  39. package/dist/shared/describe.d.ts +32 -0
  40. package/dist/shared/describe.d.ts.map +1 -0
  41. package/dist/shared/describe.js +101 -0
  42. package/dist/shared/describe.js.map +1 -0
  43. package/dist/shared/globals.js +1 -39
  44. package/dist/shared/globals.js.map +1 -1
  45. package/dist/shared/index.d.ts +537 -17
  46. package/dist/shared/index.d.ts.map +1 -1
  47. package/dist/shared/input.d.ts +31 -13
  48. package/dist/shared/input.d.ts.map +1 -1
  49. package/dist/shared/input.js +40 -2
  50. package/dist/shared/input.js.map +1 -1
  51. package/dist/shared/json-guards.js +48 -0
  52. package/dist/shared/json-guards.js.map +1 -0
  53. package/dist/shared/live-canvas.js +62 -0
  54. package/dist/shared/live-canvas.js.map +1 -0
  55. package/dist/shared/mp.js +1 -1
  56. package/dist/shared/overlays.js +800 -0
  57. package/dist/shared/overlays.js.map +1 -0
  58. package/dist/shared/page-stack.js +2 -1
  59. package/dist/shared/page-stack.js.map +1 -1
  60. package/dist/shared/pose-key.js +249 -0
  61. package/dist/shared/pose-key.js.map +1 -0
  62. package/dist/shared/race.js +1 -1
  63. package/dist/shared/rooms.js +1 -1
  64. package/dist/shared/scene.d.ts +90 -0
  65. package/dist/shared/scene.d.ts.map +1 -0
  66. package/dist/shared/social.d.ts +55 -4
  67. package/dist/shared/social.d.ts.map +1 -1
  68. package/dist/shared/src.js +2217 -199
  69. package/dist/shared/src.js.map +1 -1
  70. package/dist/shared/svg-markup.js +1860 -0
  71. package/dist/shared/svg-markup.js.map +1 -0
  72. package/dist/shared/svg.js +27 -0
  73. package/dist/shared/svg.js.map +1 -0
  74. package/dist/shared/types.d.ts +166 -0
  75. package/dist/shared/types.d.ts.map +1 -0
  76. package/dist/social.d.ts +2 -2
  77. package/dist/social.js +2 -2
  78. package/dist/turns.d.ts +39 -0
  79. package/dist/turns.d.ts.map +1 -0
  80. package/dist/turns.js +117 -0
  81. package/dist/turns.js.map +1 -0
  82. package/package.json +33 -3
  83. package/src/avatar/adapters/pixi-avatar.ts +73 -0
  84. package/src/avatar/adapters/pixi-crowd.ts +302 -0
  85. package/src/avatar/adapters/pixi-types.ts +84 -0
  86. package/src/avatar/adapters/three.ts +126 -0
  87. package/src/avatar/animator/animator.ts +285 -0
  88. package/src/avatar/animator/reactions.ts +44 -0
  89. package/src/avatar/animator/schedule.ts +73 -0
  90. package/src/avatar/art/beard.ts +49 -0
  91. package/src/avatar/art/glasses.ts +9 -0
  92. package/src/avatar/art/hair.ts +254 -0
  93. package/src/avatar/art/headwear.ts +75 -0
  94. package/src/avatar/art/index.ts +21 -0
  95. package/src/avatar/art/shape.ts +68 -0
  96. package/src/avatar/art-store.ts +36 -0
  97. package/src/avatar/atlas.ts +236 -0
  98. package/src/avatar/catalog-view.ts +14 -0
  99. package/src/avatar/colour.ts +109 -0
  100. package/src/avatar/describe.ts +76 -0
  101. package/src/avatar/face.ts +187 -0
  102. package/src/avatar/geom.ts +168 -0
  103. package/src/avatar/layout.ts +47 -0
  104. package/src/avatar/look-like.ts +52 -0
  105. package/src/avatar/outputs/bitmap.ts +75 -0
  106. package/src/avatar/outputs/canvas-paint.ts +177 -0
  107. package/src/avatar/outputs/canvas.ts +62 -0
  108. package/src/avatar/outputs/custom-element.ts +140 -0
  109. package/src/avatar/outputs/element.ts +58 -0
  110. package/src/avatar/outputs/live-canvas.ts +57 -0
  111. package/src/avatar/outputs/overlay-svg.ts +22 -0
  112. package/src/avatar/outputs/pose-key.ts +30 -0
  113. package/src/avatar/outputs/svg-markup.ts +113 -0
  114. package/src/avatar/outputs/svg.ts +41 -0
  115. package/src/avatar/overlays.ts +128 -0
  116. package/src/avatar/render/body.ts +116 -0
  117. package/src/avatar/render/colours.ts +81 -0
  118. package/src/avatar/render/compose.ts +258 -0
  119. package/src/avatar/render/context.ts +117 -0
  120. package/src/avatar/render/eyes.ts +78 -0
  121. package/src/avatar/render/eyewear.ts +56 -0
  122. package/src/avatar/render/frame.ts +60 -0
  123. package/src/avatar/render/head.ts +81 -0
  124. package/src/avatar/render/mouth.ts +86 -0
  125. package/src/avatar/render/props.ts +91 -0
  126. package/src/avatar/render/types.ts +84 -0
  127. package/src/avatar/scene.ts +119 -0
  128. package/src/avatar-pixi.ts +20 -0
  129. package/src/avatar-svg.ts +21 -0
  130. package/src/avatar-three.ts +18 -0
  131. package/src/avatar.ts +62 -0
  132. package/src/core/avatar/avatar-cache.ts +95 -0
  133. package/src/core/avatar/avatar-ref.ts +34 -0
  134. package/src/core/avatar/avatar-types.ts +43 -0
  135. package/src/core/avatar/hub-avatar.ts +308 -0
  136. package/src/core/connection.ts +9 -0
  137. package/src/core/errors.ts +15 -2
  138. package/src/core/hub-progress.ts +269 -202
  139. package/src/core/hub.ts +174 -6
  140. package/src/core/offline/cloud-saves.ts +62 -0
  141. package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
  142. package/src/core/offline/hub-kinds/index.ts +14 -3
  143. package/src/core/offline/hub-kinds/progress-kinds.ts +21 -4
  144. package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
  145. package/src/core/offline/local-store.ts +46 -1
  146. package/src/core/page-account.ts +121 -0
  147. package/src/core/rest/routes.gen.ts +31 -1
  148. package/src/core/resume/hub-resume.ts +39 -5
  149. package/src/core/resume/resume-types.ts +2 -2
  150. package/src/core/saved/daily-saves.ts +196 -0
  151. package/src/core/saved/daily-seed.ts +16 -0
  152. package/src/core/saved/hub-saved.ts +221 -0
  153. package/src/core/saved/local-saved.ts +122 -0
  154. package/src/core/saved/saved-list.ts +129 -0
  155. package/src/core/saved/saved-open.ts +104 -0
  156. package/src/core/saved/saved-order.ts +49 -0
  157. package/src/core/saved/saved-types.ts +24 -0
  158. package/src/core/turns/hub-turns.ts +140 -0
  159. package/src/core/turns/turn-game.ts +388 -0
  160. package/src/core/version/client-version.gen.ts +2 -2
  161. package/src/daily/daily-system.ts +112 -13
  162. package/src/daily/page-daily.ts +2 -2
  163. package/src/daily/types.ts +46 -9
  164. package/src/doctor/hub-version-fetch.ts +43 -43
  165. package/src/index.ts +11 -1
  166. package/src/input/input-system.ts +10 -1
  167. package/src/input/navigator.ts +11 -0
  168. package/src/input.ts +1 -0
  169. package/src/overlay/overlay.ts +25 -1
  170. package/src/realtime/runtime-api.ts +14 -3
  171. package/src/social.ts +1 -0
  172. package/src/turns-kit/check-rules.ts +182 -0
  173. package/src/turns.ts +13 -0
package/src/core/hub.ts CHANGED
@@ -10,16 +10,23 @@ import { getInput, getUiStack, type InputSystem } from '../input';
10
10
  import { mp } from '../mp';
11
11
  import { race } from '../race';
12
12
  import { rooms } from '../rooms';
13
+ import { pageBridge } from '../realtime/bridge';
13
14
  import { isInHubRoom } from '../realtime/rooms-forwarder';
14
15
  import { linkRealtimeToHub, notify, presence, social } from '../social';
16
+ import { HubAvatar } from './avatar/hub-avatar';
15
17
  import { HubError, OfflineError, toHubError } from './errors';
16
18
  import { HubEconomy } from './hub-economy';
17
- import { DAILY_KIND, RESUME_KIND } from './offline/hub-kinds';
19
+ import { AVATAR_KIND, DAILY_KIND, RESUME_KIND, SAVED_KIND } from './offline/hub-kinds';
18
20
  import { readDailyCompletion } from './offline/hub-kinds/daily-kind';
19
21
  import { hubMenu, type HubMenuControl } from './menu-control';
20
22
  import { detectGameTitle, emitWindowEvent } from './page';
21
23
  import { HubResume, type ResumeHost } from './resume/hub-resume';
22
24
  import { isResumeRequested } from './resume/resume-request';
25
+ import { DailySaves } from './saved/daily-saves';
26
+ import { HubSaved, type SavedHost } from './saved/hub-saved';
27
+ import { HubTurns, type TurnsHost } from './turns/hub-turns';
28
+ import { savedOpenedOnPage } from './saved/saved-open';
29
+ import type { SavedEntry } from './saved/saved-types';
23
30
  import { fetchGameServerTicket } from './ticket';
24
31
  import { clientSupport, readHubVersion, type ClientSupport } from './version/hub-version';
25
32
  import { HUB_CLIENT_VERSION } from './version/client-version.gen';
@@ -31,6 +38,12 @@ export class Hub extends HubEconomy {
31
38
  readonly clientVersion: string = HUB_CLIENT_VERSION;
32
39
 
33
40
  private resumeApi: HubResume | undefined;
41
+ private avatarApi: HubAvatar | undefined;
42
+ private savedApi: HubSaved | undefined;
43
+ private dailySavesApi: DailySaves | undefined;
44
+ private turnsApi: HubTurns | undefined;
45
+ /** The online games open on this page (a run isn't saved while one is). */
46
+ private readonly openTurnGames = new Set<object>();
34
47
 
35
48
  /**
36
49
  * The hub's version and the client versions it supports (`GET /_api/version`),
@@ -118,6 +131,110 @@ export class Hub extends HubEconomy {
118
131
  return this.resumeApi;
119
132
  }
120
133
 
134
+ /**
135
+ * Players' avatars: `hub.avatar.mine()`, `of(userId)`, `from(seat)`,
136
+ * `bot(seed)`, `on('change')`, `openDesigner()` and `load()` for the renderer
137
+ * (docs: "Avatars"). No art or renderer until `load()`.
138
+ */
139
+ get avatar(): HubAvatar {
140
+ this.avatarApi ??= new HubAvatar({
141
+ currentUserId: () => this.userId(),
142
+ onAccount: (listener) => this.account.on(listener),
143
+ fetchMine: () => this.rest.call('getMyAvatar'),
144
+ fetchCodes: async (ids) => (await this.rest.call('getAvatars', { ids: [...ids] })).codes,
145
+ queueSave: async (code, savedAt) => {
146
+ await this.mutateOffline(AVATAR_KIND, { code, savedAt }, () => undefined);
147
+ },
148
+ });
149
+ return this.avatarApi;
150
+ }
151
+
152
+ /**
153
+ * Every game the player has in progress, of every kind: `hub.saved.list()`
154
+ * for this game's "Continue" list, `opened` and `onOpen` to open the one
155
+ * the player picked (docs: "Saved games").
156
+ */
157
+ get saved(): HubSaved {
158
+ this.savedApi ??= new HubSaved(this.savedHost());
159
+ return this.savedApi;
160
+ }
161
+
162
+ /**
163
+ * Online games played over minutes or days, run by the hub from the
164
+ * game's rules module (`game.turns`): `hub.turns.create`, `join`, `open`
165
+ * (docs/multiplayer.md "Turn games").
166
+ */
167
+ get turns(): HubTurns {
168
+ this.turnsApi ??= new HubTurns(this.turnsHost());
169
+ return this.turnsApi;
170
+ }
171
+
172
+ /** What `hub.turns` needs from this client: its REST calls. */
173
+ private turnsHost(): TurnsHost {
174
+ const { rest } = this;
175
+ return {
176
+ slug: this.slug,
177
+ create: (body) => rest.call('createTurnGame', body),
178
+ join: (id) => rest.call('joinTurnGame', { id }),
179
+ joinByLink: (key) => rest.call('joinTurnGameByLink', { key }),
180
+ get: (id) => rest.call('getTurnGame', { id }),
181
+ start: (id) => rest.call('startTurnGame', { id }),
182
+ move: (id, seq, move) => rest.call('moveTurnGame', { id, seq, move }),
183
+ history: async (id, since) => (await rest.call('getTurnGameHistory', { id, since })).moves,
184
+ leave: async (id) => { await rest.call('leaveTurnGame', { id }); },
185
+ nudge: async (id) => { await rest.call('nudgeTurnGame', { id }); },
186
+ setNotify: async (id, mode) => { await rest.call('setTurnGameNotify', { id, mode }); },
187
+ rematch: (id) => rest.call('rematchTurnGame', { id }),
188
+ invite: (id, userIds) => rest.call('inviteToTurnGame', { id, userIds: [...userIds] }),
189
+ share: (id, share) => rest.call('shareTurnGame', { id, ...share }),
190
+ autoPlay: (id) => rest.call('autoPlayTurn', { id }),
191
+ chat: (id, send) => rest.call('chatInTurnGame', { id, ...send }),
192
+ listOpen: async (game) => (await rest.call('listOpenTurnGames', game === null ? {} : { game })).games,
193
+ list: () => this.saved.list({ kinds: ['turns'] }),
194
+ opened: (game) => { this.openTurnGames.add(game); },
195
+ closed: (game) => { this.openTurnGames.delete(game); },
196
+ isLive: () => pageBridge.current() !== null,
197
+ };
198
+ }
199
+
200
+ /** What `hub.saved` needs from this client. */
201
+ private savedHost(): SavedHost {
202
+ return {
203
+ slug: this.slug,
204
+ fetchList: (game) => this.withFallback<readonly SavedEntry[] | undefined>(
205
+ async () => (await this.rest.call('listMySavedGames', game === null ? {} : { game })).games,
206
+ () => undefined,
207
+ ),
208
+ deviceCopies: () => {
209
+ const userId = this.store.getUser()?.id ?? null;
210
+ return {
211
+ runs: new Map([...this.resumeStore.all()].filter(([, copy]) => copy.userId === userId)),
212
+ dailies: this.dailyStore.all(userId),
213
+ };
214
+ },
215
+ discard: (entry) => this.discardSaved(entry),
216
+ navigate: (url) => {
217
+ location.assign(url);
218
+ },
219
+ };
220
+ }
221
+
222
+ /** Discard a daily or run: this game's run through `hub.resume` (works offline), others on the hub. */
223
+ private async discardSaved(entry: SavedEntry): Promise<void> {
224
+ if (entry.kind === 'run' && entry.game === this.slug) {
225
+ await this.resume.clear();
226
+ return;
227
+ }
228
+ if (!entry.id.startsWith('local:')) {
229
+ await this.rest.call('discardSavedGame', { id: entry.id });
230
+ }
231
+ if (entry.kind === 'run') {
232
+ this.resumeStore.remove(entry.game);
233
+ } else if (entry.daily !== undefined) {
234
+ this.dailyStore.remove(entry.game, entry.daily.day);
235
+ }
236
+ }
237
+
121
238
  /** What `hub.resume` needs from this client. */
122
239
  private resumeHost(): ResumeHost {
123
240
  return {
@@ -129,7 +246,14 @@ export class Hub extends HubEconomy {
129
246
  if (race.current() !== null) {
130
247
  return 'race';
131
248
  }
132
- return this.daily.active === null ? (isInHubRoom() ? 'room' : null) : 'daily';
249
+ if (this.openTurnGames.size > 0) {
250
+ return 'turns';
251
+ }
252
+ return isInHubRoom() ? 'room' : null;
253
+ },
254
+ daily: () => {
255
+ const daily = this.daily;
256
+ return daily.active === null ? null : daily;
133
257
  },
134
258
  send: async (payload) => {
135
259
  await this.mutateOffline(RESUME_KIND, payload, () => undefined);
@@ -148,17 +272,59 @@ export class Hub extends HubEconomy {
148
272
  clearOther: async (slug) => {
149
273
  await this.rest.call('clearResumeSlot', { slug });
150
274
  },
275
+ onChange: () => {
276
+ this.savedApi?.changed();
277
+ },
151
278
  };
152
279
  }
153
280
 
154
- /** What the daily system needs from this client: its REST calls and window events. */
281
+ /** This game's daily saves (device copies and their sends). */
282
+ private dailySaves(): DailySaves {
283
+ const slug = this.slug;
284
+ if (slug === null) {
285
+ throw new HubError('hub.daily: could not determine the game slug.');
286
+ }
287
+ this.dailySavesApi ??= new DailySaves({
288
+ slug,
289
+ local: this.dailyStore,
290
+ currentUserId: () => this.store.getUser()?.id ?? null,
291
+ gameTitle: () => detectGameTitle(slug),
292
+ send: async (payload) => {
293
+ await this.mutateOffline(SAVED_KIND, payload, () => undefined);
294
+ this.savedApi?.changed();
295
+ },
296
+ });
297
+ return this.dailySavesApi;
298
+ }
299
+
300
+ /** What the daily system needs from this client: its REST calls, the device copies and window events. */
155
301
  private dailyHost(): DailyHost {
156
302
  return {
157
303
  slug: this.slug,
158
304
  status: () => this.rest.call('getGameDailyChallenges', this.gameFields()),
159
305
  all: () => this.rest.call('listDailyChallengesForAllGames'),
160
- start: (day) => this.rest.call('startDailyChallenge', { ...this.gameFields(), day }),
161
- complete: (day, token, meta) => this.mutateOffline<DailyCompleteResponse | null>(DAILY_KIND, { day, token, meta }, () => null, (result) => readDailyCompletion(result)),
306
+ start: (day) => this.withFallback(
307
+ async () => this.dailySaves().remember(await this.rest.call('startDailyChallenge', { ...this.gameFields(), day })),
308
+ () => this.dailySaves().startLocally(day),
309
+ ),
310
+ resume: async (instanceId) => this.dailySaves().remember(await this.rest.call('resumeDailyChallenge', { id: instanceId })),
311
+ complete: async (day, token, meta) => {
312
+ this.dailySaves().finished(day);
313
+ const completed = await this.mutateOffline<DailyCompleteResponse | null>(DAILY_KIND, { day, token, meta }, () => null, (result) => readDailyCompletion(result));
314
+ if (completed !== null) {
315
+ this.dailySaves().completed(day);
316
+ }
317
+ this.savedApi?.changed();
318
+ return completed;
319
+ },
320
+ save: (challenge, input) => {
321
+ const isSaved = this.dailySaves().save(challenge, input);
322
+ this.savedApi?.changed();
323
+ return isSaved;
324
+ },
325
+ flush: () => this.dailySaves().flush(),
326
+ discard: (challenge) => this.dailySaves().discard(challenge),
327
+ inProgress: () => this.saved.list({ kinds: ['daily'] }),
162
328
  emit: (eventName, detail) => {
163
329
  emitWindowEvent(eventName, detail);
164
330
  },
@@ -175,8 +341,10 @@ linkRealtimeToHub({
175
341
  /** A ready-to-use client, with the slug and API base detected from the page. */
176
342
  export const hub: Hub = new Hub();
177
343
 
178
- // Take `?hub_resume` out of the address bar before the game's router sees it (the answer stays on `hub.resume.requested`).
344
+ // Take `?hub_resume`, `?hub_open` and `?hub_kind` out of the address bar before the game's router sees them
345
+ // (the answers stay on `hub.resume.requested` and `hub.saved.opened`).
179
346
  isResumeRequested();
347
+ savedOpenedOnPage();
180
348
 
181
349
  /** Create a client for another slug or API base, or with other offline behaviour. */
182
350
  export function createHub(options: HubOptions = {}): Hub {
@@ -0,0 +1,62 @@
1
+ /**
2
+ * What this device last saw of a game's save slots on the hub, per player:
3
+ * each slot's `updatedAt` there, or `null` for a slot known to be empty. It
4
+ * decides two things offline. `getSave` answers from the cache only when
5
+ * the device knows the hub's copy of that slot (so a fresh device or cleared
6
+ * browser never reports "no save" for a slot it simply hasn't seen), and a
7
+ * queued save write sends the hub's `updatedAt` it was built on, so the hub
8
+ * refuses it when the slot has changed since.
9
+ *
10
+ * A snapshot gives the full list (`complete`: any other slot is empty); a
11
+ * single read or write adds that slot to a partial list.
12
+ */
13
+ import { isRecord, optionalNumber } from '../storage/json-guards';
14
+
15
+ /** The hub's save slots for one game, as this device last saw them. */
16
+ export interface CloudSaveIndex {
17
+ /** Whose slots these are. */
18
+ readonly userId: number;
19
+ /** `true` when every slot the hub had is listed (from a snapshot). */
20
+ readonly complete: boolean;
21
+ /** Each slot's `updatedAt` on the hub, or `null` when it was empty. */
22
+ readonly saves: Readonly<Record<string, number | null>>;
23
+ }
24
+
25
+ /** A stored index, or `null` when missing or malformed. */
26
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: an index read back from localStorage
27
+ export function readCloudSaveIndex(stored: unknown): CloudSaveIndex | null {
28
+ if (!isRecord(stored) || typeof stored.complete !== 'boolean' || !isRecord(stored.saves)) {
29
+ return null;
30
+ }
31
+ const userId = optionalNumber(stored.userId);
32
+ if (userId === undefined) {
33
+ return null;
34
+ }
35
+ const saves: Record<string, number | null> = {};
36
+ for (const [slot, updatedAt] of Object.entries(stored.saves)) {
37
+ const known = updatedAt === null ? null : optionalNumber(updatedAt);
38
+ if (known === undefined) {
39
+ return null;
40
+ }
41
+ saves[slot] = known;
42
+ }
43
+ return { userId, complete: stored.complete, saves };
44
+ }
45
+
46
+ /**
47
+ * The hub's `updatedAt` for `slot` as `userId` last saw it: a number, `null`
48
+ * when the slot was empty, or `undefined` when this device doesn't know.
49
+ */
50
+ export function cloudSaveOf(index: CloudSaveIndex | null, userId: number, slot: string): number | null | undefined {
51
+ if (index?.userId !== userId) {
52
+ return undefined;
53
+ }
54
+ const updatedAt = index.saves[slot];
55
+ return updatedAt === undefined && index.complete ? null : updatedAt;
56
+ }
57
+
58
+ /** `index` with `slot` seen at `updatedAt` (`null`: empty) by `userId`; another player's index starts over. */
59
+ export function withCloudSave(index: CloudSaveIndex | null, userId: number, slot: string, updatedAt: number | null): CloudSaveIndex {
60
+ const base = index?.userId === userId ? index : { userId, complete: false, saves: {} };
61
+ return { ...base, saves: { ...base.saves, [slot]: updatedAt } };
62
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The hub's avatar kind (`hub.avatar`, avatars.md §5.6): save your look.
3
+ * Latest wins: a newer save replaces a waiting one, and the hub keeps the
4
+ * newer of the replayed save and one another device made since (`savedAt`).
5
+ */
6
+ import type { Json } from '@diffenderfer-games/hub-contract';
7
+ import { decodeLook } from '@diffenderfer-games/hub-contract/avatar';
8
+ import { isRecord, optionalNumber, optionalString } from '../../storage/json-guards';
9
+ import { checkedKind } from '../kind-adapters';
10
+ import type { RegisteredKind } from '../outbox-kinds';
11
+
12
+ /** The kind name. */
13
+ export const AVATAR_KIND = 'hub.avatar';
14
+
15
+ /** Save this look (its code) as of `savedAt`. */
16
+ export type AvatarPayload = { readonly code: string; readonly savedAt: number };
17
+
18
+ function readAvatar(stored: Json): AvatarPayload | undefined {
19
+ if (!isRecord(stored)) {
20
+ return undefined;
21
+ }
22
+ const code = optionalString(stored.code);
23
+ const savedAt = optionalNumber(stored.savedAt);
24
+ return code === undefined || savedAt === undefined ? undefined : { code, savedAt };
25
+ }
26
+
27
+ /** The avatar kind, by name. */
28
+ export function avatarKinds(): Record<string, RegisteredKind> {
29
+ return {
30
+ [AVATAR_KIND]: checkedKind(readAvatar, {
31
+ mergeKey: () => 'avatar',
32
+ replaces: true,
33
+ run: async (op, context) => {
34
+ const look = decodeLook(op.code)?.look;
35
+ if (look !== undefined) {
36
+ await context.call('saveMyAvatar', { look: { ...look, accessories: [...look.accessories] }, savedAt: op.savedAt });
37
+ }
38
+ return undefined;
39
+ },
40
+ }),
41
+ };
42
+ }
@@ -3,19 +3,24 @@
3
3
  * client's outbox. The docs' "Offline and replay" table lists them.
4
4
  */
5
5
  import type { LocalResumeStore } from '../../resume/local-resume';
6
+ import type { LocalDailyStore } from '../../saved/local-saved';
6
7
  import { writeLocalFavorites } from '../favorites';
7
8
  import type { Outbox } from '../outbox';
9
+ import { avatarKinds } from './avatar-kind';
8
10
  import { dailyKinds, type DailyCompletion, type DailyPayload } from './daily-kind';
9
11
  import { economyKinds } from './economy-kinds';
10
12
  import type { GameFieldsSource } from './game-fields';
11
- import { progressKinds } from './progress-kinds';
13
+ import { progressKinds, type CloudSaveTracker } from './progress-kinds';
12
14
  import { resumeKinds } from './resume-kind';
15
+ import { savedKinds } from './saved-kind';
13
16
 
17
+ export { AVATAR_KIND, type AvatarPayload } from './avatar-kind';
14
18
  export { DAILY_KIND, type DailyPayload } from './daily-kind';
15
19
  export { ECONOMY_KINDS, type ExchangePayload, type FavoritePayload } from './economy-kinds';
16
20
  export { legacyOperation } from './legacy-ops';
17
21
  export { RESUME_KIND, type ResumePayload } from './resume-kind';
18
- export { PROGRESS_KINDS, type ProfilePayload, type SavePayload, type ScorePayload, type StatPayload } from './progress-kinds';
22
+ export { SAVED_KIND, type SavedPayload } from './saved-kind';
23
+ export { PROGRESS_KINDS, type CloudSaveTracker, type ProfilePayload, type SavePayload, type ScorePayload, type StatPayload } from './progress-kinds';
19
24
 
20
25
  /** What the hub kinds report back to their client. */
21
26
  export interface HubKindHooks {
@@ -25,15 +30,21 @@ export interface HubKindHooks {
25
30
  readonly onDailyComplete: (payload: DailyPayload, completion: DailyCompletion) => void;
26
31
  /** The device copies of each game's resume slot (`hub.resume`). */
27
32
  readonly resume: LocalResumeStore;
33
+ /** The device copies of daily challenges in progress (`hub.saved`). */
34
+ readonly dailies: LocalDailyStore;
35
+ /** What this device last saw of the game's save slots on the hub. */
36
+ readonly cloudSaves: CloudSaveTracker;
28
37
  }
29
38
 
30
39
  /** Register every hub kind on `outbox`. */
31
40
  export function registerHubKinds(outbox: Outbox, hooks: HubKindHooks): void {
32
41
  const kinds = {
33
- ...progressKinds(hooks.game),
42
+ ...progressKinds(hooks.game, hooks.cloudSaves),
34
43
  ...dailyKinds(hooks.game, hooks.onDailyComplete),
35
44
  ...economyKinds(hooks.game, writeLocalFavorites),
36
45
  ...resumeKinds(hooks.game, hooks.resume),
46
+ ...avatarKinds(),
47
+ ...savedKinds(hooks.dailies),
37
48
  };
38
49
  for (const [kind, definition] of Object.entries(kinds)) {
39
50
  outbox.define(kind, definition);
@@ -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, {
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The hub's saved-games kind (`hub.saved`): send a daily challenge's state,
3
+ * or discard the day, from the device copy (`LocalDailyStore`). One op per
4
+ * (game, day): a newer one replaces a waiting one, so a discard after a
5
+ * save drops the save. The op carries only which day and when; when it runs
6
+ * it sends the newest state the device holds, so the outbox stays small.
7
+ *
8
+ * A day started offline has a device-only id (`local:<slug>:<day>`): the op
9
+ * first starts the day on the hub, which finds or makes its saved game (one
10
+ * per player, game and day), then saves to that. A save or discard that
11
+ * finds the day already finished or discarded elsewhere (409, 404) is done:
12
+ * the device copy is dropped.
13
+ */
14
+ import type { Json } from '@diffenderfer-games/hub-contract';
15
+ import { HubError } from '../../errors';
16
+ import type { LocalDaily, LocalDailyStore } from '../../saved/local-saved';
17
+ import { isRecord, optionalNumber, optionalString } from '../../storage/json-guards';
18
+ import { checkedKind } from '../kind-adapters';
19
+ import type { RegisteredKind, ReplayContext } from '../outbox-kinds';
20
+
21
+ /** The kind name. */
22
+ export const SAVED_KIND = 'hub.saved';
23
+
24
+ /** Send (`save`) or discard a day's saved game; `at` is when the device copy changed. */
25
+ export type SavedPayload = { readonly action: 'save' | 'discard'; readonly game: string; readonly day: string; readonly at: number };
26
+
27
+ function readSaved(stored: Json): SavedPayload | undefined {
28
+ if (!isRecord(stored) || (stored.action !== 'save' && stored.action !== 'discard')) {
29
+ return undefined;
30
+ }
31
+ const game = optionalString(stored.game);
32
+ const day = optionalString(stored.day);
33
+ const at = optionalNumber(stored.at);
34
+ return game === undefined || day === undefined || at === undefined ? undefined : { action: stored.action, game, day, at };
35
+ }
36
+
37
+ /** Whether a failure means the instance is gone or closed on the hub (the change is moot). */
38
+ function isGoneOrClosed(failure: Error): boolean {
39
+ return failure instanceof HubError && (failure.status === 404 || failure.status === 409);
40
+ }
41
+
42
+ /** The hub's id for the copy's day: its own, or the one the hub gives the day when it was started offline (`null`: already completed). */
43
+ async function resolveId(copy: LocalDaily, context: ReplayContext): Promise<string | null> {
44
+ if (!copy.id.startsWith('local:')) {
45
+ return copy.id;
46
+ }
47
+ const started = await context.call('startDailyChallenge', { slug: copy.game, day: copy.day });
48
+ return started.instanceId;
49
+ }
50
+
51
+ /** Run one op against the copy it names. */
52
+ async function runSaved(op: SavedPayload, local: LocalDailyStore, context: ReplayContext): Promise<undefined> {
53
+ const copy = local.read(op.game, op.day);
54
+ if (copy === null) {
55
+ return undefined;
56
+ }
57
+ try {
58
+ const id = await resolveId(copy, context);
59
+ if (id === null) {
60
+ local.remove(op.game, op.day);
61
+ return undefined;
62
+ }
63
+ if (op.action === 'discard') {
64
+ await context.call('discardSavedGame', { id });
65
+ local.remove(op.game, op.day);
66
+ return undefined;
67
+ }
68
+ // A null state is sent too: it is hub.resume.clear() during the day (start over from the seed).
69
+ if (copy.status === 'active') {
70
+ await context.call('saveSavedGameState', {
71
+ id, state: copy.state, progress: { label: copy.label, fraction: copy.fraction }, title: copy.title, version: copy.version, savedAt: copy.at,
72
+ });
73
+ }
74
+ local.markSynced(op.game, op.day, copy.at, id);
75
+ } catch (failure) {
76
+ if (!(failure instanceof Error) || !isGoneOrClosed(failure)) {
77
+ throw failure;
78
+ }
79
+ local.remove(op.game, op.day);
80
+ }
81
+ return undefined;
82
+ }
83
+
84
+ /** The saved-games kind, by name, over the device copies in `local`. */
85
+ export function savedKinds(local: LocalDailyStore): Record<string, RegisteredKind> {
86
+ return {
87
+ [SAVED_KIND]: checkedKind(readSaved, {
88
+ mergeKey: (op) => `daily:${op.game}#${op.day}`,
89
+ replaces: true,
90
+ run: (op, context) => runSaved(op, local, context),
91
+ }),
92
+ };
93
+ }
@@ -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
  }