@diffenderfer-games/hub 2.0.0-next.5 → 2.0.0-next.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/CHANGELOG.md +65 -19
  2. package/README.md +3 -6
  3. package/dist/auto.d.ts +5 -5
  4. package/dist/auto.js +2 -2
  5. package/dist/avatar-pixi.d.ts +147 -0
  6. package/dist/avatar-pixi.d.ts.map +1 -0
  7. package/dist/avatar-pixi.js +280 -0
  8. package/dist/avatar-pixi.js.map +1 -0
  9. package/dist/avatar-svg.d.ts +4 -0
  10. package/dist/avatar-svg.js +5 -0
  11. package/dist/avatar-three.d.ts +69 -0
  12. package/dist/avatar-three.d.ts.map +1 -0
  13. package/dist/avatar-three.js +49 -0
  14. package/dist/avatar-three.js.map +1 -0
  15. package/dist/avatar.d.ts +7 -0
  16. package/dist/avatar.js +487 -0
  17. package/dist/avatar.js.map +1 -0
  18. package/dist/daily.d.ts +2 -2
  19. package/dist/doctor/cli.mjs +1 -1
  20. package/dist/index.d.ts +5 -5
  21. package/dist/index.js +2 -2
  22. package/dist/input.d.ts +2 -2
  23. package/dist/shared/animator.d.ts +104 -0
  24. package/dist/shared/animator.d.ts.map +1 -0
  25. package/dist/shared/art.js +1946 -0
  26. package/dist/shared/art.js.map +1 -0
  27. package/dist/shared/atlas.d.ts +134 -0
  28. package/dist/shared/atlas.d.ts.map +1 -0
  29. package/dist/shared/avatar.d.ts +117 -0
  30. package/dist/shared/avatar.d.ts.map +1 -0
  31. package/dist/shared/bridge.js +13 -5
  32. package/dist/shared/bridge.js.map +1 -1
  33. package/dist/shared/client-version.gen.js +1 -1
  34. package/dist/shared/daily.d.ts +129 -18
  35. package/dist/shared/daily.d.ts.map +1 -1
  36. package/dist/shared/daily.js +120 -26
  37. package/dist/shared/daily.js.map +1 -1
  38. package/dist/shared/defineProperty.js +39 -0
  39. package/dist/shared/describe.d.ts +32 -0
  40. package/dist/shared/describe.d.ts.map +1 -0
  41. package/dist/shared/describe.js +109 -0
  42. package/dist/shared/describe.js.map +1 -0
  43. package/dist/shared/globals.js +1 -39
  44. package/dist/shared/globals.js.map +1 -1
  45. package/dist/shared/index.d.ts +503 -14
  46. package/dist/shared/index.d.ts.map +1 -1
  47. package/dist/shared/input.d.ts +31 -13
  48. package/dist/shared/input.d.ts.map +1 -1
  49. package/dist/shared/input.js +40 -2
  50. package/dist/shared/input.js.map +1 -1
  51. package/dist/shared/json-guards.js +48 -0
  52. package/dist/shared/json-guards.js.map +1 -0
  53. package/dist/shared/live-canvas.js +62 -0
  54. package/dist/shared/live-canvas.js.map +1 -0
  55. package/dist/shared/mp.js +1 -1
  56. package/dist/shared/overlays.js +800 -0
  57. package/dist/shared/overlays.js.map +1 -0
  58. package/dist/shared/page-stack.js +2 -1
  59. package/dist/shared/page-stack.js.map +1 -1
  60. package/dist/shared/pose-key.js +249 -0
  61. package/dist/shared/pose-key.js.map +1 -0
  62. package/dist/shared/race.js +1 -1
  63. package/dist/shared/rooms.js +1 -1
  64. package/dist/shared/scene.d.ts +90 -0
  65. package/dist/shared/scene.d.ts.map +1 -0
  66. package/dist/shared/social.d.ts +55 -4
  67. package/dist/shared/social.d.ts.map +1 -1
  68. package/dist/shared/src.js +2020 -144
  69. package/dist/shared/src.js.map +1 -1
  70. package/dist/shared/svg-markup.js +1945 -0
  71. package/dist/shared/svg-markup.js.map +1 -0
  72. package/dist/shared/svg.js +27 -0
  73. package/dist/shared/svg.js.map +1 -0
  74. package/dist/shared/types.d.ts +166 -0
  75. package/dist/shared/types.d.ts.map +1 -0
  76. package/dist/social.d.ts +2 -2
  77. package/dist/social.js +2 -2
  78. package/dist/turns.d.ts +39 -0
  79. package/dist/turns.d.ts.map +1 -0
  80. package/dist/turns.js +117 -0
  81. package/dist/turns.js.map +1 -0
  82. package/package.json +33 -3
  83. package/src/avatar/adapters/pixi-avatar.ts +73 -0
  84. package/src/avatar/adapters/pixi-crowd.ts +302 -0
  85. package/src/avatar/adapters/pixi-types.ts +84 -0
  86. package/src/avatar/adapters/three.ts +126 -0
  87. package/src/avatar/animator/animator.ts +285 -0
  88. package/src/avatar/animator/reactions.ts +44 -0
  89. package/src/avatar/animator/schedule.ts +73 -0
  90. package/src/avatar/art/beard.ts +49 -0
  91. package/src/avatar/art/glasses.ts +67 -0
  92. package/src/avatar/art/hair.ts +254 -0
  93. package/src/avatar/art/headwear.ts +75 -0
  94. package/src/avatar/art/index.ts +21 -0
  95. package/src/avatar/art/shape.ts +87 -0
  96. package/src/avatar/art-store.ts +36 -0
  97. package/src/avatar/atlas.ts +236 -0
  98. package/src/avatar/catalog-view.ts +14 -0
  99. package/src/avatar/colour.ts +109 -0
  100. package/src/avatar/describe.ts +79 -0
  101. package/src/avatar/face.ts +187 -0
  102. package/src/avatar/geom.ts +173 -0
  103. package/src/avatar/layout.ts +47 -0
  104. package/src/avatar/look-like.ts +52 -0
  105. package/src/avatar/outputs/bitmap.ts +75 -0
  106. package/src/avatar/outputs/canvas-paint.ts +177 -0
  107. package/src/avatar/outputs/canvas.ts +62 -0
  108. package/src/avatar/outputs/custom-element.ts +140 -0
  109. package/src/avatar/outputs/element.ts +58 -0
  110. package/src/avatar/outputs/live-canvas.ts +57 -0
  111. package/src/avatar/outputs/overlay-svg.ts +22 -0
  112. package/src/avatar/outputs/pose-key.ts +30 -0
  113. package/src/avatar/outputs/svg-markup.ts +113 -0
  114. package/src/avatar/outputs/svg.ts +41 -0
  115. package/src/avatar/overlays.ts +128 -0
  116. package/src/avatar/render/body.ts +116 -0
  117. package/src/avatar/render/colours.ts +81 -0
  118. package/src/avatar/render/compose.ts +303 -0
  119. package/src/avatar/render/context.ts +117 -0
  120. package/src/avatar/render/eyes.ts +78 -0
  121. package/src/avatar/render/eyewear.ts +118 -0
  122. package/src/avatar/render/frame.ts +60 -0
  123. package/src/avatar/render/head.ts +81 -0
  124. package/src/avatar/render/mouth.ts +86 -0
  125. package/src/avatar/render/props.ts +91 -0
  126. package/src/avatar/render/types.ts +84 -0
  127. package/src/avatar/scene.ts +119 -0
  128. package/src/avatar-pixi.ts +20 -0
  129. package/src/avatar-svg.ts +21 -0
  130. package/src/avatar-three.ts +18 -0
  131. package/src/avatar.ts +62 -0
  132. package/src/core/avatar/avatar-cache.ts +95 -0
  133. package/src/core/avatar/avatar-ref.ts +34 -0
  134. package/src/core/avatar/avatar-types.ts +43 -0
  135. package/src/core/avatar/hub-avatar.ts +308 -0
  136. package/src/core/connection.ts +8 -0
  137. package/src/core/errors.ts +7 -1
  138. package/src/core/hub-progress.ts +39 -8
  139. package/src/core/hub.ts +174 -6
  140. package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
  141. package/src/core/offline/hub-kinds/index.ts +9 -0
  142. package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
  143. package/src/core/page-account.ts +121 -0
  144. package/src/core/rest/routes.gen.ts +31 -1
  145. package/src/core/resume/hub-resume.ts +39 -5
  146. package/src/core/resume/resume-types.ts +2 -2
  147. package/src/core/saved/daily-saves.ts +196 -0
  148. package/src/core/saved/daily-seed.ts +16 -0
  149. package/src/core/saved/hub-saved.ts +221 -0
  150. package/src/core/saved/local-saved.ts +122 -0
  151. package/src/core/saved/saved-list.ts +129 -0
  152. package/src/core/saved/saved-open.ts +104 -0
  153. package/src/core/saved/saved-order.ts +49 -0
  154. package/src/core/saved/saved-types.ts +24 -0
  155. package/src/core/turns/hub-turns.ts +140 -0
  156. package/src/core/turns/turn-game.ts +388 -0
  157. package/src/core/version/client-version.gen.ts +2 -2
  158. package/src/daily/daily-system.ts +112 -13
  159. package/src/daily/page-daily.ts +2 -2
  160. package/src/daily/types.ts +46 -9
  161. package/src/doctor/hub-version-fetch.ts +43 -43
  162. package/src/index.ts +10 -1
  163. package/src/input/input-system.ts +10 -1
  164. package/src/input/navigator.ts +11 -0
  165. package/src/input.ts +1 -0
  166. package/src/overlay/overlay.ts +25 -1
  167. package/src/realtime/runtime-api.ts +14 -3
  168. package/src/social.ts +1 -0
  169. package/src/turns-kit/check-rules.ts +182 -0
  170. package/src/turns.ts +13 -0
@@ -11,8 +11,9 @@
11
11
  * - `load()` / `get()` read it back; `requested` says the page was opened to
12
12
  * resume, so the game restores without asking.
13
13
  *
14
- * Saves are ignored while the page is in a race, a daily challenge or an
15
- * online room: those runs are never resumed.
14
+ * Saves are ignored while the page is in a race or an online room: those
15
+ * runs are never resumed. During a daily challenge they go to the day's own
16
+ * saved game (`hub.daily.save`), so the day can be picked up again.
16
17
  */
17
18
  import type { ResumeSlot, ResumeSummary } from '@diffenderfer-games/hub-contract';
18
19
  import { HubError } from '../errors';
@@ -35,6 +36,7 @@ const BLOCKED_HINTS: Readonly<Record<ResumeBlocker, string>> = {
35
36
  daily: 'a daily challenge is active. When the player leaves a daily without finishing it, call hub.daily.cancel().',
36
37
  race: 'a race is on: races are never resumed.',
37
38
  room: 'the page is in a hub room: online games are never resumed.',
39
+ turns: 'an online game is open: the hub keeps its state, so nothing is saved on the page.',
38
40
  };
39
41
 
40
42
  /** Longest label or title kept (the contract's `RESUME_TEXT_MAX`). */
@@ -51,6 +53,8 @@ export interface ResumeHost {
51
53
  gameTitle(): string;
52
54
  /** Why saving is off right now, or `null`. */
53
55
  blocker(): ResumeBlocker | null;
56
+ /** The page's daily challenge while one is being played (saves go to the day instead of the run), else `null`. */
57
+ daily?(): DailyRoute | null;
54
58
  /** Perform a resume op: at once when online with nothing waiting, else queued. */
55
59
  send(payload: ResumePayload): Promise<void>;
56
60
  /** This game's run on the hub, or `undefined` when the hub can't be reached. */
@@ -59,8 +63,18 @@ export interface ResumeHost {
59
63
  fetchList(): Promise<ResumeSummary[] | undefined>;
60
64
  /** Clear another game's run on the hub (the home page's discard); throws offline. */
61
65
  clearOther(slug: string): Promise<void>;
66
+ /** The run changed here (saved or cleared): `hub.saved` listeners hear it. */
67
+ onChange?(): void;
62
68
  }
63
69
 
70
+ /** Where a save goes during a daily challenge (`hub.daily`). */
71
+ export interface DailyRoute {
72
+ save(input: ResumeSaveInput<GamePayload>): boolean;
73
+ }
74
+
75
+ /** The label a daily goes back to when the game clears its run mid-daily (the day restarts from its seed). */
76
+ const DAILY_RESTART_LABEL = 'Started';
77
+
64
78
  /** A label or title trimmed and clipped, or `null` when blank. */
65
79
  function shortText(text: string | null | undefined): string | null {
66
80
  const trimmed = (text ?? '').trim();
@@ -111,8 +125,9 @@ export class HubResume {
111
125
  /**
112
126
  * Save the run (the latest save wins). Stored on the device at once; sent
113
127
  * after {@link RESUME_DEBOUNCE_MS} of quiet, or when the page is hidden.
114
- * Returns `false` (and saves nothing) in a race, a daily challenge or an
115
- * online room. Throws a `HubError` 413 over 512 KB, 400 without a label.
128
+ * Returns `false` (and saves nothing) in a race or an online room; during
129
+ * a daily challenge the save goes to the day (`hub.daily.save`). Throws a
130
+ * `HubError` 413 over 512 KB, 400 without a label.
116
131
  */
117
132
  save<TState extends GamePayload>(run: ResumeSaveInput<TState>): boolean {
118
133
  const blocker = this.host.blocker();
@@ -120,6 +135,11 @@ export class HubResume {
120
135
  this.warnBlocked(blocker);
121
136
  return false;
122
137
  }
138
+ const daily = this.host.daily?.() ?? null;
139
+ if (daily !== null) {
140
+ // During a daily challenge the run IS the day: it is saved to the day's saved game, not the game's run.
141
+ return daily.save(run);
142
+ }
123
143
  const slug = this.requireSlug();
124
144
  const label = shortText(run.progress.label);
125
145
  if (label === null) {
@@ -135,6 +155,7 @@ export class HubResume {
135
155
  version: Math.max(0, Math.floor(run.version ?? 0)), gameTitle: this.host.gameTitle(), state, isSynced: false,
136
156
  };
137
157
  const isStored = this.host.local.write(slug, copy);
158
+ this.host.onChange?.();
138
159
  this.schedule({
139
160
  action: 'save', savedAt: copy.at, label, fraction, title: copy.title, version: copy.version,
140
161
  ...(isStored ? {} : { state }),
@@ -158,6 +179,17 @@ export class HubResume {
158
179
  /** Clear the run: call it when the run ends (won, lost or quit). Works offline. */
159
180
  async clear(): Promise<void> {
160
181
  const slug = this.requireSlug();
182
+ const daily = this.host.daily?.() ?? null;
183
+ if (daily !== null) {
184
+ // The day's run ended without a win: the day stays in progress, starting over from its seed.
185
+ daily.save({ state: null, progress: { label: DAILY_RESTART_LABEL } });
186
+ return;
187
+ }
188
+ await this.clearRun(slug);
189
+ }
190
+
191
+ /** Delete the game's saved run (never the day being played). */
192
+ private async clearRun(slug: string): Promise<void> {
161
193
  if (this.timer !== null) {
162
194
  clearTimeout(this.timer);
163
195
  this.timer = null;
@@ -165,6 +197,7 @@ export class HubResume {
165
197
  this.waiting = null;
166
198
  const at = Date.now();
167
199
  this.host.local.write(slug, { status: 'cleared', userId: this.host.currentUserId(), at, isSynced: false });
200
+ this.host.onChange?.();
168
201
  await this.host.send({ action: 'clear', before: at });
169
202
  }
170
203
 
@@ -186,7 +219,8 @@ export class HubResume {
186
219
  return run;
187
220
  }
188
221
  if (run.version < options.version) {
189
- await this.clear();
222
+ // A stale saved run is dropped; a daily being played is left alone.
223
+ await this.clearRun(slug);
190
224
  }
191
225
  return null;
192
226
  }
@@ -60,5 +60,5 @@ export interface ResumeEntry {
60
60
  readonly isLocalOnly: boolean;
61
61
  }
62
62
 
63
- /** Why a save was ignored: the page is in a race, a daily challenge or an online room. */
64
- export type ResumeBlocker = 'race' | 'daily' | 'room';
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
+ }
@@ -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
+ }