@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,130 @@
1
+ /**
2
+ * The device's copy of each game's resume slot (`hub:<slug>:resume`), in the
3
+ * browser storage every game on the site shares. It is written the moment a
4
+ * game saves, before the debounced send, so:
5
+ * - `hub.resume.load()` works offline and right after a reload;
6
+ * - the queued send reads the newest state from here, so the outbox entry
7
+ * stays small (it carries the state itself only when this copy couldn't
8
+ * be stored);
9
+ * - the home page's Resume card can show a run the hub hasn't heard of yet
10
+ * (saved offline, or the tab closed before the send), and hide one cleared
11
+ * offline.
12
+ *
13
+ * A record belongs to the player who saved it (`userId`), so another account
14
+ * on the same device never sees it. `isSynced` turns true once the hub has it.
15
+ */
16
+ import type { Json } from '@diffenderfer-games/hub-contract';
17
+ import { isJson, isNullableString, isRecord, optionalNumber, optionalString, parseJsonText } from '../storage/json-guards';
18
+ import type { KeyValueStore } from '../storage/key-value';
19
+
20
+ /** A run as this device saved it. */
21
+ export interface LocalRun {
22
+ readonly status: 'saved';
23
+ readonly userId: number | null;
24
+ /** When the game saved it, 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
+ /** The game's title, for the Resume card before the hub knows the run. */
31
+ readonly gameTitle: string;
32
+ readonly state: Json;
33
+ readonly isSynced: boolean;
34
+ }
35
+
36
+ /** A run this device cleared (kept until the hub has the clear). */
37
+ export interface LocalClear {
38
+ readonly status: 'cleared';
39
+ readonly userId: number | null;
40
+ readonly at: number;
41
+ readonly isSynced: boolean;
42
+ }
43
+
44
+ /** A device copy of one game's slot. */
45
+ export type LocalResume = LocalRun | LocalClear;
46
+
47
+ const KEY_PATTERN = /^hub:(.+):resume$/;
48
+
49
+ function keyFor(slug: string): string {
50
+ return `hub:${slug}:resume`;
51
+ }
52
+
53
+ /** A stored record read back, or `null` when missing or malformed. */
54
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: a record read back from localStorage
55
+ function readRecord(stored: unknown): LocalResume | null {
56
+ if (!isRecord(stored)) {
57
+ return null;
58
+ }
59
+ const at = optionalNumber(stored.at);
60
+ const userId = stored.userId === null ? null : optionalNumber(stored.userId);
61
+ const isSynced = stored.isSynced === true;
62
+ if (at === undefined || userId === undefined) {
63
+ return null;
64
+ }
65
+ if (stored.status === 'cleared') {
66
+ return { status: 'cleared', userId, at, isSynced };
67
+ }
68
+ const label = optionalString(stored.label);
69
+ const gameTitle = optionalString(stored.gameTitle);
70
+ const version = optionalNumber(stored.version);
71
+ const fraction = stored.fraction === null ? null : optionalNumber(stored.fraction);
72
+ if (stored.status !== 'saved' || label === undefined || gameTitle === undefined || version === undefined
73
+ || fraction === undefined || !isNullableString(stored.title) || !isJson(stored.state)) {
74
+ return null;
75
+ }
76
+ return { status: 'saved', userId, at, label, fraction, title: stored.title, version, gameTitle, state: stored.state, isSynced };
77
+ }
78
+
79
+ /** The device copies of every game's slot. */
80
+ export class LocalResumeStore {
81
+ private readonly store: KeyValueStore;
82
+
83
+ constructor(store: KeyValueStore) {
84
+ this.store = store;
85
+ }
86
+
87
+ /** `slug`'s copy, or `null`. */
88
+ read(slug: string): LocalResume | null {
89
+ const text = this.store.read(keyFor(slug));
90
+ return text === null ? null : readRecord(parseJsonText(text));
91
+ }
92
+
93
+ /** Store `slug`'s copy; answers whether it stuck (a full storage refuses a large state). */
94
+ write(slug: string, record: LocalResume): boolean {
95
+ const text = JSON.stringify(record);
96
+ this.store.write(keyFor(slug), text);
97
+ return this.store.read(keyFor(slug)) === text;
98
+ }
99
+
100
+ /** Forget `slug`'s copy. */
101
+ remove(slug: string): void {
102
+ this.store.remove(keyFor(slug));
103
+ }
104
+
105
+ /** The hub now has `slug`'s save or clear made at `at`: mark it synced (a clear is simply forgotten). */
106
+ markSynced(slug: string, at: number): void {
107
+ const record = this.read(slug);
108
+ if (record === null || record.at !== at) {
109
+ return;
110
+ }
111
+ if (record.status === 'cleared') {
112
+ this.remove(slug);
113
+ } else {
114
+ this.write(slug, { ...record, isSynced: true });
115
+ }
116
+ }
117
+
118
+ /** Every game's copy, by slug. */
119
+ all(): Map<string, LocalResume> {
120
+ const records = new Map<string, LocalResume>();
121
+ for (const key of this.store.keys()) {
122
+ const slug = KEY_PATTERN.exec(key)?.[1];
123
+ const record = slug === undefined ? null : this.read(slug);
124
+ if (slug !== undefined && record !== null) {
125
+ records.set(slug, record);
126
+ }
127
+ }
128
+ return records;
129
+ }
130
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The player's saved runs: the hub's list merged with this device's copies,
3
+ * so a run saved offline (or just before the tab closed) shows at once and a
4
+ * run cleared offline disappears at once. Pure.
5
+ */
6
+ import type { ResumeSummary } from '@diffenderfer-games/hub-contract';
7
+ import type { LocalResume, LocalRun } from './local-resume';
8
+ import type { ResumeEntry } from './resume-types';
9
+
10
+ function fromHub(summary: ResumeSummary): ResumeEntry {
11
+ return {
12
+ slug: summary.game,
13
+ gameTitle: summary.gameTitle,
14
+ path: summary.path,
15
+ hasImage: summary.hasImage,
16
+ progress: summary.progress,
17
+ title: summary.title,
18
+ savedAt: summary.updatedAt,
19
+ isLocalOnly: false,
20
+ };
21
+ }
22
+
23
+ function fromDevice(slug: string, run: LocalRun, known: ResumeSummary | undefined): ResumeEntry {
24
+ return {
25
+ slug,
26
+ gameTitle: known?.gameTitle ?? run.gameTitle,
27
+ path: known?.path ?? `/${slug}`,
28
+ hasImage: known?.hasImage ?? false,
29
+ progress: { label: run.label, fraction: run.fraction },
30
+ title: run.title,
31
+ savedAt: run.at,
32
+ isLocalOnly: !run.isSynced,
33
+ };
34
+ }
35
+
36
+ /** One game's entry, from the hub's summary (if any) and the device copy (if the player's). */
37
+ function mergeOne(slug: string, hub: ResumeSummary | undefined, copy: LocalResume | undefined, isHubKnown: boolean): ResumeEntry | null {
38
+ if (copy === undefined) {
39
+ return hub === undefined ? null : fromHub(hub);
40
+ }
41
+ if (copy.status === 'cleared') {
42
+ return hub !== undefined && hub.updatedAt > copy.at ? fromHub(hub) : null;
43
+ }
44
+ if (hub !== undefined && hub.updatedAt >= copy.at) {
45
+ return fromHub(hub);
46
+ }
47
+ // The hub hasn't got this run: show it while it waits to be sent, or when the hub can't be asked.
48
+ // A synced copy the hub no longer lists was cleared or pruned elsewhere.
49
+ return !copy.isSynced || !isHubKnown || hub !== undefined ? fromDevice(slug, copy, hub) : null;
50
+ }
51
+
52
+ /**
53
+ * The runs to show, newest first. `hub` is the hub's list, or `undefined`
54
+ * when it couldn't be reached; `copies` are the device's, by slug; only
55
+ * those saved by `userId` count.
56
+ */
57
+ export function mergeResumeLists(hub: readonly ResumeSummary[] | undefined, copies: ReadonlyMap<string, LocalResume>, userId: number | null): ResumeEntry[] {
58
+ const hubBySlug = new Map((hub ?? []).map((summary) => [summary.game, summary] as const));
59
+ const mine = new Map([...copies].filter(([, copy]) => copy.userId === userId));
60
+ const slugs = new Set([...hubBySlug.keys(), ...mine.keys()]);
61
+ return [...slugs]
62
+ .map((slug) => mergeOne(slug, hubBySlug.get(slug), mine.get(slug), hub !== undefined))
63
+ .filter((entry): entry is ResumeEntry => entry !== null)
64
+ .sort((a, b) => b.savedAt - a.savedAt);
65
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Whether this page was opened from the home page's Resume card
3
+ * (`/<slug>/?hub_resume=1`). The first client copy on the page to look takes
4
+ * the parameter out of the address bar (so a reload or a shared link doesn't
5
+ * resume again) and records the answer on `window`, so every other copy on
6
+ * the page (a game's bundled client and the host's `/_hub/hub.js`) answers
7
+ * the same, however late it asks.
8
+ */
9
+ import { isBrowserPage } from '../page';
10
+
11
+ /** The query parameter (the contract's `RESUME_REQUEST_PARAM`; the browser client carries no contract code). */
12
+ export const RESUME_REQUEST_PARAM = 'hub_resume';
13
+
14
+ /** Where the answer is kept for the page. */
15
+ const PAGE_FLAG = '__HUB_RESUME_REQUESTED__';
16
+
17
+ /** Take `hub_resume` out of the address bar, keeping the rest of the URL and the history state. */
18
+ function stripParam(url: URL): void {
19
+ url.searchParams.delete(RESUME_REQUEST_PARAM);
20
+ try {
21
+ history.replaceState(history.state, '', `${url.pathname}${url.search}${url.hash}`);
22
+ } catch {
23
+ // A sandboxed page that refuses replaceState keeps the parameter; the answer is still recorded.
24
+ }
25
+ }
26
+
27
+ /** Whether the address has `hub_resume`; it is taken out when it does. */
28
+ function takeParam(): boolean {
29
+ try {
30
+ const url = new URL(location.href);
31
+ if (!url.searchParams.has(RESUME_REQUEST_PARAM)) {
32
+ return false;
33
+ }
34
+ stripParam(url);
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ /** `true` when the page was opened to resume (synchronous, so a game can decide before it routes or draws). */
42
+ export function isResumeRequested(): boolean {
43
+ if (!isBrowserPage()) {
44
+ return false;
45
+ }
46
+ if (Reflect.has(window, PAGE_FLAG)) {
47
+ return Reflect.get(window, PAGE_FLAG) === true;
48
+ }
49
+ const isRequested = takeParam();
50
+ Reflect.set(window, PAGE_FLAG, isRequested);
51
+ return isRequested;
52
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `hub.resume`'s public types: what a game saves, what it loads back, and
3
+ * the player's list of saved runs the home page shows.
4
+ */
5
+ import type { GamePayload } from '../game-data';
6
+
7
+ /** How far along a run is. */
8
+ export interface ResumeProgress {
9
+ /** A short line the game writes, such as "Level 12" or "Round 3 of 5" (up to 60 characters). */
10
+ readonly label: string;
11
+ /** 0 to 1, drawn as a progress bar; leave it out when the game can't say. */
12
+ readonly fraction?: number | null;
13
+ }
14
+
15
+ /** A run to save. `TState` is the game's own JSON-safe state type. */
16
+ export interface ResumeSaveInput<TState extends GamePayload> {
17
+ /** Everything the game needs to pick the run up again (up to 512 KB as JSON). */
18
+ readonly state: TState;
19
+ readonly progress: ResumeProgress;
20
+ /** An optional name for the run ("Klondike, draw 3"), shown under the game's name. */
21
+ readonly title?: string | null;
22
+ /** The game's state version (default 0). Bump it when `state` changes shape; see `load({ version })`. */
23
+ readonly version?: number;
24
+ }
25
+
26
+ /** A saved run, read back. */
27
+ export interface ResumeRun<TState> {
28
+ readonly state: TState;
29
+ readonly progress: { readonly label: string; readonly fraction: number | null };
30
+ readonly title: string | null;
31
+ readonly version: number;
32
+ /** When the game saved it, epoch ms. */
33
+ readonly savedAt: number;
34
+ }
35
+
36
+ /** Options for reading the run back. */
37
+ export interface ResumeLoadOptions {
38
+ /**
39
+ * The state version this code reads. A run saved with an older version is
40
+ * dropped (cleared) and reads as `null`; a newer one (from a newer copy of
41
+ * the game in another tab) reads as `null` but is kept.
42
+ */
43
+ readonly version?: number;
44
+ }
45
+
46
+ /** One saved run in the player's list (the home page's Resume card). */
47
+ export interface ResumeEntry {
48
+ /** The game's slug. */
49
+ readonly slug: string;
50
+ readonly gameTitle: string;
51
+ /** The game's URL path, like `/solitaire`. */
52
+ readonly path: string;
53
+ /** The game has a tile image (`/_tiles/<slug>`). */
54
+ readonly hasImage: boolean;
55
+ readonly progress: { readonly label: string; readonly fraction: number | null };
56
+ readonly title: string | null;
57
+ /** When it was saved, epoch ms. */
58
+ readonly savedAt: number;
59
+ /** Saved on this device and not yet sent to the hub (offline, or the tab closed first). */
60
+ readonly isLocalOnly: boolean;
61
+ }
62
+
63
+ /** Why a save was ignored: the page is in a race or an online room. (`'daily'` is no longer returned: a save during a daily goes to the day.) */
64
+ export type ResumeBlocker = 'race' | 'daily' | 'room' | 'turns';
@@ -0,0 +1,196 @@
1
+ /**
2
+ * A game's daily challenges in progress on this device: what `hub.daily`
3
+ * saves and discards, kept in the device copy at once (`LocalDailyStore`)
4
+ * and sent through the `hub.saved` outbox kind after a short quiet spell
5
+ * (at once when the page hides), so a day survives a reload, offline play
6
+ * and a tab closed mid-move. A day started while the hub can't be reached
7
+ * gets a device-only id; its first send starts it on the hub.
8
+ */
9
+ import type { DailyChallenge, DailySaveInput, DailyStartResponse } from '../../daily/types';
10
+ import { HubError } from '../errors';
11
+ import { toStoredJson } from '../game-data';
12
+ import type { SavedPayload } from '../offline/hub-kinds';
13
+ import { isBrowserPage } from '../page';
14
+ import { dailySeed } from './daily-seed';
15
+ import type { LocalDaily, LocalDailyStore } from './local-saved';
16
+ import { localDailyId } from './saved-list';
17
+
18
+ /** How long saves must be quiet before the latest is sent, ms (the same as `hub.resume`). */
19
+ export const DAILY_SAVE_DEBOUNCE_MS = 3000;
20
+
21
+ /** Largest state, bytes of JSON (the contract's `SAVED_MAX_STATE_BYTES`). */
22
+ const MAX_STATE_BYTES = 512 * 1024;
23
+
24
+ /** Longest label or title kept. */
25
+ const TEXT_MAX = 60;
26
+
27
+ /** The label of a day started and not saved yet. */
28
+ const STARTED_LABEL = 'Started';
29
+
30
+ /** What the daily saves need from their client. */
31
+ export interface DailySavesHost {
32
+ readonly slug: string;
33
+ readonly local: LocalDailyStore;
34
+ currentUserId(): number | null;
35
+ gameTitle(): string;
36
+ /** Queue (or send at once) a `hub.saved` op. */
37
+ send(payload: SavedPayload): Promise<void>;
38
+ }
39
+
40
+ /** A label or title trimmed and clipped, or `null` when blank. */
41
+ function shortText(text: string | null | undefined): string | null {
42
+ const trimmed = (text ?? '').trim();
43
+ return trimmed === '' ? null : trimmed.slice(0, TEXT_MAX);
44
+ }
45
+
46
+ /** A game's daily saves. */
47
+ export class DailySaves {
48
+ private readonly host: DailySavesHost;
49
+ private readonly timers = new Map<string, ReturnType<typeof setTimeout>>();
50
+ private readonly waiting = new Map<string, SavedPayload>();
51
+ private isListening = false;
52
+
53
+ constructor(host: DailySavesHost) {
54
+ this.host = host;
55
+ }
56
+
57
+ /**
58
+ * The answer a device-only start gives while the hub can't be reached:
59
+ * the day's copy here (its saved state) or a fresh one. No token: the
60
+ * completion fetches one when the hub is back.
61
+ */
62
+ startLocally(day: string): DailyStartResponse {
63
+ const userId = this.host.currentUserId();
64
+ const existing = this.host.local.read(this.host.slug, day);
65
+ const copy: LocalDaily = existing !== null && existing.status === 'active' && existing.userId === userId ? existing : {
66
+ id: localDailyId(this.host.slug, day), game: this.host.slug, day, seed: dailySeed(this.host.slug, day), status: 'active', userId, at: Date.now(),
67
+ label: STARTED_LABEL, fraction: null, title: null, version: 0, gameTitle: this.host.gameTitle(), state: null, isSynced: false,
68
+ };
69
+ if (copy !== existing) {
70
+ this.host.local.write(copy);
71
+ }
72
+ return {
73
+ token: '', day, seed: copy.seed, expiresAt: 0, alreadyCompleted: false, completedAt: null, meta: null,
74
+ instanceId: copy.id, saved: copy.state, savedVersion: copy.version, resumed: existing !== null,
75
+ };
76
+ }
77
+
78
+ /** The hub started (or resumed) a day: keep a copy that knows its id, unless the device has a newer save. */
79
+ remember(started: DailyStartResponse): DailyStartResponse {
80
+ if (started.instanceId === null) {
81
+ return started;
82
+ }
83
+ const existing = this.host.local.read(this.host.slug, started.day);
84
+ if (existing !== null && existing.status === 'active' && existing.userId === this.host.currentUserId() && !existing.isSynced) {
85
+ // This device saved after its last send: play on from that.
86
+ this.host.local.write({ ...existing, id: started.instanceId });
87
+ return { ...started, saved: existing.state, savedVersion: existing.version };
88
+ }
89
+ return started;
90
+ }
91
+
92
+ /** Save the active day's state: on the device now, to the hub soon. */
93
+ save(challenge: DailyChallenge, input: DailySaveInput): boolean {
94
+ const label = shortText(input.progress.label);
95
+ if (label === null) {
96
+ throw new HubError('hub.daily.save: progress.label is required.', 400);
97
+ }
98
+ const state = toStoredJson(input.state);
99
+ if (new TextEncoder().encode(JSON.stringify(state)).length > MAX_STATE_BYTES) {
100
+ throw new HubError(`hub.daily.save: state exceeds ${String(MAX_STATE_BYTES)} bytes.`, 413);
101
+ }
102
+ if (challenge.instanceId === null) {
103
+ return false;
104
+ }
105
+ const fraction = typeof input.progress.fraction === 'number' && Number.isFinite(input.progress.fraction) ? Math.min(1, Math.max(0, input.progress.fraction)) : null;
106
+ const copy: LocalDaily = {
107
+ id: challenge.instanceId, game: this.host.slug, day: challenge.day, seed: challenge.seed, status: 'active', userId: this.host.currentUserId(),
108
+ at: Date.now(), label, fraction, title: shortText(input.title), version: Math.max(0, Math.floor(input.version ?? 0)),
109
+ gameTitle: this.host.gameTitle(), state, isSynced: false,
110
+ };
111
+ if (!this.host.local.write(copy)) {
112
+ return false;
113
+ }
114
+ this.schedule({ action: 'save', game: copy.game, day: copy.day, at: copy.at });
115
+ return true;
116
+ }
117
+
118
+ /** Give the day up: the copy says so at once, the hub hears it now (or when back online). */
119
+ async discard(challenge: DailyChallenge): Promise<void> {
120
+ this.dropWaiting(challenge.day);
121
+ const at = Date.now();
122
+ const base = this.host.local.read(this.host.slug, challenge.day) ?? this.blankCopy(challenge);
123
+ this.host.local.write({ ...base, id: challenge.instanceId ?? base.id, status: 'discarded', at, isSynced: false });
124
+ await this.host.send({ action: 'discard', game: this.host.slug, day: challenge.day, at });
125
+ }
126
+
127
+ /** The day was won: no save is waiting for it any more, and the lists hide it until the hub confirms. */
128
+ finished(day: string): void {
129
+ this.dropWaiting(day);
130
+ const existing = this.host.local.read(this.host.slug, day);
131
+ if (existing !== null) {
132
+ this.host.local.write({ ...existing, status: 'finished', at: Date.now(), isSynced: false });
133
+ }
134
+ }
135
+
136
+ /** The hub recorded the day: forget the copy. */
137
+ completed(day: string): void {
138
+ this.host.local.remove(this.host.slug, day);
139
+ }
140
+
141
+ /** Send every waiting save now. */
142
+ async flush(): Promise<void> {
143
+ const payloads = [...this.waiting.values()];
144
+ for (const timer of this.timers.values()) {
145
+ clearTimeout(timer);
146
+ }
147
+ this.timers.clear();
148
+ this.waiting.clear();
149
+ await Promise.all(payloads.map((payload) => this.host.send(payload)));
150
+ }
151
+
152
+ private blankCopy(challenge: DailyChallenge): LocalDaily {
153
+ return {
154
+ id: challenge.instanceId ?? localDailyId(this.host.slug, challenge.day), game: this.host.slug, day: challenge.day, seed: challenge.seed,
155
+ status: 'active', userId: this.host.currentUserId(), at: Date.now(), label: STARTED_LABEL, fraction: null, title: null, version: 0,
156
+ gameTitle: this.host.gameTitle(), state: null, isSynced: false,
157
+ };
158
+ }
159
+
160
+ private dropWaiting(day: string): void {
161
+ const timer = this.timers.get(day);
162
+ if (timer !== undefined) {
163
+ clearTimeout(timer);
164
+ }
165
+ this.timers.delete(day);
166
+ this.waiting.delete(day);
167
+ }
168
+
169
+ private schedule(payload: SavedPayload): void {
170
+ this.dropWaiting(payload.day);
171
+ this.waiting.set(payload.day, payload);
172
+ this.timers.set(payload.day, setTimeout(() => {
173
+ this.timers.delete(payload.day);
174
+ this.waiting.delete(payload.day);
175
+ void this.host.send(payload).catch(() => undefined);
176
+ }, DAILY_SAVE_DEBOUNCE_MS));
177
+ this.listenToPage();
178
+ }
179
+
180
+ /** Send waiting saves as soon as the page is hidden or closed. */
181
+ private listenToPage(): void {
182
+ if (this.isListening || !isBrowserPage()) {
183
+ return;
184
+ }
185
+ this.isListening = true;
186
+ const flushNow = (): void => { void this.flush().catch(() => undefined); };
187
+ window.addEventListener('pagehide', flushNow);
188
+ if (typeof document !== 'undefined') {
189
+ document.addEventListener('visibilitychange', () => {
190
+ if (document.visibilityState === 'hidden') {
191
+ flushNow();
192
+ }
193
+ });
194
+ }
195
+ }
196
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The deterministic seed of a (game, day), the same FNV-1a as the
3
+ * contract's `seedForDay` (the browser client carries no contract code; a
4
+ * test keeps the two in step). Used for a day started offline.
5
+ */
6
+
7
+ /** The uint32 seed for `game` on `day`. */
8
+ export function dailySeed(game: string, day: string): number {
9
+ let hash = 2166136261 >>> 0;
10
+ const seedInput = `${game}#${day}`;
11
+ for (let i = 0; i < seedInput.length; i++) {
12
+ hash ^= seedInput.charCodeAt(i);
13
+ hash = Math.imul(hash, 16777619);
14
+ }
15
+ return hash >>> 0;
16
+ }