@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
@@ -6,8 +6,11 @@
6
6
  */
7
7
  import type { Board, Json, Stats } from '@diffenderfer-games/hub-contract';
8
8
  import { HubConnection } from './connection';
9
+ import { HubError, SAVE_UNKNOWN_CODE } from './errors';
9
10
  import { toGameData, toStoredJson, type GamePayload } from './game-data';
10
- import type { ProfilePatch, QueuedScoreOptions } from './offline/queue';
11
+ import { PROGRESS_KINDS, type ProfilePayload, type ScorePayload } from './offline/hub-kinds';
12
+ import { pageAccount, toPageUser } from './page-account';
13
+ import { isRecord } from './storage/json-guards';
11
14
  import type { LeaderboardResult, MeResponse, Profile, Save, SaveMeta, SubmitScoreOptions, SubmitScoreResult, User } from './types';
12
15
 
13
16
  /** A signed-in user, as the auth calls answer. */
@@ -44,9 +47,30 @@ function typedSave<TData>(save: Save | null): Save<TData> | null {
44
47
 
45
48
  /** Accounts, profile, saves, stats and leaderboards. */
46
49
  export class HubProgress extends HubConnection {
50
+ /** Who the page is signed in as, as the hub's events and this client's sign-ins tell it. */
51
+ protected readonly account = pageAccount();
52
+
53
+ /**
54
+ * Your user id, or `null` when nobody is signed in (also before an offline
55
+ * first visit has an account). Synchronous, and it follows the account as
56
+ * the hub changes it (a new guest's account made by the menu, sign-in,
57
+ * sign-out) with no `hub.me()` needed. Compare it with a seat's `userId`
58
+ * to find yours.
59
+ */
60
+ userId(): number | null {
61
+ const heard = this.account.user;
62
+ const id = heard === undefined ? this.store.getUser()?.id ?? null : heard?.id ?? null;
63
+ // 0 is an offline local guest: no account on the hub yet.
64
+ return id === 0 ? null : id;
65
+ }
66
+
47
67
  /** The current user (or `null`) and the games they have a profile in. */
48
68
  me(): Promise<MeResponse> {
49
- return this.withFallback(() => this.rest.call('getCurrentUser'), () => {
69
+ return this.withFallback(async () => {
70
+ const reply = await this.rest.call('getCurrentUser');
71
+ this.account.note(reply.user === null ? null : toPageUser(reply.user));
72
+ return reply;
73
+ }, () => {
50
74
  const profile = this.store.getProfile();
51
75
  return { user: this.store.getUser(), profiles: profile === null ? [] : [{ game: profile.game, displayName: profile.displayName }] };
52
76
  });
@@ -58,14 +82,12 @@ export class HubProgress extends HubConnection {
58
82
  * sign-in. Idempotent. Offline, a local guest is used.
59
83
  */
60
84
  ensureGuest(): Promise<AuthResponse> {
61
- return this.withFallback<AuthResponse>(() => this.rest.call('ensureGuestSession'), () => {
85
+ return this.withFallback<AuthResponse>(async () => this.signedIn(await this.rest.call('ensureGuestSession')), () => {
62
86
  const known = this.store.getUser();
63
87
  if (known !== null) {
64
88
  return { user: known };
65
89
  }
66
- const guest: User = { id: 0, username: 'Guest-local', guest: true };
67
- this.store.setUser(guest);
68
- return { user: guest };
90
+ return this.signInLocally({ id: 0, username: 'Guest-local', guest: true });
69
91
  });
70
92
  }
71
93
 
@@ -75,32 +97,53 @@ export class HubProgress extends HubConnection {
75
97
  * the name is taken; the player should `login` instead.
76
98
  */
77
99
  claim(username: string, password: string): Promise<AuthResponse> {
78
- return this.withFallback<AuthResponse>(() => this.rest.call('claimGuestAccount', { username, password }), () => this.signInLocally({ id: 0, username, guest: false }));
100
+ return this.withFallback<AuthResponse>(async () => this.signedIn(await this.rest.call('claimGuestAccount', { username, password })), () => this.signInLocally({ id: 0, username, guest: false }));
79
101
  }
80
102
 
81
103
  /** Create an account and sign in. (Offline: a local user is stored.) */
82
104
  signup(username: string, password: string): Promise<AuthResponse> {
83
- return this.withFallback<AuthResponse>(() => this.rest.call('signUpWithPassword', { username, password }), () => this.signInLocally({ id: 0, username }));
105
+ return this.withFallback<AuthResponse>(async () => this.signedIn(await this.rest.call('signUpWithPassword', { username, password })), () => this.signInLocally({ id: 0, username }));
84
106
  }
85
107
 
86
108
  /** Sign in. (Offline: a local user is stored so signed-in flows work.) */
87
109
  login(username: string, password: string): Promise<AuthResponse> {
88
- return this.withFallback<AuthResponse>(() => this.rest.call('logInWithPassword', { username, password }), () => this.signInLocally({ id: 0, username }));
110
+ return this.withFallback<AuthResponse>(async () => this.signedIn(await this.rest.call('logInWithPassword', { username, password })), () => this.signInLocally({ id: 0, username }));
89
111
  }
90
112
 
91
113
  /** Sign out (ends the session shared by every game). */
92
114
  logout(): Promise<{ ok: true }> {
93
- return this.withFallback(() => this.rest.call('logOut'), () => {
94
- this.store.clearUser();
115
+ return this.withFallback(async () => {
116
+ const reply = await this.rest.call('logOut');
117
+ this.signedOut();
118
+ return reply;
119
+ }, () => {
120
+ this.signedOut();
95
121
  return { ok: true } as const;
96
122
  });
97
123
  }
98
124
 
99
125
  private signInLocally(user: User): AuthResponse {
100
- this.store.setUser(user);
126
+ this.remember(user);
101
127
  return { user };
102
128
  }
103
129
 
130
+ /** Remember who the hub signed in, so the cache's notes about the hub's copies are read for the right player. */
131
+ private signedIn(reply: AuthResponse): AuthResponse {
132
+ this.remember(reply.user);
133
+ return reply;
134
+ }
135
+
136
+ /** The cache and the page (`hub.userId()`, `hub.avatar`) learn who is signed in. */
137
+ private remember(user: User): void {
138
+ this.store.setUser(user);
139
+ this.account.note(toPageUser(user));
140
+ }
141
+
142
+ private signedOut(): void {
143
+ this.store.clearUser();
144
+ this.account.note(null);
145
+ }
146
+
104
147
  /** This game's profile for the current user, or `profile: null`. `TData` is the game's own data type. */
105
148
  getProfile<TData = Json>(): Promise<{ profile: Profile<TData> | null }> {
106
149
  return this.withFallback(
@@ -111,14 +154,11 @@ export class HubProgress extends HubConnection {
111
154
 
112
155
  /** Create or update this game's profile for the current user. */
113
156
  setProfile<TData extends GamePayload = Json>(patch: ProfileUpdate<TData>): Promise<{ profile: Profile<TData> }> {
114
- const queued: ProfilePatch = {};
115
- if (patch.displayName !== undefined) {
116
- queued.displayName = patch.displayName;
117
- }
118
- if (patch.data !== undefined) {
119
- queued.data = toStoredJson(patch.data);
120
- }
121
- return this.mutateQueued({ kind: 'setProfile', patch: queued }, () => {
157
+ const queued: ProfilePayload = {
158
+ ...(patch.displayName === undefined ? {} : { displayName: patch.displayName }),
159
+ ...(patch.data === undefined ? {} : { data: toStoredJson(patch.data) }),
160
+ };
161
+ return this.mutateOffline(PROGRESS_KINDS.profile, queued, () => {
122
162
  const stored = this.store.setProfile(this.slug ?? 'app', patch.displayName ?? null, queued.data ?? {});
123
163
  return { profile: { ...stored, data: toGameData<TData>(stored.data) } };
124
164
  });
@@ -129,24 +169,48 @@ export class HubProgress extends HubConnection {
129
169
  return this.withFallback(() => this.rest.call('listSaves', this.gameFields()), () => ({ saves: this.store.listSaves() }));
130
170
  }
131
171
 
132
- /** One slot's contents, or `save: null` when the slot is empty. `TData` is the game's own save type. */
172
+ /**
173
+ * One slot's contents, or `save: null` when the slot is empty. `TData` is
174
+ * the game's own save type. Offline it answers from the device's copy, but
175
+ * only when the device knows the hub's copy of the slot; otherwise (a fresh
176
+ * device or cleared browser) it rejects with a {@link HubError} whose
177
+ * `code` is `save_unknown` (`isOffline` is `true`), never a false "empty".
178
+ */
133
179
  getSave<TData = Json>(slot: string): Promise<{ save: Save<TData> | null }> {
134
180
  return this.withFallback(
135
- async () => ({ save: typedSave<TData>((await this.rest.call('getSave', { ...this.gameFields(), slot })).save) }),
136
- () => ({ save: typedSave<TData>(this.store.getSave(slot)) }),
181
+ async () => {
182
+ const { save } = await this.rest.call('getSave', { ...this.gameFields(), slot });
183
+ if (this.outbox.count() === 0) {
184
+ this.store.cacheCloudSave(slot, save);
185
+ }
186
+ return { save: typedSave<TData>(save) };
187
+ },
188
+ () => ({ save: typedSave<TData>(this.offlineSave(slot)) }),
137
189
  );
138
190
  }
139
191
 
192
+ /** The device's copy of `slot`, when it can be trusted offline (see {@link getSave}). */
193
+ private offlineSave(slot: string): Save | null {
194
+ // Trusted: a slot whose hub copy the device has seen, a slot it wrote itself and hasn't sent
195
+ // yet, or any slot of a player signed in only on this device (offline, never reached the hub).
196
+ const isLocalOnly = this.store.getUser()?.id === 0;
197
+ const hasQueuedWrite = this.outbox.pending().some((op) => op.kind === PROGRESS_KINDS.save && isRecord(op.payload) && op.payload.slot === slot);
198
+ if (!isLocalOnly && !hasQueuedWrite && this.store.cloudSave(slot) === undefined) {
199
+ throw new HubError(`Hub: offline, and this device hasn't seen save "${slot}" yet.`, undefined, { code: SAVE_UNKNOWN_CODE });
200
+ }
201
+ return this.store.getSave(slot);
202
+ }
203
+
140
204
  /** Create or overwrite a save slot. `saveData` is any JSON up to 64 KB. */
141
205
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- kept so v1 calls such as putSave<MySave>(...) still compile
142
206
  putSave<TData extends GamePayload = Json>(slot: string, saveData: TData, label?: string): Promise<{ ok: true; updatedAt: number }> {
143
207
  const stored = toStoredJson(saveData);
144
- return this.mutateQueued({ kind: 'putSave', slot, data: stored, label: label ?? null }, () => ({ ok: true as const, updatedAt: this.store.putSave(slot, stored, label ?? null) }));
208
+ return this.mutateOffline(PROGRESS_KINDS.save, { slot, data: stored, label: label ?? null }, () => ({ ok: true as const, updatedAt: this.store.putSave(slot, stored, label ?? null) }));
145
209
  }
146
210
 
147
211
  /** Delete a save slot. */
148
212
  deleteSave(slot: string): Promise<{ ok: true }> {
149
- return this.mutateQueued({ kind: 'deleteSave', slot }, () => {
213
+ return this.mutateOffline(PROGRESS_KINDS.save, { slot, deleted: true }, () => {
150
214
  this.store.deleteSave(slot);
151
215
  return { ok: true as const };
152
216
  });
@@ -162,27 +226,27 @@ export class HubProgress extends HubConnection {
162
226
 
163
227
  /** Overwrite a stat. */
164
228
  setStat(key: string, value: number): Promise<StatResult> {
165
- return this.mutateQueued({ kind: 'setStat', key, value }, () => ({ key, value: this.store.updateStat(key, value, 'set') }));
229
+ return this.mutateOffline(PROGRESS_KINDS.stat, { key, mode: 'set', value }, () => ({ key, value: this.store.updateStat(key, value, 'set') }));
166
230
  }
167
231
 
168
232
  /** Add to a stat (creating it at `amount` when absent). */
169
233
  incrStat(key: string, amount = 1): Promise<StatResult> {
170
- return this.mutateQueued({ kind: 'incrStat', key, amount }, () => ({ key, value: this.store.updateStat(key, amount, 'incr') }));
234
+ return this.mutateOffline(PROGRESS_KINDS.stat, { key, mode: 'incr', value: amount }, () => ({ key, value: this.store.updateStat(key, amount, 'incr') }));
171
235
  }
172
236
 
173
237
  /** Keep the larger of the stored and the given value. */
174
238
  maxStat(key: string, value: number): Promise<StatResult> {
175
- return this.mutateQueued({ kind: 'maxStat', key, value }, () => ({ key, value: this.store.updateStat(key, value, 'max') }));
239
+ return this.mutateOffline(PROGRESS_KINDS.stat, { key, mode: 'max', value }, () => ({ key, value: this.store.updateStat(key, value, 'max') }));
176
240
  }
177
241
 
178
242
  /** Submit a score. The board is created on first use; only a player's best score sticks. */
179
243
  submitScore<TMeta extends GamePayload = Json>(boardKey: string, score: number, opts: SubmitScoreOptions<TMeta> = {}): Promise<SubmitScoreResult> {
180
- const queued: QueuedScoreOptions = {
244
+ const queued: ScorePayload['opts'] = {
181
245
  ...(opts.title === undefined ? {} : { title: opts.title }),
182
246
  ...(opts.sort === undefined ? {} : { sort: opts.sort }),
183
247
  ...(opts.meta === undefined ? {} : { meta: toStoredJson(opts.meta) }),
184
248
  };
185
- return this.mutateQueued({ kind: 'submitScore', key: boardKey, score, opts: queued }, () => ({ ...this.store.submitScore(boardKey, score, queued), rank: 1 }));
249
+ return this.mutateOffline(PROGRESS_KINDS.score, { key: boardKey, score, opts: queued }, () => ({ ...this.store.submitScore(boardKey, score, queued), rank: 1 }));
186
250
  }
187
251
 
188
252
  /** One board's ranked entries (top `limit`, default 50). Offline: only the player's own best. */
package/src/core/hub.ts CHANGED
@@ -4,17 +4,29 @@
4
4
  * getters (`hub.input`, `hub.daily`, `hub.social`, `hub.mp`, `hub.rooms`,
5
5
  * `hub.race`, ...), so v1 code such as `hub.input.define(...)` works unchanged.
6
6
  */
7
- import type { HubVersion } from '@diffenderfer-games/hub-contract';
8
- import { getDaily, type DailyHost, type DailySystem } from '../daily';
7
+ import type { HubVersion, ResumeSlot } from '@diffenderfer-games/hub-contract';
8
+ import { getDaily, type DailyCompleteResponse, type DailyHost, type DailySystem } from '../daily';
9
9
  import { getInput, getUiStack, type InputSystem } from '../input';
10
10
  import { mp } from '../mp';
11
11
  import { race } from '../race';
12
12
  import { rooms } from '../rooms';
13
+ import { pageBridge } from '../realtime/bridge';
14
+ import { isInHubRoom } from '../realtime/rooms-forwarder';
13
15
  import { linkRealtimeToHub, notify, presence, social } from '../social';
14
- import { OfflineError, toHubError } from './errors';
16
+ import { HubAvatar } from './avatar/hub-avatar';
17
+ import { HubError, OfflineError, toHubError } from './errors';
15
18
  import { HubEconomy } from './hub-economy';
19
+ import { AVATAR_KIND, DAILY_KIND, RESUME_KIND, SAVED_KIND } from './offline/hub-kinds';
20
+ import { readDailyCompletion } from './offline/hub-kinds/daily-kind';
16
21
  import { hubMenu, type HubMenuControl } from './menu-control';
17
- import { emitWindowEvent } from './page';
22
+ import { detectGameTitle, emitWindowEvent } from './page';
23
+ import { HubResume, type ResumeHost } from './resume/hub-resume';
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';
18
30
  import { fetchGameServerTicket } from './ticket';
19
31
  import { clientSupport, readHubVersion, type ClientSupport } from './version/hub-version';
20
32
  import { HUB_CLIENT_VERSION } from './version/client-version.gen';
@@ -25,6 +37,14 @@ export class Hub extends HubEconomy {
25
37
  /** This client's package version (sent as `x-hub-client`). */
26
38
  readonly clientVersion: string = HUB_CLIENT_VERSION;
27
39
 
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>();
47
+
28
48
  /**
29
49
  * The hub's version and the client versions it supports (`GET /_api/version`),
30
50
  * read once per page. `null` for a hub from before versioning.
@@ -101,14 +121,210 @@ export class Hub extends HubEconomy {
101
121
  return race;
102
122
  }
103
123
 
104
- /** What the daily system needs from this client: its REST calls and window events. */
124
+ /**
125
+ * Resume a saved game: `hub.resume.save({ state, progress })` during a long
126
+ * single-player run, `clear()` when it ends, `load()` and `requested` to
127
+ * pick it up again (docs: "Resume saved games"). Needs `game.resumable: true`.
128
+ */
129
+ get resume(): HubResume {
130
+ this.resumeApi ??= new HubResume(this.resumeHost());
131
+ return this.resumeApi;
132
+ }
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
+
238
+ /** What `hub.resume` needs from this client. */
239
+ private resumeHost(): ResumeHost {
240
+ return {
241
+ slug: this.slug,
242
+ local: this.resumeStore,
243
+ currentUserId: () => this.store.getUser()?.id ?? null,
244
+ gameTitle: () => detectGameTitle(this.slug ?? ''),
245
+ blocker: () => {
246
+ if (race.current() !== null) {
247
+ return 'race';
248
+ }
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;
257
+ },
258
+ send: async (payload) => {
259
+ await this.mutateOffline(RESUME_KIND, payload, () => undefined);
260
+ },
261
+ fetchSlot: () => this.withFallback<ResumeSlot | null | undefined>(async () => {
262
+ try {
263
+ return (await this.rest.call('getResumeSlot', this.gameFields())).resume;
264
+ } catch (failure) {
265
+ if (failure instanceof HubError && failure.status === 401) {
266
+ return null;
267
+ }
268
+ throw failure;
269
+ }
270
+ }, () => undefined),
271
+ fetchList: () => this.withFallback(async () => (await this.rest.call('listMyResumeSlots')).games, () => undefined),
272
+ clearOther: async (slug) => {
273
+ await this.rest.call('clearResumeSlot', { slug });
274
+ },
275
+ onChange: () => {
276
+ this.savedApi?.changed();
277
+ },
278
+ };
279
+ }
280
+
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. */
105
301
  private dailyHost(): DailyHost {
106
302
  return {
107
303
  slug: this.slug,
108
304
  status: () => this.rest.call('getGameDailyChallenges', this.gameFields()),
109
305
  all: () => this.rest.call('listDailyChallengesForAllGames'),
110
- start: (day) => this.rest.call('startDailyChallenge', { ...this.gameFields(), day }),
111
- complete: (day, token, meta) => this.rest.call('completeDailyChallenge', { ...this.gameFields(), day, token, meta }),
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'] }),
112
328
  emit: (eventName, detail) => {
113
329
  emitWindowEvent(eventName, detail);
114
330
  },
@@ -125,6 +341,11 @@ linkRealtimeToHub({
125
341
  /** A ready-to-use client, with the slug and API base detected from the page. */
126
342
  export const hub: Hub = new Hub();
127
343
 
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`).
346
+ isResumeRequested();
347
+ savedOpenedOnPage();
348
+
128
349
  /** Create a client for another slug or API base, or with other offline behaviour. */
129
350
  export function createHub(options: HubOptions = {}): Hub {
130
351
  return new Hub(options);
@@ -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
+ }