@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
@@ -0,0 +1,221 @@
1
+ /**
2
+ * `hub.saved`: the player's games in progress, of every kind (a daily
3
+ * challenge, a single-player run, an online game), for a game's own
4
+ * "Continue" list and for the hub's "Your games" lists.
5
+ *
6
+ * - `list()` is this game's, `listAll()` every game's (the home page, the
7
+ * menu); both merge in what this device saved and the hub hasn't heard
8
+ * of yet, and work offline from the device copies.
9
+ * - `opened` says which instance the page was opened for (synchronous).
10
+ * - `onOpen(handler)` lets a running game switch to an instance picked from
11
+ * the hub menu without a reload; `open(id)` is that same path.
12
+ * - `on('change')` fires when this game's list changes (a save here or on
13
+ * another device, a finish, a discard, a move in an online game).
14
+ */
15
+ import type { SavedCounts } from '@diffenderfer-games/hub-contract';
16
+ import { isBrowserPage } from '../page';
17
+ import { readField } from '../../realtime/cross-bundle';
18
+ import { mergeSavedLists, type DeviceCopies } from './saved-list';
19
+ import { countSaved } from './saved-order';
20
+ import { SAVED_CHANGE_EVENT, SAVED_OPEN_EVENT, savedGameUrl, savedOpenedOnPage, type SavedOpened } from './saved-open';
21
+ import type { SavedEntry, SavedListOptions } from './saved-types';
22
+
23
+ /** How long changes must be quiet before `on('change')` listeners hear the new list, ms. */
24
+ export const SAVED_CHANGE_QUIET_MS = 300;
25
+
26
+ /** What `hub.saved` needs from its client. */
27
+ export interface SavedHost {
28
+ /** This game's slug; `null` on the home page. */
29
+ readonly slug: string | null;
30
+ /** The hub's list (one game, or all), or `undefined` when it can't be reached. */
31
+ fetchList(game: string | null): Promise<readonly SavedEntry[] | undefined>;
32
+ /** What this device holds for the signed-in player. */
33
+ deviceCopies(): DeviceCopies;
34
+ /** Discard a daily or run (works offline where it can). */
35
+ discard(entry: SavedEntry): Promise<void>;
36
+ /** Go to a URL (`location.assign`). */
37
+ navigate(url: string): void;
38
+ }
39
+
40
+ /** A change listener: this game's games in progress. */
41
+ export type SavedChangeListener = (games: SavedEntry[]) => void;
42
+
43
+ /** An `onOpen` handler: switch to this instance without a reload. */
44
+ export type SavedOpenHandler = (instance: SavedEntry) => void | Promise<void>;
45
+
46
+ /** The detail of {@link SAVED_OPEN_EVENT}. */
47
+ export interface SavedOpenDetail {
48
+ readonly entry: SavedEntry;
49
+ }
50
+
51
+ /** Whether an entry is of one of `kinds` (all when absent). */
52
+ function isOfKinds(entry: SavedEntry, options: SavedListOptions): boolean {
53
+ return options.kinds === undefined || options.kinds.includes(entry.kind);
54
+ }
55
+
56
+ /** `hub.saved`. */
57
+ export class HubSaved {
58
+ private readonly host: SavedHost;
59
+ /** Each handler with the kinds it takes (every kind when `undefined`). */
60
+ private readonly openHandlers = new Map<SavedOpenHandler, SavedListOptions>();
61
+ private readonly changeListeners = new Set<SavedChangeListener>();
62
+ private isListeningForOpen = false;
63
+ private isListeningForChange = false;
64
+ private changeTimer: ReturnType<typeof setTimeout> | null = null;
65
+
66
+ constructor(host: SavedHost) {
67
+ this.host = host;
68
+ }
69
+
70
+ /**
71
+ * The instance this page was opened for (`?hub_open=<id>&hub_kind=<kind>`,
72
+ * taken out of the address bar), or `null`. Synchronous: read it before
73
+ * routing or drawing a title screen.
74
+ */
75
+ get opened(): SavedOpened | null {
76
+ return savedOpenedOnPage();
77
+ }
78
+
79
+ /** This game's games in progress: online games waiting on the player first, then the newest. Works offline. */
80
+ async list(options: SavedListOptions = {}): Promise<SavedEntry[]> {
81
+ const slug = this.host.slug;
82
+ if (slug === null) {
83
+ return [];
84
+ }
85
+ const hub = await this.host.fetchList(slug);
86
+ return mergeSavedLists(hub, this.host.deviceCopies(), slug).filter((entry) => isOfKinds(entry, options));
87
+ }
88
+
89
+ /** Every game's games in progress (the home page and the menu). Online, plus this device's copies. */
90
+ async listAll(): Promise<SavedEntry[]> {
91
+ return mergeSavedLists(await this.host.fetchList(null), this.host.deviceCopies(), null);
92
+ }
93
+
94
+ /** The badge counts across every game. */
95
+ async counts(): Promise<SavedCounts> {
96
+ return countSaved(await this.listAll());
97
+ }
98
+
99
+ /**
100
+ * Switch to an instance of THIS game when the player picks it from the
101
+ * hub menu, without a reload. Without a handler the page reloads with
102
+ * `?hub_open=<id>`. With `{ kinds }` the handler takes only those kinds;
103
+ * the others still reload the page (with `?hub_resume=1` or `?daily=` for
104
+ * a saved run or a daily). Returns a function that removes the handler.
105
+ */
106
+ onOpen(handler: SavedOpenHandler, options: SavedListOptions = {}): () => void {
107
+ this.openHandlers.set(handler, options);
108
+ this.listenForOpen();
109
+ return () => {
110
+ this.openHandlers.delete(handler);
111
+ };
112
+ }
113
+
114
+ /** Open an instance: in this game through `onOpen` (or a reload), in another game by going there. */
115
+ async open(id: string): Promise<void> {
116
+ const entry = (await this.listAll()).find((candidate) => candidate.id === id);
117
+ if (entry === undefined) {
118
+ return;
119
+ }
120
+ this.openEntry(entry);
121
+ }
122
+
123
+ /** Open an entry already in hand (the hub's lists). */
124
+ openEntry(entry: SavedEntry): void {
125
+ if (entry.game === this.host.slug && isBrowserPage()) {
126
+ const event = new CustomEvent<SavedOpenDetail>(SAVED_OPEN_EVENT, { detail: { entry }, cancelable: true });
127
+ if (!window.dispatchEvent(event)) {
128
+ return;
129
+ }
130
+ }
131
+ this.host.navigate(savedGameUrl(entry));
132
+ }
133
+
134
+ /**
135
+ * Discard a daily challenge or a saved run (the caller asks first; the
136
+ * hub's own lists ask for themselves). Online games are left with
137
+ * `hub.turns`, never discarded.
138
+ */
139
+ async discard(id: string): Promise<void> {
140
+ const entry = (await this.listAll()).find((candidate) => candidate.id === id);
141
+ if (entry === undefined || entry.kind === 'turns') {
142
+ return;
143
+ }
144
+ await this.host.discard(entry);
145
+ this.changed();
146
+ }
147
+
148
+ /** Hear when this game's list changes; returns a function that stops listening. */
149
+ on(_eventName: 'change', listener: SavedChangeListener): () => void {
150
+ this.changeListeners.add(listener);
151
+ this.listenForChange();
152
+ return () => {
153
+ this.changeListeners.delete(listener);
154
+ };
155
+ }
156
+
157
+ /** Something of this game's changed (a save here, an event from the hub): tell the listeners, once per quiet spell. */
158
+ changed(): void {
159
+ if (this.changeListeners.size === 0 || this.changeTimer !== null) {
160
+ return;
161
+ }
162
+ this.changeTimer = setTimeout(() => {
163
+ this.changeTimer = null;
164
+ void this.list().then((games) => {
165
+ for (const listener of this.changeListeners) {
166
+ listener(games);
167
+ }
168
+ }, () => undefined);
169
+ }, SAVED_CHANGE_QUIET_MS);
170
+ }
171
+
172
+ private listenForOpen(): void {
173
+ if (this.isListeningForOpen || !isBrowserPage()) {
174
+ return;
175
+ }
176
+ this.isListeningForOpen = true;
177
+ window.addEventListener(SAVED_OPEN_EVENT, (event) => {
178
+ const entry = event instanceof CustomEvent ? readEntry(event.detail) : null;
179
+ const handlers = entry === null ? [] : [...this.openHandlers].filter(([, options]) => isOfKinds(entry, options)).map(([handler]) => handler);
180
+ if (entry === null || entry.game !== this.host.slug || handlers.length === 0 || event.defaultPrevented) {
181
+ return;
182
+ }
183
+ event.preventDefault();
184
+ for (const handler of handlers) {
185
+ void Promise.resolve(handler(entry)).catch(() => undefined);
186
+ }
187
+ });
188
+ }
189
+
190
+ private listenForChange(): void {
191
+ if (this.isListeningForChange || !isBrowserPage()) {
192
+ return;
193
+ }
194
+ this.isListeningForChange = true;
195
+ window.addEventListener(SAVED_CHANGE_EVENT, (event) => {
196
+ const game = event instanceof CustomEvent ? readField(event.detail, 'game') : undefined;
197
+ if (game === this.host.slug) {
198
+ this.changed();
199
+ }
200
+ });
201
+ }
202
+ }
203
+
204
+ /** The entry in an open event's detail (it may come from another bundle), or `null`. */
205
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: an event detail another bundle may have made
206
+ function readEntry(detail: unknown): SavedEntry | null {
207
+ const entry = readField(detail, 'entry');
208
+ const id = readField(entry, 'id');
209
+ const game = readField(entry, 'game');
210
+ const kind = readField(entry, 'kind');
211
+ if (typeof id !== 'string' || typeof game !== 'string' || (kind !== 'daily' && kind !== 'run' && kind !== 'turns')) {
212
+ return null;
213
+ }
214
+ return isSavedEntry(entry) ? entry : null;
215
+ }
216
+
217
+ /** Shape check for an entry made by another copy of this client. */
218
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: see readEntry
219
+ function isSavedEntry(candidate: unknown): candidate is SavedEntry {
220
+ return typeof readField(candidate, 'path') === 'string' && typeof readField(candidate, 'progress') === 'object';
221
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The device's copies of daily challenges in progress
3
+ * (`hub:<slug>:daily:<day>`), in the browser storage every game on the site
4
+ * shares, written the moment a game saves a daily (before the debounced
5
+ * send). So a daily resumes offline and right after a reload, the queued
6
+ * send stays small (it reads the newest state here), and the lists show a
7
+ * daily the hub hasn't heard of yet, or hide one finished or discarded
8
+ * offline. A copy belongs to the player who made it (`userId`); `id` is the
9
+ * hub's instance id, or `local:<slug>:<day>` for a day started offline.
10
+ */
11
+ import type { Json } from '@diffenderfer-games/hub-contract';
12
+ import { isJson, isNullableString, isRecord, optionalNumber, optionalString, parseJsonText } from '../storage/json-guards';
13
+ import type { KeyValueStore } from '../storage/key-value';
14
+
15
+ /** A daily challenge as this device last saw it. */
16
+ export interface LocalDaily {
17
+ readonly id: string;
18
+ readonly game: string;
19
+ readonly day: string;
20
+ readonly seed: number;
21
+ /** `active` in progress; `finished` (won) or `discarded`, kept until the hub has it. */
22
+ readonly status: 'active' | 'finished' | 'discarded';
23
+ readonly userId: number | null;
24
+ /** When it last changed here, epoch ms. */
25
+ readonly at: number;
26
+ readonly label: string;
27
+ readonly fraction: number | null;
28
+ readonly title: string | null;
29
+ readonly version: number;
30
+ readonly gameTitle: string;
31
+ /** The state the game last saved, `null` before its first save. */
32
+ readonly state: Json;
33
+ readonly isSynced: boolean;
34
+ }
35
+
36
+ const KEY_PATTERN = /^hub:(.+):daily:(\d{4}-\d{2}-\d{2})$/;
37
+ const STATUSES: readonly LocalDaily['status'][] = ['active', 'finished', 'discarded'];
38
+
39
+ function keyFor(game: string, day: string): string {
40
+ return `hub:${game}:daily:${day}`;
41
+ }
42
+
43
+ /** A stored copy read back, or `null` when missing or malformed. */
44
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: a record read back from localStorage
45
+ function readCopy(stored: unknown): LocalDaily | null {
46
+ if (!isRecord(stored)) {
47
+ return null;
48
+ }
49
+ const id = optionalString(stored.id);
50
+ const game = optionalString(stored.game);
51
+ const day = optionalString(stored.day);
52
+ const seed = optionalNumber(stored.seed);
53
+ const at = optionalNumber(stored.at);
54
+ const label = optionalString(stored.label);
55
+ const version = optionalNumber(stored.version);
56
+ const gameTitle = optionalString(stored.gameTitle);
57
+ const userId = stored.userId === null ? null : optionalNumber(stored.userId);
58
+ const fraction = stored.fraction === null ? null : optionalNumber(stored.fraction);
59
+ const status = STATUSES.find((candidate) => candidate === stored.status);
60
+ if (id === undefined || game === undefined || day === undefined || seed === undefined || at === undefined || label === undefined
61
+ || version === undefined || gameTitle === undefined || userId === undefined || fraction === undefined || status === undefined
62
+ || !isNullableString(stored.title) || !isJson(stored.state)) {
63
+ return null;
64
+ }
65
+ return { id, game, day, seed, status, userId, at, label, fraction, title: stored.title, version, gameTitle, state: stored.state, isSynced: stored.isSynced === true };
66
+ }
67
+
68
+ /** The device copies of daily challenges. */
69
+ export class LocalDailyStore {
70
+ private readonly store: KeyValueStore;
71
+
72
+ constructor(store: KeyValueStore) {
73
+ this.store = store;
74
+ }
75
+
76
+ /** The copy of `game`'s `day`, or `null`. */
77
+ read(game: string, day: string): LocalDaily | null {
78
+ const text = this.store.read(keyFor(game, day));
79
+ return text === null ? null : readCopy(parseJsonText(text));
80
+ }
81
+
82
+ /** Store a copy; answers whether it stuck (a full storage refuses a large state). */
83
+ write(copy: LocalDaily): boolean {
84
+ const text = JSON.stringify(copy);
85
+ this.store.write(keyFor(copy.game, copy.day), text);
86
+ return this.store.read(keyFor(copy.game, copy.day)) === text;
87
+ }
88
+
89
+ /** Forget a copy. */
90
+ remove(game: string, day: string): void {
91
+ this.store.remove(keyFor(game, day));
92
+ }
93
+
94
+ /** The hub has the change made at `at` (and calls the instance `id`): mark it synced; a closed copy is forgotten. */
95
+ markSynced(game: string, day: string, at: number, id?: string): void {
96
+ const copy = this.read(game, day);
97
+ if (copy === null || copy.at !== at) {
98
+ if (copy !== null && id !== undefined && copy.id !== id) {
99
+ this.write({ ...copy, id });
100
+ }
101
+ return;
102
+ }
103
+ if (copy.status === 'active') {
104
+ this.write({ ...copy, id: id ?? copy.id, isSynced: true });
105
+ } else {
106
+ this.remove(game, day);
107
+ }
108
+ }
109
+
110
+ /** Every copy of `userId`'s, any game. */
111
+ all(userId: number | null): LocalDaily[] {
112
+ const copies: LocalDaily[] = [];
113
+ for (const key of this.store.keys()) {
114
+ const match = KEY_PATTERN.exec(key);
115
+ const copy = match?.[1] === undefined || match[2] === undefined ? null : this.read(match[1], match[2]);
116
+ if (copy !== null && copy.userId === userId) {
117
+ copies.push(copy);
118
+ }
119
+ }
120
+ return copies;
121
+ }
122
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * A player's games in progress: the hub's list merged with this device's
3
+ * copies of runs (`hub.resume`) and daily challenges (`hub.daily.save`), so
4
+ * one saved offline (or just before the tab closed) shows at once, and one
5
+ * cleared or discarded offline disappears at once. Pure.
6
+ */
7
+ import { compareSaved } from './saved-order';
8
+ import type { LocalResume, LocalRun } from '../resume/local-resume';
9
+ import type { LocalDaily } from './local-saved';
10
+ import type { SavedEntry } from './saved-types';
11
+
12
+ /** The id a run saved only on this device goes by until the hub has it. */
13
+ export function localRunId(slug: string): string {
14
+ return `local:${slug}:run`;
15
+ }
16
+
17
+ /** The id a daily started only on this device goes by until the hub has it. */
18
+ export function localDailyId(slug: string, day: string): string {
19
+ return `local:${slug}:${day}`;
20
+ }
21
+
22
+ /** A device run as an entry, with the game's catalog fields from the hub's entry when it has one. */
23
+ function runEntry(slug: string, run: LocalRun, known: SavedEntry | undefined): SavedEntry {
24
+ return {
25
+ id: known?.id ?? localRunId(slug),
26
+ game: slug,
27
+ gameTitle: known?.gameTitle ?? run.gameTitle,
28
+ path: known?.path ?? `/${slug}`,
29
+ hasImage: known?.hasImage ?? false,
30
+ kind: 'run',
31
+ status: 'active',
32
+ title: run.title,
33
+ progress: { label: run.label, fraction: run.fraction },
34
+ version: run.version,
35
+ createdAt: known?.createdAt ?? run.at,
36
+ updatedAt: run.at,
37
+ finishedAt: null,
38
+ isLocalOnly: !run.isSynced,
39
+ };
40
+ }
41
+
42
+ /** One game's run: the hub's, the device's newer copy, or none (cleared on the device). */
43
+ function mergeRun(slug: string, hub: SavedEntry | undefined, copy: LocalResume | undefined, isHubKnown: boolean): SavedEntry | null {
44
+ if (copy === undefined) {
45
+ return hub ?? null;
46
+ }
47
+ if (copy.status === 'cleared') {
48
+ return hub !== undefined && hub.updatedAt > copy.at ? hub : null;
49
+ }
50
+ if (hub !== undefined && hub.updatedAt >= copy.at) {
51
+ return hub;
52
+ }
53
+ // The hub hasn't got this run: show it while it waits to be sent, or when the hub can't be asked.
54
+ return !copy.isSynced || !isHubKnown || hub !== undefined ? runEntry(slug, copy, hub) : null;
55
+ }
56
+
57
+ /** A device daily as an entry. */
58
+ function dailyEntry(daily: LocalDaily, known: SavedEntry | undefined): SavedEntry {
59
+ return {
60
+ id: daily.id,
61
+ game: daily.game,
62
+ gameTitle: known?.gameTitle ?? daily.gameTitle,
63
+ path: known?.path ?? `/${daily.game}`,
64
+ hasImage: known?.hasImage ?? false,
65
+ kind: 'daily',
66
+ status: 'active',
67
+ title: daily.title,
68
+ progress: { label: daily.label, fraction: daily.fraction },
69
+ version: daily.version,
70
+ createdAt: known?.createdAt ?? daily.at,
71
+ updatedAt: daily.at,
72
+ finishedAt: null,
73
+ daily: { day: daily.day, seed: daily.seed },
74
+ isLocalOnly: !daily.isSynced,
75
+ };
76
+ }
77
+
78
+ /** What the device holds, for one player. */
79
+ export interface DeviceCopies {
80
+ /** Run copies by slug (`hub.resume`). */
81
+ readonly runs: ReadonlyMap<string, LocalResume>;
82
+ /** Daily copies (`hub.daily`), any game. */
83
+ readonly dailies: readonly LocalDaily[];
84
+ }
85
+
86
+ /** One daily: the hub's entry, the device's newer copy, or none (discarded or finished on the device). */
87
+ function mergeDaily(hub: SavedEntry | undefined, copy: LocalDaily | undefined, isHubKnown: boolean): SavedEntry | null {
88
+ if (copy === undefined) {
89
+ return hub ?? null;
90
+ }
91
+ if (copy.status !== 'active') {
92
+ return hub !== undefined && hub.updatedAt > copy.at ? hub : null;
93
+ }
94
+ if (hub !== undefined && hub.updatedAt >= copy.at) {
95
+ return hub;
96
+ }
97
+ return !copy.isSynced || !isHubKnown || hub !== undefined ? dailyEntry(copy, hub) : null;
98
+ }
99
+
100
+ /** The key that pairs a hub daily with a device copy: its game and day. */
101
+ const dailyKey = (game: string, day: string): string => `${game}#${day}`;
102
+
103
+ /**
104
+ * The entries to show, in the shared list order. `hub` is the hub's list,
105
+ * or `undefined` when it couldn't be reached; `copies` are the device's
106
+ * (already only the player's); `game` limits the device copies to one game.
107
+ */
108
+ export function mergeSavedLists(hub: readonly SavedEntry[] | undefined, copies: DeviceCopies, game: string | null): SavedEntry[] {
109
+ const isHubKnown = hub !== undefined;
110
+ const fromHub = hub ?? [];
111
+ const runs = new Map(fromHub.filter((entry) => entry.kind === 'run').map((entry) => [entry.game, entry] as const));
112
+ const dailies = new Map(fromHub.flatMap((entry) => (entry.kind === 'daily' && entry.daily !== undefined ? [[dailyKey(entry.game, entry.daily.day), entry] as const] : [])));
113
+ const runCopies = new Map([...copies.runs].filter(([slug]) => game === null || slug === game));
114
+ const dailyCopies = new Map(copies.dailies.filter((copy) => game === null || copy.game === game).map((copy) => [dailyKey(copy.game, copy.day), copy] as const));
115
+ const merged: SavedEntry[] = fromHub.filter((entry) => entry.kind === 'turns');
116
+ for (const slug of new Set([...runs.keys(), ...runCopies.keys()])) {
117
+ const entry = mergeRun(slug, runs.get(slug), runCopies.get(slug), isHubKnown);
118
+ if (entry !== null) {
119
+ merged.push(entry);
120
+ }
121
+ }
122
+ for (const key of new Set([...dailies.keys(), ...dailyCopies.keys()])) {
123
+ const entry = mergeDaily(dailies.get(key), dailyCopies.get(key), isHubKnown);
124
+ if (entry !== null) {
125
+ merged.push(entry);
126
+ }
127
+ }
128
+ return merged.sort(compareSaved);
129
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Opening one saved game in its game page: the address the hub's lists use
3
+ * (`/<slug>/?hub_open=<id>&hub_kind=<kind>`, plus `hub_resume=1` for a run
4
+ * and `daily=<day>` for a daily, so games that only know `hub.resume` or
5
+ * `hub.daily` restore it too), and reading it back on arrival. The first
6
+ * client copy on the page takes the parameters out of the address bar (a
7
+ * reload or a shared link doesn't open it again) and records the answer on
8
+ * `window`, so every copy (a game's bundled client, the host's
9
+ * `/_hub/hub.js`) answers the same.
10
+ *
11
+ * Picking a game of THIS page from the hub menu fires
12
+ * {@link SAVED_OPEN_EVENT} on `window`: a client with an `onOpen` handler
13
+ * cancels it and switches in place; otherwise the menu loads the address.
14
+ */
15
+ import type { SavedGame, SavedKind } from '@diffenderfer-games/hub-contract';
16
+ import { readField } from '../../realtime/cross-bundle';
17
+ import { isBrowserPage } from '../page';
18
+ import { RESUME_REQUEST_PARAM } from '../resume/resume-request';
19
+
20
+ /** The instance id parameter (the contract's `SAVED_OPEN_PARAM`). */
21
+ export const SAVED_OPEN_PARAM = 'hub_open';
22
+
23
+ /** The instance kind parameter (the contract's `SAVED_KIND_PARAM`). */
24
+ export const SAVED_KIND_PARAM = 'hub_kind';
25
+
26
+ /** The window event the hub menu fires to open an instance of this page's game (cancelable). */
27
+ export const SAVED_OPEN_EVENT = 'hub:saved-open';
28
+
29
+ /** The window event the social runtime fires for each `ev:saved` (detail: the `SavedGame`). */
30
+ export const SAVED_CHANGE_EVENT = 'hub:saved';
31
+
32
+ /** Where the answer is kept for the page. */
33
+ const PAGE_FLAG = '__HUB_SAVED_OPENED__';
34
+
35
+ /** The instance a page was opened for. */
36
+ export interface SavedOpened {
37
+ readonly id: string;
38
+ readonly kind: SavedKind;
39
+ }
40
+
41
+ const KINDS: readonly SavedKind[] = ['daily', 'run', 'turns'];
42
+
43
+ /** `text` as a kind, or `null`. */
44
+ function asKind(text: string | null): SavedKind | null {
45
+ return KINDS.find((kind) => kind === text) ?? null;
46
+ }
47
+
48
+ /** The address that opens `game` in its page. */
49
+ export function savedGameUrl(game: Pick<SavedGame, 'id' | 'kind' | 'path' | 'daily'>): string {
50
+ const params = new URLSearchParams();
51
+ if (game.kind === 'run') {
52
+ params.set(RESUME_REQUEST_PARAM, '1');
53
+ }
54
+ if (game.kind === 'daily' && game.daily !== undefined) {
55
+ params.set('daily', game.daily.day);
56
+ }
57
+ params.set(SAVED_OPEN_PARAM, game.id);
58
+ params.set(SAVED_KIND_PARAM, game.kind);
59
+ return `${game.path.replace(/\/+$/, '')}/?${params.toString()}`;
60
+ }
61
+
62
+ /** Read and strip `hub_open` / `hub_kind`; `null` when absent or malformed. */
63
+ function takeParams(): SavedOpened | null {
64
+ try {
65
+ const url = new URL(location.href);
66
+ const id = url.searchParams.get(SAVED_OPEN_PARAM);
67
+ const kind = asKind(url.searchParams.get(SAVED_KIND_PARAM));
68
+ if (!url.searchParams.has(SAVED_OPEN_PARAM) && !url.searchParams.has(SAVED_KIND_PARAM)) {
69
+ return null;
70
+ }
71
+ url.searchParams.delete(SAVED_OPEN_PARAM);
72
+ url.searchParams.delete(SAVED_KIND_PARAM);
73
+ try {
74
+ history.replaceState(history.state, '', `${url.pathname}${url.search}${url.hash}`);
75
+ } catch {
76
+ // A sandboxed page that refuses replaceState keeps them; the answer is still recorded.
77
+ }
78
+ return id === null || id === '' || kind === null ? null : { id, kind };
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /** A recorded answer read back. */
85
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: a window global another bundle may have set
86
+ function readRecorded(value: unknown): SavedOpened | null {
87
+ const id = readField(value, 'id');
88
+ const kind = readField(value, 'kind');
89
+ const savedKind = asKind(typeof kind === 'string' ? kind : null);
90
+ return typeof id === 'string' && savedKind !== null ? { id, kind: savedKind } : null;
91
+ }
92
+
93
+ /** The instance this page was opened for, or `null` (synchronous, so a game can route before it draws). */
94
+ export function savedOpenedOnPage(): SavedOpened | null {
95
+ if (!isBrowserPage()) {
96
+ return null;
97
+ }
98
+ if (Reflect.has(window, PAGE_FLAG)) {
99
+ return readRecorded(Reflect.get(window, PAGE_FLAG));
100
+ }
101
+ const opened = takeParams();
102
+ Reflect.set(window, PAGE_FLAG, opened);
103
+ return opened;
104
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The order and badge counts of saved games, the same rules as the
3
+ * contract's `compareSaved` and `countSaved` (the hub's `GET /saved` and
4
+ * every list agree; the browser client carries no contract code, so this
5
+ * copy is kept in step by a test). Pure.
6
+ */
7
+ import type { SavedCounts } from '@diffenderfer-games/hub-contract';
8
+ import type { SavedEntry } from './saved-types';
9
+
10
+ /** The fields the order and counts read. */
11
+ type Listed = Pick<SavedEntry, 'id' | 'kind' | 'updatedAt' | 'turns'>;
12
+
13
+ /** The player must move in this online game now. */
14
+ export function isYourTurn(entry: Pick<SavedEntry, 'turns'>): boolean {
15
+ return entry.turns?.isMyTurn === true && entry.turns.phase === 'playing';
16
+ }
17
+
18
+ /** Online games waiting on the player first (oldest turn first), then the newest. */
19
+ export function compareSaved(a: Listed, b: Listed): number {
20
+ const isATurn = isYourTurn(a);
21
+ const isBTurn = isYourTurn(b);
22
+ if (isATurn !== isBTurn) {
23
+ return isATurn ? -1 : 1;
24
+ }
25
+ if (isATurn && a.turns !== undefined && b.turns !== undefined && a.turns.turnStartedAt !== b.turns.turnStartedAt) {
26
+ return a.turns.turnStartedAt - b.turns.turnStartedAt;
27
+ }
28
+ if (a.updatedAt !== b.updatedAt) {
29
+ return b.updatedAt - a.updatedAt;
30
+ }
31
+ return a.id < b.id ? 1 : -1;
32
+ }
33
+
34
+ /** The badge counts of a list of games in progress. */
35
+ export function countSaved(entries: readonly Listed[]): SavedCounts {
36
+ const counts = { yourTurn: 0, dailies: 0, runs: 0, waiting: 0, total: entries.length };
37
+ for (const entry of entries) {
38
+ if (entry.kind === 'daily') {
39
+ counts.dailies += 1;
40
+ } else if (entry.kind === 'run') {
41
+ counts.runs += 1;
42
+ } else if (isYourTurn(entry)) {
43
+ counts.yourTurn += 1;
44
+ } else {
45
+ counts.waiting += 1;
46
+ }
47
+ }
48
+ return counts;
49
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `hub.saved`'s public types: one game in progress as the lists show it,
3
+ * the badge counts, and what `opened` says about the page.
4
+ */
5
+ import type { SavedCounts, SavedDaily, SavedGame, SavedKind, SavedProgress, SavedStatus, TurnSummary } from '@diffenderfer-games/hub-contract';
6
+
7
+ export type { SavedCounts, SavedDaily, SavedKind, SavedProgress, SavedStatus, TurnSummary };
8
+ export type { SavedOpened } from './saved-open';
9
+
10
+ /**
11
+ * One game in progress: a daily challenge, a single-player run or an online
12
+ * game. Its `id` is the hub's, or `local:…` for one saved only on this
13
+ * device so far.
14
+ */
15
+ export interface SavedEntry extends Omit<SavedGame, 'id'> {
16
+ readonly id: string;
17
+ /** Saved on this device and not yet sent to the hub (offline, or the tab closed first). */
18
+ readonly isLocalOnly?: boolean;
19
+ }
20
+
21
+ /** Which kinds to list. */
22
+ export interface SavedListOptions {
23
+ readonly kinds?: readonly SavedKind[];
24
+ }
@@ -72,3 +72,9 @@ export function parseJsonText(text: string): unknown {
72
72
  return undefined;
73
73
  }
74
74
  }
75
+
76
+ /** Whether `candidate` is a JSON object (not an array or `null`). */
77
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: checks a value read from storage
78
+ export function isJsonObject(candidate: unknown): candidate is { readonly [key: string]: Json } {
79
+ return isRecord(candidate) && Object.values(candidate).every(isJson);
80
+ }