@diffenderfer-games/hub 2.0.0-next.5 → 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 (170) hide show
  1. package/CHANGELOG.md +54 -19
  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 +503 -14
  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 +2020 -144
  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 +8 -0
  137. package/src/core/errors.ts +7 -1
  138. package/src/core/hub-progress.ts +269 -238
  139. package/src/core/hub.ts +174 -6
  140. package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
  141. package/src/core/offline/hub-kinds/index.ts +9 -0
  142. package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
  143. package/src/core/page-account.ts +121 -0
  144. package/src/core/rest/routes.gen.ts +31 -1
  145. package/src/core/resume/hub-resume.ts +39 -5
  146. package/src/core/resume/resume-types.ts +2 -2
  147. package/src/core/saved/daily-saves.ts +196 -0
  148. package/src/core/saved/daily-seed.ts +16 -0
  149. package/src/core/saved/hub-saved.ts +221 -0
  150. package/src/core/saved/local-saved.ts +122 -0
  151. package/src/core/saved/saved-list.ts +129 -0
  152. package/src/core/saved/saved-open.ts +104 -0
  153. package/src/core/saved/saved-order.ts +49 -0
  154. package/src/core/saved/saved-types.ts +24 -0
  155. package/src/core/turns/hub-turns.ts +140 -0
  156. package/src/core/turns/turn-game.ts +388 -0
  157. package/src/core/version/client-version.gen.ts +2 -2
  158. package/src/daily/daily-system.ts +112 -13
  159. package/src/daily/page-daily.ts +2 -2
  160. package/src/daily/types.ts +46 -9
  161. package/src/doctor/hub-version-fetch.ts +43 -43
  162. package/src/index.ts +10 -1
  163. package/src/input/input-system.ts +10 -1
  164. package/src/input/navigator.ts +11 -0
  165. package/src/input.ts +1 -0
  166. package/src/overlay/overlay.ts +25 -1
  167. package/src/realtime/runtime-api.ts +14 -3
  168. package/src/social.ts +1 -0
  169. package/src/turns-kit/check-rules.ts +182 -0
  170. 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,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,18 +3,23 @@
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
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';
22
+ export { SAVED_KIND, type SavedPayload } from './saved-kind';
18
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. */
@@ -25,6 +30,8 @@ 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;
28
35
  /** What this device last saw of the game's save slots on the hub. */
29
36
  readonly cloudSaves: CloudSaveTracker;
30
37
  }
@@ -36,6 +43,8 @@ export function registerHubKinds(outbox: Outbox, hooks: HubKindHooks): void {
36
43
  ...dailyKinds(hooks.game, hooks.onDailyComplete),
37
44
  ...economyKinds(hooks.game, writeLocalFavorites),
38
45
  ...resumeKinds(hooks.game, hooks.resume),
46
+ ...avatarKinds(),
47
+ ...savedKinds(hooks.dailies),
39
48
  };
40
49
  for (const [kind, definition] of Object.entries(kinds)) {
41
50
  outbox.define(kind, definition);
@@ -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
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Who the page is signed in as, as the hub tells it, so `hub.userId()` and
3
+ * `hub.avatar` follow the account without the game calling `hub.me()`:
4
+ *
5
+ * - the menu's `hub:account` (every refresh: after it makes a new guest's
6
+ * account, after sign-in) and `hub:auth` (the account changed);
7
+ * - the social runtime's {@link SESSION_EVENT} each time its socket says
8
+ * hello (connected, or connected again after a drop);
9
+ * - this client's own sign-in, sign-up, claim and sign-out.
10
+ *
11
+ * One per page: every bundle's copy hears the same window events.
12
+ */
13
+ import { readField } from '../realtime/cross-bundle';
14
+ import { SESSION_EVENT } from '../realtime/runtime-api';
15
+ import { isBrowserPage } from './page';
16
+
17
+ /** The window events that say who is signed in (`{ user }`). */
18
+ const ACCOUNT_EVENTS = ['hub:account', 'hub:auth'] as const;
19
+
20
+ /** The signed-in player as the page heard it. */
21
+ export interface PageUser {
22
+ readonly id: number;
23
+ /** Their look code when the source carried it, else `null`. */
24
+ readonly look: string | null;
25
+ }
26
+
27
+ /** Where the news came from: an account event, or a socket hello (a time to catch up on what was missed). */
28
+ export type AccountSource = 'account' | 'session';
29
+
30
+ /** Hears every report of who is signed in. */
31
+ export type AccountListener = (user: PageUser | null, source: AccountSource) => void;
32
+
33
+ /** The page's account, as last heard. */
34
+ export class PageAccount {
35
+ /** `undefined` until something said; `null` when signed out. */
36
+ private heard: PageUser | null | undefined = undefined;
37
+ private readonly listeners = new Set<AccountListener>();
38
+ private isListening = false;
39
+
40
+ constructor() {
41
+ this.listen();
42
+ }
43
+
44
+ /** The signed-in player, `null` when signed out, `undefined` before anything said. */
45
+ get user(): PageUser | null | undefined {
46
+ this.listen();
47
+ return this.heard;
48
+ }
49
+
50
+ /** Record who is signed in (this client's own sign-in or sign-out) and tell the listeners. */
51
+ note(user: PageUser | null, source: AccountSource = 'account'): void {
52
+ this.heard = user;
53
+ for (const listener of [...this.listeners]) {
54
+ listener(user, source);
55
+ }
56
+ }
57
+
58
+ /** Hear every report; returns the unsubscribe function. */
59
+ on(listener: AccountListener): () => void {
60
+ this.listen();
61
+ this.listeners.add(listener);
62
+ return () => {
63
+ this.listeners.delete(listener);
64
+ };
65
+ }
66
+
67
+ /** Listen to the window events (at once in a page; made before a window existed, on first use). */
68
+ private listen(): void {
69
+ if (this.isListening || !isBrowserPage()) {
70
+ return;
71
+ }
72
+ this.isListening = true;
73
+ for (const eventName of ACCOUNT_EVENTS) {
74
+ window.addEventListener(eventName, (event) => { this.onEvent(event, 'account'); });
75
+ }
76
+ window.addEventListener(SESSION_EVENT, (event) => { this.onEvent(event, 'session'); });
77
+ }
78
+
79
+ private onEvent(event: Event, source: AccountSource): void {
80
+ if (!(event instanceof CustomEvent)) {
81
+ return;
82
+ }
83
+ const user = readUser(event.detail);
84
+ if (user !== undefined) {
85
+ this.note(user, source);
86
+ }
87
+ }
88
+ }
89
+
90
+ /** A page user from an account the hub sent (`look` when it has one). */
91
+ export function toPageUser(user: { readonly id: number; readonly look?: string | undefined }): PageUser {
92
+ return { id: user.id, look: lookOrNull(user.look) };
93
+ }
94
+
95
+ /** A non-empty look code, else `null`. */
96
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: a field of another bundle's event detail
97
+ function lookOrNull(look: unknown): string | null {
98
+ return typeof look === 'string' && look !== '' ? look : null;
99
+ }
100
+
101
+ /** The user in a `{ user }` detail: the user, `null` (signed out), or `undefined` (it doesn't say). */
102
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: another bundle's event detail, read field by field
103
+ function readUser(detail: unknown): PageUser | null | undefined {
104
+ const user = readField(detail, 'user');
105
+ if (user === null) {
106
+ return null;
107
+ }
108
+ const id = readField(user, 'id');
109
+ if (typeof id !== 'number' || !Number.isInteger(id)) {
110
+ return undefined;
111
+ }
112
+ return { id, look: lookOrNull(readField(user, 'look')) };
113
+ }
114
+
115
+ let pageAccountSingleton: PageAccount | null = null;
116
+
117
+ /** The page's account tracker (made on first use; the hub client makes it at load). */
118
+ export function pageAccount(): PageAccount {
119
+ pageAccountSingleton ??= new PageAccount();
120
+ return pageAccountSingleton;
121
+ }
@@ -1,4 +1,4 @@
1
- // Generated by tools/hub-client/generate.ts. Do not edit; run `npm run gen:hub-client` in next/.
1
+ // Generated by tools/hub-client/generate.ts. Do not edit; run `npm run gen:hub-client`.
2
2
  /** Every public REST endpoint's method, path (under `/_api`), query keys and offline mode (`'replay'` when it may be queued offline), from the contract. */
3
3
  export const ROUTES = {
4
4
  ensureGuestSession: { method: 'POST', path: '/auth/guest', query: [], offline: null },
@@ -31,6 +31,7 @@ export const ROUTES = {
31
31
  submitScore: { method: 'POST', path: '/games/:slug/leaderboards/:key/scores', query: [], offline: 'replay' },
32
32
  listDailyChallengesForAllGames: { method: 'GET', path: '/daily', query: [], offline: null },
33
33
  getGameDailyChallenges: { method: 'GET', path: '/games/:slug/daily', query: [], offline: null },
34
+ resumeDailyChallenge: { method: 'POST', path: '/daily/instances/:id/resume', query: [], offline: null },
34
35
  startDailyChallenge: { method: 'POST', path: '/games/:slug/daily/:day/start', query: [], offline: null },
35
36
  completeDailyChallenge: { method: 'POST', path: '/games/:slug/daily/:day/complete', query: [], offline: 'replay' },
36
37
  getMyInventory: { method: 'GET', path: '/games/:slug/inventory', query: [], offline: null },
@@ -71,6 +72,7 @@ export const ROUTES = {
71
72
  getNotificationPreferences: { method: 'GET', path: '/social/notify', query: [], offline: null },
72
73
  unsubscribeFromPush: { method: 'DELETE', path: '/social/push', query: [], offline: null },
73
74
  setNotificationPreferences: { method: 'PUT', path: '/social/notify', query: [], offline: null },
75
+ setQuietHours: { method: 'PUT', path: '/social/notify/quiet', query: [], offline: null },
74
76
  subscribeToPush: { method: 'POST', path: '/social/push', query: [], offline: null },
75
77
  fileReport: { method: 'POST', path: '/social/reports', query: [], offline: null },
76
78
  listPublicSuggestions: { method: 'GET', path: '/suggestions', query: ['sort'], offline: null },
@@ -109,6 +111,34 @@ export const ROUTES = {
109
111
  saveResumeSlot: { method: 'PUT', path: '/games/:slug/resume', query: [], offline: 'replay' },
110
112
  clearResumeSlot: { method: 'DELETE', path: '/games/:slug/resume', query: ['before'], offline: 'replay' },
111
113
  listMyResumeSlots: { method: 'GET', path: '/me/resume', query: [], offline: null },
114
+ getMyAvatar: { method: 'GET', path: '/avatar/me', query: [], offline: null },
115
+ recordAvatarNudge: { method: 'POST', path: '/avatar/me/nudge', query: [], offline: null },
116
+ saveMyAvatar: { method: 'PUT', path: '/avatar/me', query: [], offline: null },
117
+ resetMyAvatar: { method: 'DELETE', path: '/avatar/me', query: [], offline: null },
118
+ getAvatar: { method: 'GET', path: '/avatar/of/:id', query: [], offline: null },
119
+ getAvatars: { method: 'POST', path: '/avatar/batch', query: [], offline: null },
120
+ listMySavedGames: { method: 'GET', path: '/saved', query: ['game', 'kinds'], offline: null },
121
+ getSavedGame: { method: 'GET', path: '/saved/:id', query: [], offline: null },
122
+ saveSavedGameState: { method: 'PUT', path: '/saved/:id/state', query: [], offline: 'replay' },
123
+ discardSavedGame: { method: 'POST', path: '/saved/:id/discard', query: [], offline: 'replay' },
124
+ discardGameSavedGames: { method: 'POST', path: '/games/:slug/saved/discard', query: [], offline: null },
125
+ createTurnGame: { method: 'POST', path: '/turns', query: [], offline: null },
126
+ joinTurnGame: { method: 'POST', path: '/turns/:id/join', query: [], offline: null },
127
+ joinTurnGameByLink: { method: 'POST', path: '/turns/join', query: [], offline: null },
128
+ startTurnGame: { method: 'POST', path: '/turns/:id/start', query: [], offline: null },
129
+ moveTurnGame: { method: 'POST', path: '/turns/:id/moves', query: [], offline: null },
130
+ leaveTurnGame: { method: 'POST', path: '/turns/:id/leave', query: [], offline: null },
131
+ nudgeTurnGame: { method: 'POST', path: '/turns/:id/nudge', query: [], offline: null },
132
+ setTurnGameNotify: { method: 'PUT', path: '/turns/:id/notify', query: [], offline: null },
133
+ rematchTurnGame: { method: 'POST', path: '/turns/:id/rematch', query: [], offline: null },
134
+ inviteToTurnGame: { method: 'POST', path: '/turns/:id/invite', query: [], offline: null },
135
+ shareTurnGame: { method: 'POST', path: '/turns/:id/share', query: [], offline: null },
136
+ autoPlayTurn: { method: 'POST', path: '/turns/:id/auto', query: [], offline: null },
137
+ chatInTurnGame: { method: 'POST', path: '/turns/:id/chat', query: [], offline: null },
138
+ getTurnGame: { method: 'GET', path: '/turns/:id', query: [], offline: null },
139
+ getTurnGameHistory: { method: 'GET', path: '/turns/:id/history', query: ['since'], offline: null },
140
+ listOpenTurnGames: { method: 'GET', path: '/open-turns', query: ['game'], offline: null },
141
+ listMyTurnGames: { method: 'GET', path: '/turns', query: ['game'], offline: null },
112
142
  } as const;
113
143
 
114
144
  /** The request header that carries an offline operation key (the contract's `OFFLINE_OP_HEADER`). */