@diffenderfer-games/hub 2.0.0-next.4 → 2.0.0-next.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/CHANGELOG.md +54 -11
  2. package/README.md +3 -6
  3. package/dist/auto.d.ts +5 -5
  4. package/dist/auto.js +2 -2
  5. package/dist/avatar-pixi.d.ts +147 -0
  6. package/dist/avatar-pixi.d.ts.map +1 -0
  7. package/dist/avatar-pixi.js +280 -0
  8. package/dist/avatar-pixi.js.map +1 -0
  9. package/dist/avatar-svg.d.ts +4 -0
  10. package/dist/avatar-svg.js +5 -0
  11. package/dist/avatar-three.d.ts +69 -0
  12. package/dist/avatar-three.d.ts.map +1 -0
  13. package/dist/avatar-three.js +49 -0
  14. package/dist/avatar-three.js.map +1 -0
  15. package/dist/avatar.d.ts +7 -0
  16. package/dist/avatar.js +487 -0
  17. package/dist/avatar.js.map +1 -0
  18. package/dist/daily.d.ts +2 -2
  19. package/dist/doctor/cli.mjs +1 -1
  20. package/dist/index.d.ts +5 -5
  21. package/dist/index.js +2 -2
  22. package/dist/input.d.ts +2 -2
  23. package/dist/shared/animator.d.ts +104 -0
  24. package/dist/shared/animator.d.ts.map +1 -0
  25. package/dist/shared/art.js +1804 -0
  26. package/dist/shared/art.js.map +1 -0
  27. package/dist/shared/atlas.d.ts +134 -0
  28. package/dist/shared/atlas.d.ts.map +1 -0
  29. package/dist/shared/avatar.d.ts +117 -0
  30. package/dist/shared/avatar.d.ts.map +1 -0
  31. package/dist/shared/bridge.js +13 -5
  32. package/dist/shared/bridge.js.map +1 -1
  33. package/dist/shared/client-version.gen.js +1 -1
  34. package/dist/shared/daily.d.ts +129 -18
  35. package/dist/shared/daily.d.ts.map +1 -1
  36. package/dist/shared/daily.js +120 -26
  37. package/dist/shared/daily.js.map +1 -1
  38. package/dist/shared/defineProperty.js +39 -0
  39. package/dist/shared/describe.d.ts +32 -0
  40. package/dist/shared/describe.d.ts.map +1 -0
  41. package/dist/shared/describe.js +101 -0
  42. package/dist/shared/describe.js.map +1 -0
  43. package/dist/shared/globals.js +1 -39
  44. package/dist/shared/globals.js.map +1 -1
  45. package/dist/shared/index.d.ts +537 -17
  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 +2217 -199
  69. package/dist/shared/src.js.map +1 -1
  70. package/dist/shared/svg-markup.js +1860 -0
  71. package/dist/shared/svg-markup.js.map +1 -0
  72. package/dist/shared/svg.js +27 -0
  73. package/dist/shared/svg.js.map +1 -0
  74. package/dist/shared/types.d.ts +166 -0
  75. package/dist/shared/types.d.ts.map +1 -0
  76. package/dist/social.d.ts +2 -2
  77. package/dist/social.js +2 -2
  78. package/dist/turns.d.ts +39 -0
  79. package/dist/turns.d.ts.map +1 -0
  80. package/dist/turns.js +117 -0
  81. package/dist/turns.js.map +1 -0
  82. package/package.json +33 -3
  83. package/src/avatar/adapters/pixi-avatar.ts +73 -0
  84. package/src/avatar/adapters/pixi-crowd.ts +302 -0
  85. package/src/avatar/adapters/pixi-types.ts +84 -0
  86. package/src/avatar/adapters/three.ts +126 -0
  87. package/src/avatar/animator/animator.ts +285 -0
  88. package/src/avatar/animator/reactions.ts +44 -0
  89. package/src/avatar/animator/schedule.ts +73 -0
  90. package/src/avatar/art/beard.ts +49 -0
  91. package/src/avatar/art/glasses.ts +9 -0
  92. package/src/avatar/art/hair.ts +254 -0
  93. package/src/avatar/art/headwear.ts +75 -0
  94. package/src/avatar/art/index.ts +21 -0
  95. package/src/avatar/art/shape.ts +68 -0
  96. package/src/avatar/art-store.ts +36 -0
  97. package/src/avatar/atlas.ts +236 -0
  98. package/src/avatar/catalog-view.ts +14 -0
  99. package/src/avatar/colour.ts +109 -0
  100. package/src/avatar/describe.ts +76 -0
  101. package/src/avatar/face.ts +187 -0
  102. package/src/avatar/geom.ts +168 -0
  103. package/src/avatar/layout.ts +47 -0
  104. package/src/avatar/look-like.ts +52 -0
  105. package/src/avatar/outputs/bitmap.ts +75 -0
  106. package/src/avatar/outputs/canvas-paint.ts +177 -0
  107. package/src/avatar/outputs/canvas.ts +62 -0
  108. package/src/avatar/outputs/custom-element.ts +140 -0
  109. package/src/avatar/outputs/element.ts +58 -0
  110. package/src/avatar/outputs/live-canvas.ts +57 -0
  111. package/src/avatar/outputs/overlay-svg.ts +22 -0
  112. package/src/avatar/outputs/pose-key.ts +30 -0
  113. package/src/avatar/outputs/svg-markup.ts +113 -0
  114. package/src/avatar/outputs/svg.ts +41 -0
  115. package/src/avatar/overlays.ts +128 -0
  116. package/src/avatar/render/body.ts +116 -0
  117. package/src/avatar/render/colours.ts +81 -0
  118. package/src/avatar/render/compose.ts +258 -0
  119. package/src/avatar/render/context.ts +117 -0
  120. package/src/avatar/render/eyes.ts +78 -0
  121. package/src/avatar/render/eyewear.ts +56 -0
  122. package/src/avatar/render/frame.ts +60 -0
  123. package/src/avatar/render/head.ts +81 -0
  124. package/src/avatar/render/mouth.ts +86 -0
  125. package/src/avatar/render/props.ts +91 -0
  126. package/src/avatar/render/types.ts +84 -0
  127. package/src/avatar/scene.ts +119 -0
  128. package/src/avatar-pixi.ts +20 -0
  129. package/src/avatar-svg.ts +21 -0
  130. package/src/avatar-three.ts +18 -0
  131. package/src/avatar.ts +62 -0
  132. package/src/core/avatar/avatar-cache.ts +95 -0
  133. package/src/core/avatar/avatar-ref.ts +34 -0
  134. package/src/core/avatar/avatar-types.ts +43 -0
  135. package/src/core/avatar/hub-avatar.ts +308 -0
  136. package/src/core/connection.ts +9 -0
  137. package/src/core/errors.ts +15 -2
  138. package/src/core/hub-progress.ts +269 -202
  139. package/src/core/hub.ts +174 -6
  140. package/src/core/offline/cloud-saves.ts +62 -0
  141. package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
  142. package/src/core/offline/hub-kinds/index.ts +14 -3
  143. package/src/core/offline/hub-kinds/progress-kinds.ts +21 -4
  144. package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
  145. package/src/core/offline/local-store.ts +46 -1
  146. package/src/core/page-account.ts +121 -0
  147. package/src/core/rest/routes.gen.ts +31 -1
  148. package/src/core/resume/hub-resume.ts +39 -5
  149. package/src/core/resume/resume-types.ts +2 -2
  150. package/src/core/saved/daily-saves.ts +196 -0
  151. package/src/core/saved/daily-seed.ts +16 -0
  152. package/src/core/saved/hub-saved.ts +221 -0
  153. package/src/core/saved/local-saved.ts +122 -0
  154. package/src/core/saved/saved-list.ts +129 -0
  155. package/src/core/saved/saved-open.ts +104 -0
  156. package/src/core/saved/saved-order.ts +49 -0
  157. package/src/core/saved/saved-types.ts +24 -0
  158. package/src/core/turns/hub-turns.ts +140 -0
  159. package/src/core/turns/turn-game.ts +388 -0
  160. package/src/core/version/client-version.gen.ts +2 -2
  161. package/src/daily/daily-system.ts +112 -13
  162. package/src/daily/page-daily.ts +2 -2
  163. package/src/daily/types.ts +46 -9
  164. package/src/doctor/hub-version-fetch.ts +43 -43
  165. package/src/index.ts +11 -1
  166. package/src/input/input-system.ts +10 -1
  167. package/src/input/navigator.ts +11 -0
  168. package/src/input.ts +1 -0
  169. package/src/overlay/overlay.ts +25 -1
  170. package/src/realtime/runtime-api.ts +14 -3
  171. package/src/social.ts +1 -0
  172. package/src/turns-kit/check-rules.ts +182 -0
  173. package/src/turns.ts +13 -0
@@ -1,58 +1,388 @@
1
- import { r as _defineProperty } from "./globals.js";
1
+ import { a as readField } from "./globals.js";
2
+ import { t as _defineProperty } from "./defineProperty.js";
2
3
  import { n as getDaily } from "./daily.js";
3
4
  import { t as HUB_CLIENT_VERSION } from "./client-version.gen.js";
4
5
  import { n as getInput } from "./input.js";
5
6
  import { t as getUiStack } from "./page-stack.js";
6
- import { f as linkRealtimeToHub } from "./bridge.js";
7
+ import { d as SESSION_EVENT, n as pageBridge, p as linkRealtimeToHub } from "./bridge.js";
7
8
  import { t as multiplayerForwarder } from "./mp.js";
9
+ import { t as createRuntimeOpener } from "./runtime-opener.js";
8
10
  import { t as raceForwarder } from "./race.js";
9
11
  import { n as roomsForwarder, t as isInHubRoom } from "./rooms.js";
10
12
  import { n as presenceForwarder, r as socialForwarder, t as notifyForwarder } from "./social.js";
11
- //#region src/core/storage/json-guards.ts
12
- /** Whether `candidate` is a non-null, non-array object. */
13
- function isRecord(candidate) {
14
- return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate);
15
- }
16
- /** `candidate` when it is a string, else `undefined`. */
17
- function optionalString(candidate) {
18
- return typeof candidate === "string" ? candidate : void 0;
19
- }
20
- /** `candidate` when it is a finite number, else `undefined`. */
21
- function optionalNumber(candidate) {
22
- return typeof candidate === "number" && Number.isFinite(candidate) ? candidate : void 0;
23
- }
24
- /** Whether `candidate` is a string or `null`. */
25
- function isNullableString(candidate) {
26
- return candidate === null || typeof candidate === "string";
27
- }
28
- /** Whether `candidate` is a JSON value (what `JSON.parse` can return). */
29
- function isJson(candidate) {
30
- if (candidate === null || typeof candidate === "string" || typeof candidate === "boolean") return true;
31
- if (typeof candidate === "number") return Number.isFinite(candidate);
32
- if (Array.isArray(candidate)) return candidate.every(isJson);
33
- return isRecord(candidate) && Object.values(candidate).every(isJson);
34
- }
35
- /** Whether `candidate` is an object whose every value is a finite number. */
36
- function isNumberRecord(candidate) {
37
- return isRecord(candidate) && Object.values(candidate).every((entry) => optionalNumber(entry) !== void 0);
38
- }
39
- /** The strings in `candidate`, or an empty list when it is not an array. */
40
- function stringsIn(candidate) {
41
- return Array.isArray(candidate) ? candidate.filter((entry) => typeof entry === "string") : [];
42
- }
43
- /** Parse JSON text without throwing: `undefined` when it is not JSON. */
44
- function parseJsonText(text) {
13
+ import { a as isRecord, c as parseJsonText, i as isNumberRecord, l as stringsIn, n as isJsonObject, o as optionalNumber, r as isNullableString, s as optionalString, t as isJson } from "./json-guards.js";
14
+ import { decodeLook, defaultLook, encodeLook, isLookCode } from "@diffenderfer-games/hub-contract/avatar";
15
+ //#region src/core/avatar/avatar-cache.ts
16
+ /**
17
+ * The device's avatar cache (avatars.md §5.6), so faces render offline:
18
+ * your own look (`hub:avatar:me`, read synchronously at startup) and an LRU
19
+ * of other players' codes (`hub:avatar:cache`, 200 entries).
20
+ */
21
+ const MINE_KEY = "hub:avatar:me";
22
+ const OTHERS_KEY = "hub:avatar:cache";
23
+ const OTHERS_MAX = 200;
24
+ /** The device cache over a key-value store. */
25
+ var AvatarCache = class {
26
+ constructor(store) {
27
+ _defineProperty(this, "store", void 0);
28
+ _defineProperty(this, "others", null);
29
+ this.store = store;
30
+ }
31
+ /** Your cached look, or `null` (never cached, or another player's on this device). */
32
+ readMine(userId) {
33
+ const parsed = parseJsonText(this.store.read(MINE_KEY) ?? "");
34
+ if (!isRecord(parsed)) return null;
35
+ const id = optionalNumber(parsed.userId);
36
+ const code = optionalString(parsed.code);
37
+ if (id === void 0 || code === void 0 || !isLookCode(code) || userId !== null && id !== userId) return null;
38
+ return {
39
+ userId: id,
40
+ code,
41
+ isDefault: parsed.isDefault === true,
42
+ updatedAt: optionalNumber(parsed.updatedAt) ?? 0
43
+ };
44
+ }
45
+ /** Remember your look. */
46
+ writeMine(mine) {
47
+ this.store.write(MINE_KEY, JSON.stringify(mine));
48
+ this.remember(mine.userId, mine.code);
49
+ }
50
+ /** A player's cached code, or `undefined`. */
51
+ codeOf(userId) {
52
+ return this.loadOthers().get(userId);
53
+ }
54
+ /** Remember a player's code (most recent last; the oldest drop past 200). */
55
+ remember(userId, code) {
56
+ if (code === "") return;
57
+ const others = this.loadOthers();
58
+ if (others.get(userId) === code) return;
59
+ others.delete(userId);
60
+ others.set(userId, code);
61
+ while (others.size > OTHERS_MAX) {
62
+ const oldest = others.keys().next().value;
63
+ if (oldest === void 0) break;
64
+ others.delete(oldest);
65
+ }
66
+ this.store.write(OTHERS_KEY, JSON.stringify([...others]));
67
+ }
68
+ loadOthers() {
69
+ if (this.others !== null) return this.others;
70
+ const others = /* @__PURE__ */ new Map();
71
+ const parsed = parseJsonText(this.store.read(OTHERS_KEY) ?? "[]");
72
+ if (Array.isArray(parsed)) for (const entry of parsed) {
73
+ const id = Array.isArray(entry) ? optionalNumber(entry[0]) : void 0;
74
+ const code = Array.isArray(entry) ? optionalString(entry[1]) : void 0;
75
+ if (id !== void 0 && code !== void 0 && isLookCode(code)) others.set(id, code);
76
+ }
77
+ this.others = others;
78
+ return others;
79
+ }
80
+ };
81
+ //#endregion
82
+ //#region src/core/avatar/avatar-ref.ts
83
+ /** Building {@link AvatarRef}s from codes, payloads and seeds. */
84
+ /** A ref for a code (`''` = hidden). */
85
+ function refOf(code, isDefault = false) {
86
+ if (code === "") return {
87
+ code: "",
88
+ look: null,
89
+ isDefault: false,
90
+ hidden: true
91
+ };
92
+ return {
93
+ code,
94
+ look: decodeLook(code)?.look ?? null,
95
+ isDefault,
96
+ hidden: false
97
+ };
98
+ }
99
+ /** A seeded default look as a ref (bots, previews, local pass-and-play seats). */
100
+ function botRef(seed) {
101
+ const look = defaultLook(seed);
102
+ return {
103
+ code: encodeLook(look),
104
+ look,
105
+ isDefault: true,
106
+ hidden: false
107
+ };
108
+ }
109
+ /** The ref a payload carries; an old hub's payload (no `look`) falls back to the id's default. */
110
+ function refFrom(carrier) {
111
+ if (carrier.look !== void 0) return refOf(carrier.look);
112
+ const id = carrier.userId ?? carrier.id ?? carrier.uid;
113
+ return id === void 0 ? refOf("") : botRef(id);
114
+ }
115
+ //#endregion
116
+ //#region src/core/storage/key-value.ts
117
+ /** A store backed by a `Storage` (`localStorage`). */
118
+ function storageBacked(storage) {
119
+ return {
120
+ read: (key) => storage.getItem(key),
121
+ write: (key, text) => {
122
+ try {
123
+ storage.setItem(key, text);
124
+ } catch {}
125
+ },
126
+ remove: (key) => {
127
+ storage.removeItem(key);
128
+ },
129
+ keys: () => Object.keys(storage)
130
+ };
131
+ }
132
+ /** A store held in memory for this page only. */
133
+ function createMemoryStore() {
134
+ const entries = /* @__PURE__ */ new Map();
135
+ return {
136
+ read: (key) => entries.get(key) ?? null,
137
+ write: (key, text) => {
138
+ entries.set(key, text);
139
+ },
140
+ remove: (key) => {
141
+ entries.delete(key);
142
+ },
143
+ keys: () => [...entries.keys()]
144
+ };
145
+ }
146
+ /** The page's `localStorage`, or `undefined` when it is missing or throws (privacy mode). */
147
+ function pageStorage() {
45
148
  try {
46
- return JSON.parse(text);
149
+ return typeof localStorage === "undefined" ? void 0 : localStorage;
47
150
  } catch {
48
151
  return;
49
152
  }
50
153
  }
51
- /** Whether `candidate` is a JSON object (not an array or `null`). */
52
- function isJsonObject(candidate) {
53
- return isRecord(candidate) && Object.values(candidate).every(isJson);
154
+ /** `localStorage` when available, else a fresh in-memory store. */
155
+ function createPageStore() {
156
+ const storage = pageStorage();
157
+ return storage === void 0 ? createMemoryStore() : storageBacked(storage);
54
158
  }
55
159
  //#endregion
160
+ //#region src/core/avatar/hub-avatar.ts
161
+ const BATCH_MAX = 100;
162
+ const openWithRuntime = createRuntimeOpener(pageBridge);
163
+ /** The look `mine()` gives before the account exists (a new guest's is being made). */
164
+ const NO_ACCOUNT_YET = botRef("you");
165
+ /** `hub.avatar`. */
166
+ var HubAvatar = class {
167
+ constructor(host) {
168
+ _defineProperty(this, "host", void 0);
169
+ _defineProperty(this, "cache", new AvatarCache(createPageStore()));
170
+ _defineProperty(this, "listeners", /* @__PURE__ */ new Set());
171
+ _defineProperty(this, "pending", /* @__PURE__ */ new Map());
172
+ _defineProperty(this, "flushQueued", false);
173
+ _defineProperty(this, "detachRuntime", null);
174
+ _defineProperty(this, "lastUserId", void 0);
175
+ _defineProperty(this, "mineShown", null);
176
+ _defineProperty(this, "seen", /* @__PURE__ */ new Set());
177
+ this.host = host;
178
+ this.lastUserId = host.currentUserId();
179
+ host.onAccount((user, source) => {
180
+ this.onAccount(user, source === "session");
181
+ });
182
+ }
183
+ /**
184
+ * Your look (your default if you never designed one). Works offline from
185
+ * the device cache. Before your account exists (a new guest's is made just
186
+ * after the game starts) it resolves at once with a default look, and
187
+ * `change` brings yours when the account is there.
188
+ */
189
+ async mine() {
190
+ const userId = this.host.currentUserId();
191
+ if (userId === null) return this.showMine(NO_ACCOUNT_YET);
192
+ try {
193
+ const mine = await this.host.fetchMine();
194
+ return this.noteMine(userId, mine);
195
+ } catch (failure) {
196
+ const cached = this.mineSync();
197
+ if (cached !== null) return cached;
198
+ throw failure;
199
+ }
200
+ }
201
+ /** Your cached look, or your default; `null` only while nobody is signed in. */
202
+ mineSync() {
203
+ const userId = this.host.currentUserId();
204
+ if (userId === null) return null;
205
+ const cached = this.cache.readMine(userId);
206
+ return this.showMine(cached === null ? botRef(userId) : refOf(cached.code, cached.isDefault));
207
+ }
208
+ /** Any player's look: from the cache, else batched with other requests in the same tick. */
209
+ of(userId) {
210
+ this.seen.add(userId);
211
+ const cached = this.cache.codeOf(userId);
212
+ if (cached !== void 0) return Promise.resolve(refOf(cached));
213
+ return new Promise((resolve) => {
214
+ const waiting = this.pending.get(userId) ?? [];
215
+ waiting.push((code) => {
216
+ resolve(code === "" ? botRef(userId) : refOf(code));
217
+ });
218
+ this.pending.set(userId, waiting);
219
+ this.scheduleFlush();
220
+ });
221
+ }
222
+ /** Many players' looks at once. */
223
+ async many(userIds) {
224
+ const refs = await Promise.all(userIds.map(async (id) => [id, await this.of(id)]));
225
+ return new Map(refs);
226
+ }
227
+ /** The look a payload already carries (a `RoomSeat`, `PlayerLite`, racer or ticket user). No request. */
228
+ from(carrier) {
229
+ const ref = refFrom(carrier);
230
+ const id = carrier.userId ?? carrier.id ?? carrier.uid;
231
+ if (id !== void 0 && carrier.look !== void 0) {
232
+ this.seen.add(id);
233
+ this.cache.remember(id, carrier.look);
234
+ }
235
+ return ref;
236
+ }
237
+ /** A seeded default for bots, previews and local pass-and-play seats: `bot('local:2')`. */
238
+ bot(seed) {
239
+ return botRef(seed);
240
+ }
241
+ /** Save your look (designer code paths; queued offline, latest wins). */
242
+ async save(look) {
243
+ const code = encodeLook(look);
244
+ const savedAt = Date.now();
245
+ const userId = this.host.currentUserId();
246
+ if (userId !== null) this.noteMine(userId, {
247
+ code,
248
+ isDefault: false,
249
+ updatedAt: savedAt
250
+ });
251
+ await this.host.queueSave(code, savedAt);
252
+ return refOf(code);
253
+ }
254
+ /**
255
+ * Hear about look changes (yours, friends', your room's, and yours when
256
+ * your account appears). Returns the unsubscribe function. Wait for this
257
+ * rather than polling `mine()`.
258
+ */
259
+ on(_event, listener) {
260
+ this.listeners.add(listener);
261
+ this.attachRuntime();
262
+ return () => {
263
+ this.listeners.delete(listener);
264
+ };
265
+ }
266
+ /** Open "Make your avatar" over the game; resolves when it closes. */
267
+ openDesigner(options = {}) {
268
+ return new Promise((resolve) => {
269
+ openWithRuntime((realtime) => {
270
+ if (realtime.avatar === void 0) {
271
+ resolve({ saved: false });
272
+ return;
273
+ }
274
+ realtime.avatar.openDesigner(options).then(resolve, () => {
275
+ resolve({ saved: false });
276
+ });
277
+ });
278
+ });
279
+ }
280
+ /** Loads the renderer and its art; resolves when rendering is synchronous. */
281
+ async load() {
282
+ const avatar = await import("../avatar.js");
283
+ await avatar.ready();
284
+ return avatar;
285
+ }
286
+ /** Remember the ref the page was given for your look. */
287
+ showMine(ref) {
288
+ this.mineShown = ref.code;
289
+ return ref;
290
+ }
291
+ /** Cache your look; `change` fires when it differs from the one the page last had. */
292
+ noteMine(userId, mine) {
293
+ const before = this.mineShown ?? this.cache.readMine(userId)?.code ?? null;
294
+ this.cache.writeMine({
295
+ userId,
296
+ code: mine.code,
297
+ isDefault: mine.isDefault,
298
+ updatedAt: mine.updatedAt
299
+ });
300
+ const ref = this.showMine(refOf(mine.code, mine.isDefault));
301
+ if (mine.code !== before) this.emit({
302
+ userId,
303
+ ref
304
+ });
305
+ return ref;
306
+ }
307
+ /** Someone said who is signed in: fetch your look when the account is new or its look isn't the cached one. */
308
+ onAccount(user, isHello) {
309
+ const userId = user === null || user.id === 0 ? null : user.id;
310
+ const isNewAccount = userId !== this.lastUserId;
311
+ this.lastUserId = userId;
312
+ if (user === null || userId === null) return;
313
+ if (isNewAccount || user.look !== null && user.look !== this.cache.readMine(userId)?.code) this.refreshMine(userId);
314
+ if (isHello) this.recheckSeen();
315
+ }
316
+ async refreshMine(userId) {
317
+ const mine = await this.host.fetchMine().catch(() => null);
318
+ if (mine !== null && this.host.currentUserId() === userId) this.noteMine(userId, mine);
319
+ }
320
+ /** The socket (re)connected: a change sent while it was down never arrived, so ask again. */
321
+ async recheckSeen() {
322
+ if (this.listeners.size === 0 || this.seen.size === 0) return;
323
+ const codes = await this.fetchCodes([...this.seen]);
324
+ for (const [userId, code] of codes) if (code !== "" && code !== this.cache.codeOf(userId)) {
325
+ this.cache.remember(userId, code);
326
+ this.emit({
327
+ userId,
328
+ ref: refOf(code)
329
+ });
330
+ }
331
+ }
332
+ emit(change) {
333
+ for (const listener of this.listeners) listener(change);
334
+ }
335
+ attachRuntime() {
336
+ if (this.detachRuntime !== null) return;
337
+ this.detachRuntime = pageBridge.subscribe((realtime) => realtime.avatar?.on("change", ({ userId, code }) => {
338
+ if (userId === this.host.currentUserId()) {
339
+ this.noteMine(userId, {
340
+ code,
341
+ isDefault: false,
342
+ updatedAt: Date.now()
343
+ });
344
+ return;
345
+ }
346
+ this.cache.remember(userId, code);
347
+ this.emit({
348
+ userId,
349
+ ref: refOf(code)
350
+ });
351
+ }) ?? (() => void 0));
352
+ }
353
+ scheduleFlush() {
354
+ if (this.flushQueued) return;
355
+ this.flushQueued = true;
356
+ queueMicrotask(() => {
357
+ this.flushQueued = false;
358
+ this.flush();
359
+ });
360
+ }
361
+ async flush() {
362
+ const batch = this.pending;
363
+ this.pending = /* @__PURE__ */ new Map();
364
+ const codes = await this.fetchCodes([...batch.keys()]);
365
+ for (const [id, waiting] of batch) {
366
+ const code = codes.get(id) ?? "";
367
+ if (code !== "") this.cache.remember(id, code);
368
+ for (const resolve of waiting) resolve(code);
369
+ }
370
+ }
371
+ /** Players' codes, in batches; offline or failing, the missing ones are left out (they get their default). */
372
+ async fetchCodes(ids) {
373
+ const codes = /* @__PURE__ */ new Map();
374
+ for (let i = 0; i < ids.length; i += BATCH_MAX) {
375
+ const slice = ids.slice(i, i + BATCH_MAX);
376
+ const answered = await this.host.fetchCodes(slice).catch(() => ({}));
377
+ for (const id of slice) {
378
+ const code = answered[String(id)];
379
+ if (code !== void 0) codes.set(id, code);
380
+ }
381
+ }
382
+ return codes;
383
+ }
384
+ };
385
+ //#endregion
56
386
  //#region src/core/errors.ts
57
387
  /**
58
388
  * The code a client gives a call the hub does not have yet (a newer client on
@@ -60,6 +390,12 @@ function isJsonObject(candidate) {
60
390
  */
61
391
  const UNSUPPORTED_BY_HUB = "unsupported_by_hub";
62
392
  /**
393
+ * The code `getSave` rejects with when the hub can't be reached and this
394
+ * device has no copy of the slot it can trust (a fresh device or cleared
395
+ * browser): the slot may well hold a save, so it is not reported empty.
396
+ */
397
+ const SAVE_UNKNOWN_CODE = "save_unknown";
398
+ /**
63
399
  * Thrown by every hub call that fails. `status` is the HTTP status when the
64
400
  * hub answered (401 = not signed in, 404 = unknown game or board, 413 = too
65
401
  * large), or `undefined` for a transport failure (offline, no host). Social
@@ -75,6 +411,7 @@ var HubError = class extends Error {
75
411
  _defineProperty(this, "hint", void 0);
76
412
  _defineProperty(this, "retryAfterMs", void 0);
77
413
  _defineProperty(this, "until", void 0);
414
+ _defineProperty(this, "rule", void 0);
78
415
  this.name = "HubError";
79
416
  this.status = status;
80
417
  this.code = details.code;
@@ -82,6 +419,7 @@ var HubError = class extends Error {
82
419
  this.hint = details.hint;
83
420
  this.retryAfterMs = details.retryAfterMs;
84
421
  this.until = details.until;
422
+ this.rule = details.rule;
85
423
  }
86
424
  /** `true` when the failure was a network or transport error, not an HTTP status. */
87
425
  get isOffline() {
@@ -117,12 +455,13 @@ function readErrorFields(body) {
117
455
  reason: optionalString(body.reason),
118
456
  hint: optionalString(body.hint),
119
457
  retryAfterMs: optionalNumber(body.retryAfterMs),
120
- until: optionalNumber(body.until)
458
+ until: optionalNumber(body.until),
459
+ rule: optionalString(body.rule)
121
460
  };
122
461
  }
123
462
  /** The right error class for a failure: a 422 or `content_rejected` is a {@link ContentRejectedError}. */
124
463
  function errorFor(message, status, details) {
125
- return status === 422 || details.code === "content_rejected" ? new ContentRejectedError(message, status, details) : new HubError(message, status, details);
464
+ return status === 422 && details.code !== "illegal" || details.code === "content_rejected" ? new ContentRejectedError(message, status, details) : new HubError(message, status, details);
126
465
  }
127
466
  /**
128
467
  * Re-create an error from another bundle (the social runtime, or another
@@ -138,50 +477,6 @@ function toHubError(thrown) {
138
477
  return errorFor(message, optionalNumber(thrown.status), fields);
139
478
  }
140
479
  //#endregion
141
- //#region src/core/storage/key-value.ts
142
- /** A store backed by a `Storage` (`localStorage`). */
143
- function storageBacked(storage) {
144
- return {
145
- read: (key) => storage.getItem(key),
146
- write: (key, text) => {
147
- try {
148
- storage.setItem(key, text);
149
- } catch {}
150
- },
151
- remove: (key) => {
152
- storage.removeItem(key);
153
- },
154
- keys: () => Object.keys(storage)
155
- };
156
- }
157
- /** A store held in memory for this page only. */
158
- function createMemoryStore() {
159
- const entries = /* @__PURE__ */ new Map();
160
- return {
161
- read: (key) => entries.get(key) ?? null,
162
- write: (key, text) => {
163
- entries.set(key, text);
164
- },
165
- remove: (key) => {
166
- entries.delete(key);
167
- },
168
- keys: () => [...entries.keys()]
169
- };
170
- }
171
- /** The page's `localStorage`, or `undefined` when it is missing or throws (privacy mode). */
172
- function pageStorage() {
173
- try {
174
- return typeof localStorage === "undefined" ? void 0 : localStorage;
175
- } catch {
176
- return;
177
- }
178
- }
179
- /** `localStorage` when available, else a fresh in-memory store. */
180
- function createPageStore() {
181
- const storage = pageStorage();
182
- return storage === void 0 ? createMemoryStore() : storageBacked(storage);
183
- }
184
- //#endregion
185
480
  //#region src/core/offline/favorites.ts
186
481
  /**
187
482
  * Starred games are site-wide, not per game, so they live under one global
@@ -255,6 +550,36 @@ function gameKind(definition) {
255
550
  };
256
551
  }
257
552
  //#endregion
553
+ //#region src/core/offline/hub-kinds/avatar-kind.ts
554
+ /** The kind name. */
555
+ const AVATAR_KIND = "hub.avatar";
556
+ function readAvatar(stored) {
557
+ if (!isRecord(stored)) return;
558
+ const code = optionalString(stored.code);
559
+ const savedAt = optionalNumber(stored.savedAt);
560
+ return code === void 0 || savedAt === void 0 ? void 0 : {
561
+ code,
562
+ savedAt
563
+ };
564
+ }
565
+ /** The avatar kind, by name. */
566
+ function avatarKinds() {
567
+ return { [AVATAR_KIND]: checkedKind(readAvatar, {
568
+ mergeKey: () => "avatar",
569
+ replaces: true,
570
+ run: async (op, context) => {
571
+ const look = decodeLook(op.code)?.look;
572
+ if (look !== void 0) await context.call("saveMyAvatar", {
573
+ look: {
574
+ ...look,
575
+ accessories: [...look.accessories]
576
+ },
577
+ savedAt: op.savedAt
578
+ });
579
+ }
580
+ }) };
581
+ }
582
+ //#endregion
258
583
  //#region src/core/offline/hub-kinds/daily-kind.ts
259
584
  /** The kind name. */
260
585
  const DAILY_KIND = "hub.daily";
@@ -555,23 +880,36 @@ function mergeStats(waiting, incoming) {
555
880
  case "set": return incoming;
556
881
  }
557
882
  }
558
- /** The progress kinds, by name. */
559
- function progressKinds(game) {
883
+ /**
884
+ * The progress kinds, by name. A save write replayed from the queue sends
885
+ * the hub's `updatedAt` this device last saw for the slot (`null` when it
886
+ * saw it empty or never saw it), so the hub refuses it (409 `save_conflict`,
887
+ * dropped and reported as a conflict) when another device saved since. A
888
+ * write sent at once, online, always lands, as before.
889
+ */
890
+ function progressKinds(game, cloudSaves) {
560
891
  return {
561
892
  [PROGRESS_KINDS.save]: checkedKind(readSave$1, {
562
893
  mergeKey: (save) => save.slot,
563
894
  replaces: true,
564
895
  run: async (save, context) => {
565
- if ("deleted" in save) await context.call("deleteSave", {
566
- ...game(),
567
- slot: save.slot
568
- });
569
- else await context.call("writeSave", {
896
+ if ("deleted" in save) {
897
+ await context.call("deleteSave", {
898
+ ...game(),
899
+ slot: save.slot
900
+ });
901
+ cloudSaves.rememberCloudSave(save.slot, null);
902
+ return;
903
+ }
904
+ const base = context.isReplay ? { base: cloudSaves.cloudSave(save.slot) ?? null } : {};
905
+ const written = await context.call("writeSave", {
570
906
  ...game(),
571
907
  slot: save.slot,
572
908
  data: save.data,
573
- label: save.label
909
+ label: save.label,
910
+ ...base
574
911
  });
912
+ cloudSaves.rememberCloudSave(save.slot, written.updatedAt);
575
913
  }
576
914
  }),
577
915
  [PROGRESS_KINDS.stat]: checkedKind(readStat, {
@@ -706,6 +1044,74 @@ function resumeKinds(game, local) {
706
1044
  }) };
707
1045
  }
708
1046
  //#endregion
1047
+ //#region src/core/offline/hub-kinds/saved-kind.ts
1048
+ /** The kind name. */
1049
+ const SAVED_KIND = "hub.saved";
1050
+ function readSaved(stored) {
1051
+ if (!isRecord(stored) || stored.action !== "save" && stored.action !== "discard") return;
1052
+ const game = optionalString(stored.game);
1053
+ const day = optionalString(stored.day);
1054
+ const at = optionalNumber(stored.at);
1055
+ return game === void 0 || day === void 0 || at === void 0 ? void 0 : {
1056
+ action: stored.action,
1057
+ game,
1058
+ day,
1059
+ at
1060
+ };
1061
+ }
1062
+ /** Whether a failure means the instance is gone or closed on the hub (the change is moot). */
1063
+ function isGoneOrClosed(failure) {
1064
+ return failure instanceof HubError && (failure.status === 404 || failure.status === 409);
1065
+ }
1066
+ /** The hub's id for the copy's day: its own, or the one the hub gives the day when it was started offline (`null`: already completed). */
1067
+ async function resolveId(copy, context) {
1068
+ if (!copy.id.startsWith("local:")) return copy.id;
1069
+ return (await context.call("startDailyChallenge", {
1070
+ slug: copy.game,
1071
+ day: copy.day
1072
+ })).instanceId;
1073
+ }
1074
+ /** Run one op against the copy it names. */
1075
+ async function runSaved(op, local, context) {
1076
+ const copy = local.read(op.game, op.day);
1077
+ if (copy === null) return;
1078
+ try {
1079
+ const id = await resolveId(copy, context);
1080
+ if (id === null) {
1081
+ local.remove(op.game, op.day);
1082
+ return;
1083
+ }
1084
+ if (op.action === "discard") {
1085
+ await context.call("discardSavedGame", { id });
1086
+ local.remove(op.game, op.day);
1087
+ return;
1088
+ }
1089
+ if (copy.status === "active") await context.call("saveSavedGameState", {
1090
+ id,
1091
+ state: copy.state,
1092
+ progress: {
1093
+ label: copy.label,
1094
+ fraction: copy.fraction
1095
+ },
1096
+ title: copy.title,
1097
+ version: copy.version,
1098
+ savedAt: copy.at
1099
+ });
1100
+ local.markSynced(op.game, op.day, copy.at, id);
1101
+ } catch (failure) {
1102
+ if (!(failure instanceof Error) || !isGoneOrClosed(failure)) throw failure;
1103
+ local.remove(op.game, op.day);
1104
+ }
1105
+ }
1106
+ /** The saved-games kind, by name, over the device copies in `local`. */
1107
+ function savedKinds(local) {
1108
+ return { [SAVED_KIND]: checkedKind(readSaved, {
1109
+ mergeKey: (op) => `daily:${op.game}#${op.day}`,
1110
+ replaces: true,
1111
+ run: (op, context) => runSaved(op, local, context)
1112
+ }) };
1113
+ }
1114
+ //#endregion
709
1115
  //#region src/core/offline/hub-kinds/legacy-ops.ts
710
1116
  /** The outbox operation for a v1 queued write, or `undefined` when it no longer reads as one. */
711
1117
  function legacyOperation(op) {
@@ -774,13 +1180,70 @@ function legacyOperation(op) {
774
1180
  /** Register every hub kind on `outbox`. */
775
1181
  function registerHubKinds(outbox, hooks) {
776
1182
  const kinds = {
777
- ...progressKinds(hooks.game),
1183
+ ...progressKinds(hooks.game, hooks.cloudSaves),
778
1184
  ...dailyKinds(hooks.game, hooks.onDailyComplete),
779
1185
  ...economyKinds(hooks.game, writeLocalFavorites),
780
- ...resumeKinds(hooks.game, hooks.resume)
1186
+ ...resumeKinds(hooks.game, hooks.resume),
1187
+ ...avatarKinds(),
1188
+ ...savedKinds(hooks.dailies)
781
1189
  };
782
1190
  for (const [kind, definition] of Object.entries(kinds)) outbox.define(kind, definition);
783
1191
  }
1192
+ //#endregion
1193
+ //#region src/core/offline/cloud-saves.ts
1194
+ /**
1195
+ * What this device last saw of a game's save slots on the hub, per player:
1196
+ * each slot's `updatedAt` there, or `null` for a slot known to be empty. It
1197
+ * decides two things offline. `getSave` answers from the cache only when
1198
+ * the device knows the hub's copy of that slot (so a fresh device or cleared
1199
+ * browser never reports "no save" for a slot it simply hasn't seen), and a
1200
+ * queued save write sends the hub's `updatedAt` it was built on, so the hub
1201
+ * refuses it when the slot has changed since.
1202
+ *
1203
+ * A snapshot gives the full list (`complete`: any other slot is empty); a
1204
+ * single read or write adds that slot to a partial list.
1205
+ */
1206
+ /** A stored index, or `null` when missing or malformed. */
1207
+ function readCloudSaveIndex(stored) {
1208
+ if (!isRecord(stored) || typeof stored.complete !== "boolean" || !isRecord(stored.saves)) return null;
1209
+ const userId = optionalNumber(stored.userId);
1210
+ if (userId === void 0) return null;
1211
+ const saves = {};
1212
+ for (const [slot, updatedAt] of Object.entries(stored.saves)) {
1213
+ const known = updatedAt === null ? null : optionalNumber(updatedAt);
1214
+ if (known === void 0) return null;
1215
+ saves[slot] = known;
1216
+ }
1217
+ return {
1218
+ userId,
1219
+ complete: stored.complete,
1220
+ saves
1221
+ };
1222
+ }
1223
+ /**
1224
+ * The hub's `updatedAt` for `slot` as `userId` last saw it: a number, `null`
1225
+ * when the slot was empty, or `undefined` when this device doesn't know.
1226
+ */
1227
+ function cloudSaveOf(index, userId, slot) {
1228
+ if (index?.userId !== userId) return;
1229
+ const updatedAt = index.saves[slot];
1230
+ return updatedAt === void 0 && index.complete ? null : updatedAt;
1231
+ }
1232
+ /** `index` with `slot` seen at `updatedAt` (`null`: empty) by `userId`; another player's index starts over. */
1233
+ function withCloudSave(index, userId, slot, updatedAt) {
1234
+ const base = index?.userId === userId ? index : {
1235
+ userId,
1236
+ complete: false,
1237
+ saves: {}
1238
+ };
1239
+ return {
1240
+ ...base,
1241
+ saves: {
1242
+ ...base.saves,
1243
+ [slot]: updatedAt
1244
+ }
1245
+ };
1246
+ }
784
1247
  /** The well-formed events of a stored buffer. */
785
1248
  function readEvents(stored) {
786
1249
  if (!Array.isArray(stored)) return [];
@@ -867,7 +1330,7 @@ function withoutGoods(inventory, goods) {
867
1330
  //#region src/core/offline/local-store.ts
868
1331
  /** The key of the locally known user, shared by every game (like the real session). */
869
1332
  const USER_KEY = "hub:user";
870
- function readUser(stored) {
1333
+ function readUser$1(stored) {
871
1334
  if (!isRecord(stored)) return null;
872
1335
  const id = optionalNumber(stored.id);
873
1336
  const username = optionalString(stored.username);
@@ -939,7 +1402,7 @@ var LocalStore = class {
939
1402
  }
940
1403
  /** The locally known user. */
941
1404
  getUser() {
942
- return readUser(this.readStored(USER_KEY));
1405
+ return readUser$1(this.readStored(USER_KEY));
943
1406
  }
944
1407
  /** Remember the user (offline sign-in, or a snapshot). */
945
1408
  setUser(user) {
@@ -994,6 +1457,28 @@ var LocalStore = class {
994
1457
  deleteSave(slot) {
995
1458
  this.store.remove(`${this.prefix}save:${slot}`);
996
1459
  }
1460
+ /**
1461
+ * The hub's `updatedAt` for `slot` as the current user last saw it (`null`:
1462
+ * the slot was empty), or `undefined` when this device doesn't know.
1463
+ */
1464
+ cloudSave(slot) {
1465
+ const user = this.getUser();
1466
+ return user === null ? void 0 : cloudSaveOf(this.readCloudSaves(this.prefix), user.id, slot);
1467
+ }
1468
+ /** Note the hub's copy of `slot` (just read or written) for the current user: its `updatedAt`, or `null` when empty. */
1469
+ rememberCloudSave(slot, updatedAt) {
1470
+ const user = this.getUser();
1471
+ if (user !== null) this.writeStored(`${this.prefix}cloud-saves`, withCloudSave(this.readCloudSaves(this.prefix), user.id, slot, updatedAt));
1472
+ }
1473
+ /** Cache a slot as read from the hub (forgetting it when the hub has none), and note the hub's copy. */
1474
+ cacheCloudSave(slot, save) {
1475
+ if (save === null) this.deleteSave(slot);
1476
+ else this.writeStored(`${this.prefix}save:${slot}`, save);
1477
+ this.rememberCloudSave(slot, save?.updatedAt ?? null);
1478
+ }
1479
+ readCloudSaves(gamePrefix) {
1480
+ return readCloudSaveIndex(this.readStored(`${gamePrefix}cloud-saves`));
1481
+ }
997
1482
  /** This game's cached stats. */
998
1483
  getStats() {
999
1484
  const stored = this.readStored(`${this.prefix}stats`);
@@ -1070,13 +1555,32 @@ var LocalStore = class {
1070
1555
  ts: Date.now()
1071
1556
  }));
1072
1557
  }
1073
- /** Fan a `/me/snapshot` reply out across every game's cache keys. */
1558
+ /**
1559
+ * Fan a `/me/snapshot` reply out across every game's cache keys. It lists
1560
+ * every save slot of every game the player has played, so each game's
1561
+ * slots on the hub become known (and this game's are all empty when it
1562
+ * isn't listed).
1563
+ */
1074
1564
  hydrate(snapshot) {
1075
1565
  if (snapshot.user !== null) this.writeStored(USER_KEY, snapshot.user);
1566
+ const userId = snapshot.user?.id;
1567
+ if (userId !== void 0 && !Object.keys(snapshot.games).some((slug) => `hub:${slug}:` === this.prefix)) this.writeStored(`${this.prefix}cloud-saves`, {
1568
+ userId,
1569
+ complete: true,
1570
+ saves: {}
1571
+ });
1076
1572
  for (const [slug, game] of Object.entries(snapshot.games)) {
1077
1573
  const gamePrefix = `hub:${slug}:`;
1078
1574
  if (game.profile !== null) this.writeStored(`${gamePrefix}profile`, game.profile);
1079
1575
  for (const save of game.saves) this.writeStored(`${gamePrefix}save:${save.slot}`, save);
1576
+ if (userId !== void 0) {
1577
+ const saves = Object.fromEntries(game.saves.map((save) => [save.slot, save.updatedAt]));
1578
+ this.writeStored(`${gamePrefix}cloud-saves`, {
1579
+ userId,
1580
+ complete: true,
1581
+ saves
1582
+ });
1583
+ }
1080
1584
  this.writeStored(`${gamePrefix}stats`, game.stats);
1081
1585
  this.writeStored(`${gamePrefix}inventory`, game.inventory);
1082
1586
  }
@@ -1171,7 +1675,7 @@ function newEntryId(now, sequence) {
1171
1675
  return `${String(now).padStart(TIME_DIGITS, "0")}-${String(sequence % 10 ** SEQUENCE_DIGITS).padStart(SEQUENCE_DIGITS, "0")}-${tag}`;
1172
1676
  }
1173
1677
  /** A stored entry, or `undefined` when it is malformed. */
1174
- function readEntry(stored) {
1678
+ function readEntry$1(stored) {
1175
1679
  if (!isRecord(stored) || !isJson(stored.payload)) return;
1176
1680
  const id = optionalString(stored.id);
1177
1681
  const kind = optionalString(stored.kind);
@@ -1394,7 +1898,7 @@ var OutboxStore = class {
1394
1898
  const entries = [];
1395
1899
  for (const key of this.entryKeys()) {
1396
1900
  const text = this.store.read(key);
1397
- const entry = text === null ? void 0 : readEntry(parseJsonText(text));
1901
+ const entry = text === null ? void 0 : readEntry$1(parseJsonText(text));
1398
1902
  if (entry === void 0) this.store.remove(key);
1399
1903
  else entries.push(entry);
1400
1904
  }
@@ -1407,7 +1911,7 @@ var OutboxStore = class {
1407
1911
  /** One entry, or `undefined` once it is gone (replayed, or removed by another tab). */
1408
1912
  get(id) {
1409
1913
  const text = this.store.read(this.prefix + id);
1410
- return text === null ? void 0 : readEntry(parseJsonText(text));
1914
+ return text === null ? void 0 : readEntry$1(parseJsonText(text));
1411
1915
  }
1412
1916
  /** Whether `extra` more stored characters in one more entry still fit under the caps. */
1413
1917
  fits(extraChars) {
@@ -1662,8 +2166,8 @@ function outboxFull() {
1662
2166
  }
1663
2167
  //#endregion
1664
2168
  //#region src/core/resume/local-resume.ts
1665
- const KEY_PATTERN = /^hub:(.+):resume$/;
1666
- function keyFor(slug) {
2169
+ const KEY_PATTERN$1 = /^hub:(.+):resume$/;
2170
+ function keyFor$1(slug) {
1667
2171
  return `hub:${slug}:resume`;
1668
2172
  }
1669
2173
  /** A stored record read back, or `null` when missing or malformed. */
@@ -1705,18 +2209,18 @@ var LocalResumeStore = class {
1705
2209
  }
1706
2210
  /** `slug`'s copy, or `null`. */
1707
2211
  read(slug) {
1708
- const text = this.store.read(keyFor(slug));
2212
+ const text = this.store.read(keyFor$1(slug));
1709
2213
  return text === null ? null : readRecord(parseJsonText(text));
1710
2214
  }
1711
2215
  /** Store `slug`'s copy; answers whether it stuck (a full storage refuses a large state). */
1712
2216
  write(slug, record) {
1713
2217
  const text = JSON.stringify(record);
1714
- this.store.write(keyFor(slug), text);
1715
- return this.store.read(keyFor(slug)) === text;
2218
+ this.store.write(keyFor$1(slug), text);
2219
+ return this.store.read(keyFor$1(slug)) === text;
1716
2220
  }
1717
2221
  /** Forget `slug`'s copy. */
1718
2222
  remove(slug) {
1719
- this.store.remove(keyFor(slug));
2223
+ this.store.remove(keyFor$1(slug));
1720
2224
  }
1721
2225
  /** The hub now has `slug`'s save or clear made at `at`: mark it synced (a clear is simply forgotten). */
1722
2226
  markSynced(slug, at) {
@@ -1732,7 +2236,7 @@ var LocalResumeStore = class {
1732
2236
  all() {
1733
2237
  const records = /* @__PURE__ */ new Map();
1734
2238
  for (const key of this.store.keys()) {
1735
- const slug = KEY_PATTERN.exec(key)?.[1];
2239
+ const slug = KEY_PATTERN$1.exec(key)?.[1];
1736
2240
  const record = slug === void 0 ? null : this.read(slug);
1737
2241
  if (slug !== void 0 && record !== null) records.set(slug, record);
1738
2242
  }
@@ -1740,26 +2244,118 @@ var LocalResumeStore = class {
1740
2244
  }
1741
2245
  };
1742
2246
  //#endregion
1743
- //#region src/core/page.ts
1744
- /** Whether this code runs in a browser page. */
1745
- function isBrowserPage() {
1746
- return typeof window !== "undefined";
2247
+ //#region src/core/saved/local-saved.ts
2248
+ const KEY_PATTERN = /^hub:(.+):daily:(\d{4}-\d{2}-\d{2})$/;
2249
+ const STATUSES = [
2250
+ "active",
2251
+ "finished",
2252
+ "discarded"
2253
+ ];
2254
+ function keyFor(game, day) {
2255
+ return `hub:${game}:daily:${day}`;
1747
2256
  }
1748
- /** The injected config's slug and API base, read defensively (empty outside a hub page). */
1749
- function injectedConfig() {
1750
- if (!isBrowserPage() || !isRecord(window.__HUB__)) return {};
1751
- const slug = optionalString(window.__HUB__.slug);
1752
- const apiBase = optionalString(window.__HUB__.apiBase);
2257
+ /** A stored copy read back, or `null` when missing or malformed. */
2258
+ function readCopy(stored) {
2259
+ if (!isRecord(stored)) return null;
2260
+ const id = optionalString(stored.id);
2261
+ const game = optionalString(stored.game);
2262
+ const day = optionalString(stored.day);
2263
+ const seed = optionalNumber(stored.seed);
2264
+ const at = optionalNumber(stored.at);
2265
+ const label = optionalString(stored.label);
2266
+ const version = optionalNumber(stored.version);
2267
+ const gameTitle = optionalString(stored.gameTitle);
2268
+ const userId = stored.userId === null ? null : optionalNumber(stored.userId);
2269
+ const fraction = stored.fraction === null ? null : optionalNumber(stored.fraction);
2270
+ const status = STATUSES.find((candidate) => candidate === stored.status);
2271
+ if (id === void 0 || game === void 0 || day === void 0 || seed === void 0 || at === void 0 || label === void 0 || version === void 0 || gameTitle === void 0 || userId === void 0 || fraction === void 0 || status === void 0 || !isNullableString(stored.title) || !isJson(stored.state)) return null;
1753
2272
  return {
1754
- ...slug === void 0 ? {} : { slug },
1755
- ...apiBase === void 0 ? {} : { apiBase }
1756
- };
1757
- }
1758
- /** The game's display title: the host's injected one, else the page's `<title>`, else `fallback`. */
1759
- function detectGameTitle(fallback) {
1760
- const injected = isBrowserPage() && isRecord(window.__HUB__) ? optionalString(window.__HUB__.title) : void 0;
1761
- if (injected !== void 0 && injected !== "") return injected;
1762
- const pageTitle = typeof document === "undefined" ? "" : document.title.trim();
2273
+ id,
2274
+ game,
2275
+ day,
2276
+ seed,
2277
+ status,
2278
+ userId,
2279
+ at,
2280
+ label,
2281
+ fraction,
2282
+ title: stored.title,
2283
+ version,
2284
+ gameTitle,
2285
+ state: stored.state,
2286
+ isSynced: stored.isSynced === true
2287
+ };
2288
+ }
2289
+ /** The device copies of daily challenges. */
2290
+ var LocalDailyStore = class {
2291
+ constructor(store) {
2292
+ _defineProperty(this, "store", void 0);
2293
+ this.store = store;
2294
+ }
2295
+ /** The copy of `game`'s `day`, or `null`. */
2296
+ read(game, day) {
2297
+ const text = this.store.read(keyFor(game, day));
2298
+ return text === null ? null : readCopy(parseJsonText(text));
2299
+ }
2300
+ /** Store a copy; answers whether it stuck (a full storage refuses a large state). */
2301
+ write(copy) {
2302
+ const text = JSON.stringify(copy);
2303
+ this.store.write(keyFor(copy.game, copy.day), text);
2304
+ return this.store.read(keyFor(copy.game, copy.day)) === text;
2305
+ }
2306
+ /** Forget a copy. */
2307
+ remove(game, day) {
2308
+ this.store.remove(keyFor(game, day));
2309
+ }
2310
+ /** The hub has the change made at `at` (and calls the instance `id`): mark it synced; a closed copy is forgotten. */
2311
+ markSynced(game, day, at, id) {
2312
+ const copy = this.read(game, day);
2313
+ if (copy === null || copy.at !== at) {
2314
+ if (copy !== null && id !== void 0 && copy.id !== id) this.write({
2315
+ ...copy,
2316
+ id
2317
+ });
2318
+ return;
2319
+ }
2320
+ if (copy.status === "active") this.write({
2321
+ ...copy,
2322
+ id: id ?? copy.id,
2323
+ isSynced: true
2324
+ });
2325
+ else this.remove(game, day);
2326
+ }
2327
+ /** Every copy of `userId`'s, any game. */
2328
+ all(userId) {
2329
+ const copies = [];
2330
+ for (const key of this.store.keys()) {
2331
+ const match = KEY_PATTERN.exec(key);
2332
+ const copy = match?.[1] === void 0 || match[2] === void 0 ? null : this.read(match[1], match[2]);
2333
+ if (copy !== null && copy.userId === userId) copies.push(copy);
2334
+ }
2335
+ return copies;
2336
+ }
2337
+ };
2338
+ //#endregion
2339
+ //#region src/core/page.ts
2340
+ /** Whether this code runs in a browser page. */
2341
+ function isBrowserPage() {
2342
+ return typeof window !== "undefined";
2343
+ }
2344
+ /** The injected config's slug and API base, read defensively (empty outside a hub page). */
2345
+ function injectedConfig() {
2346
+ if (!isBrowserPage() || !isRecord(window.__HUB__)) return {};
2347
+ const slug = optionalString(window.__HUB__.slug);
2348
+ const apiBase = optionalString(window.__HUB__.apiBase);
2349
+ return {
2350
+ ...slug === void 0 ? {} : { slug },
2351
+ ...apiBase === void 0 ? {} : { apiBase }
2352
+ };
2353
+ }
2354
+ /** The game's display title: the host's injected one, else the page's `<title>`, else `fallback`. */
2355
+ function detectGameTitle(fallback) {
2356
+ const injected = isBrowserPage() && isRecord(window.__HUB__) ? optionalString(window.__HUB__.title) : void 0;
2357
+ if (injected !== void 0 && injected !== "") return injected;
2358
+ const pageTitle = typeof document === "undefined" ? "" : document.title.trim();
1763
2359
  return pageTitle === "" ? fallback : pageTitle;
1764
2360
  }
1765
2361
  /** The game's slug: the injected one, else the first URL path segment, else `null`. */
@@ -1969,6 +2565,12 @@ const ROUTES = {
1969
2565
  query: [],
1970
2566
  offline: null
1971
2567
  },
2568
+ resumeDailyChallenge: {
2569
+ method: "POST",
2570
+ path: "/daily/instances/:id/resume",
2571
+ query: [],
2572
+ offline: null
2573
+ },
1972
2574
  startDailyChallenge: {
1973
2575
  method: "POST",
1974
2576
  path: "/games/:slug/daily/:day/start",
@@ -2209,6 +2811,12 @@ const ROUTES = {
2209
2811
  query: [],
2210
2812
  offline: null
2211
2813
  },
2814
+ setQuietHours: {
2815
+ method: "PUT",
2816
+ path: "/social/notify/quiet",
2817
+ query: [],
2818
+ offline: null
2819
+ },
2212
2820
  subscribeToPush: {
2213
2821
  method: "POST",
2214
2822
  path: "/social/push",
@@ -2441,6 +3049,174 @@ const ROUTES = {
2441
3049
  path: "/me/resume",
2442
3050
  query: [],
2443
3051
  offline: null
3052
+ },
3053
+ getMyAvatar: {
3054
+ method: "GET",
3055
+ path: "/avatar/me",
3056
+ query: [],
3057
+ offline: null
3058
+ },
3059
+ recordAvatarNudge: {
3060
+ method: "POST",
3061
+ path: "/avatar/me/nudge",
3062
+ query: [],
3063
+ offline: null
3064
+ },
3065
+ saveMyAvatar: {
3066
+ method: "PUT",
3067
+ path: "/avatar/me",
3068
+ query: [],
3069
+ offline: null
3070
+ },
3071
+ resetMyAvatar: {
3072
+ method: "DELETE",
3073
+ path: "/avatar/me",
3074
+ query: [],
3075
+ offline: null
3076
+ },
3077
+ getAvatar: {
3078
+ method: "GET",
3079
+ path: "/avatar/of/:id",
3080
+ query: [],
3081
+ offline: null
3082
+ },
3083
+ getAvatars: {
3084
+ method: "POST",
3085
+ path: "/avatar/batch",
3086
+ query: [],
3087
+ offline: null
3088
+ },
3089
+ listMySavedGames: {
3090
+ method: "GET",
3091
+ path: "/saved",
3092
+ query: ["game", "kinds"],
3093
+ offline: null
3094
+ },
3095
+ getSavedGame: {
3096
+ method: "GET",
3097
+ path: "/saved/:id",
3098
+ query: [],
3099
+ offline: null
3100
+ },
3101
+ saveSavedGameState: {
3102
+ method: "PUT",
3103
+ path: "/saved/:id/state",
3104
+ query: [],
3105
+ offline: "replay"
3106
+ },
3107
+ discardSavedGame: {
3108
+ method: "POST",
3109
+ path: "/saved/:id/discard",
3110
+ query: [],
3111
+ offline: "replay"
3112
+ },
3113
+ discardGameSavedGames: {
3114
+ method: "POST",
3115
+ path: "/games/:slug/saved/discard",
3116
+ query: [],
3117
+ offline: null
3118
+ },
3119
+ createTurnGame: {
3120
+ method: "POST",
3121
+ path: "/turns",
3122
+ query: [],
3123
+ offline: null
3124
+ },
3125
+ joinTurnGame: {
3126
+ method: "POST",
3127
+ path: "/turns/:id/join",
3128
+ query: [],
3129
+ offline: null
3130
+ },
3131
+ joinTurnGameByLink: {
3132
+ method: "POST",
3133
+ path: "/turns/join",
3134
+ query: [],
3135
+ offline: null
3136
+ },
3137
+ startTurnGame: {
3138
+ method: "POST",
3139
+ path: "/turns/:id/start",
3140
+ query: [],
3141
+ offline: null
3142
+ },
3143
+ moveTurnGame: {
3144
+ method: "POST",
3145
+ path: "/turns/:id/moves",
3146
+ query: [],
3147
+ offline: null
3148
+ },
3149
+ leaveTurnGame: {
3150
+ method: "POST",
3151
+ path: "/turns/:id/leave",
3152
+ query: [],
3153
+ offline: null
3154
+ },
3155
+ nudgeTurnGame: {
3156
+ method: "POST",
3157
+ path: "/turns/:id/nudge",
3158
+ query: [],
3159
+ offline: null
3160
+ },
3161
+ setTurnGameNotify: {
3162
+ method: "PUT",
3163
+ path: "/turns/:id/notify",
3164
+ query: [],
3165
+ offline: null
3166
+ },
3167
+ rematchTurnGame: {
3168
+ method: "POST",
3169
+ path: "/turns/:id/rematch",
3170
+ query: [],
3171
+ offline: null
3172
+ },
3173
+ inviteToTurnGame: {
3174
+ method: "POST",
3175
+ path: "/turns/:id/invite",
3176
+ query: [],
3177
+ offline: null
3178
+ },
3179
+ shareTurnGame: {
3180
+ method: "POST",
3181
+ path: "/turns/:id/share",
3182
+ query: [],
3183
+ offline: null
3184
+ },
3185
+ autoPlayTurn: {
3186
+ method: "POST",
3187
+ path: "/turns/:id/auto",
3188
+ query: [],
3189
+ offline: null
3190
+ },
3191
+ chatInTurnGame: {
3192
+ method: "POST",
3193
+ path: "/turns/:id/chat",
3194
+ query: [],
3195
+ offline: null
3196
+ },
3197
+ getTurnGame: {
3198
+ method: "GET",
3199
+ path: "/turns/:id",
3200
+ query: [],
3201
+ offline: null
3202
+ },
3203
+ getTurnGameHistory: {
3204
+ method: "GET",
3205
+ path: "/turns/:id/history",
3206
+ query: ["since"],
3207
+ offline: null
3208
+ },
3209
+ listOpenTurnGames: {
3210
+ method: "GET",
3211
+ path: "/open-turns",
3212
+ query: ["game"],
3213
+ offline: null
3214
+ },
3215
+ listMyTurnGames: {
3216
+ method: "GET",
3217
+ path: "/turns",
3218
+ query: ["game"],
3219
+ offline: null
2444
3220
  }
2445
3221
  };
2446
3222
  /** The request header that carries an offline operation key (the contract's `OFFLINE_OP_HEADER`). */
@@ -2565,6 +3341,7 @@ var HubConnection = class {
2565
3341
  _defineProperty(this, "store", void 0);
2566
3342
  _defineProperty(this, "outbox", void 0);
2567
3343
  _defineProperty(this, "resumeStore", void 0);
3344
+ _defineProperty(this, "dailyStore", void 0);
2568
3345
  _defineProperty(this, "offlineMode", void 0);
2569
3346
  _defineProperty(this, "isReachable", true);
2570
3347
  _defineProperty(this, "runningSync", null);
@@ -2587,6 +3364,7 @@ var HubConnection = class {
2587
3364
  const pageStore = createPageStore();
2588
3365
  this.store = new LocalStore(this.slug ?? "app", pageStore);
2589
3366
  this.resumeStore = new LocalResumeStore(pageStore);
3367
+ this.dailyStore = new LocalDailyStore(pageStore);
2590
3368
  this.outbox = new Outbox({
2591
3369
  slug: this.slug ?? "app",
2592
3370
  store: pageStore,
@@ -2605,6 +3383,7 @@ var HubConnection = class {
2605
3383
  registerHubKinds(this.outbox, {
2606
3384
  game: () => this.gameFields(),
2607
3385
  onDailyComplete: (payload, completion) => {
3386
+ if (this.slug !== null) this.dailyStore.remove(this.slug, payload.day);
2608
3387
  emitWindowEvent("hub:daily-complete", {
2609
3388
  day: payload.day,
2610
3389
  count: completion.count,
@@ -2612,7 +3391,9 @@ var HubConnection = class {
2612
3391
  meta: payload.meta
2613
3392
  });
2614
3393
  },
2615
- resume: this.resumeStore
3394
+ resume: this.resumeStore,
3395
+ dailies: this.dailyStore,
3396
+ cloudSaves: this.store
2616
3397
  });
2617
3398
  this.importLegacyQueue();
2618
3399
  if (typeof navigator !== "undefined" && "onLine" in navigator) this.isReachable = navigator.onLine;
@@ -2844,6 +3625,93 @@ var HubConnection = class {
2844
3625
  }
2845
3626
  };
2846
3627
  //#endregion
3628
+ //#region src/core/page-account.ts
3629
+ /**
3630
+ * Who the page is signed in as, as the hub tells it, so `hub.userId()` and
3631
+ * `hub.avatar` follow the account without the game calling `hub.me()`:
3632
+ *
3633
+ * - the menu's `hub:account` (every refresh: after it makes a new guest's
3634
+ * account, after sign-in) and `hub:auth` (the account changed);
3635
+ * - the social runtime's {@link SESSION_EVENT} each time its socket says
3636
+ * hello (connected, or connected again after a drop);
3637
+ * - this client's own sign-in, sign-up, claim and sign-out.
3638
+ *
3639
+ * One per page: every bundle's copy hears the same window events.
3640
+ */
3641
+ /** The window events that say who is signed in (`{ user }`). */
3642
+ const ACCOUNT_EVENTS = ["hub:account", "hub:auth"];
3643
+ /** The page's account, as last heard. */
3644
+ var PageAccount = class {
3645
+ constructor() {
3646
+ _defineProperty(this, "heard", void 0);
3647
+ _defineProperty(this, "listeners", /* @__PURE__ */ new Set());
3648
+ _defineProperty(this, "isListening", false);
3649
+ this.listen();
3650
+ }
3651
+ /** The signed-in player, `null` when signed out, `undefined` before anything said. */
3652
+ get user() {
3653
+ this.listen();
3654
+ return this.heard;
3655
+ }
3656
+ /** Record who is signed in (this client's own sign-in or sign-out) and tell the listeners. */
3657
+ note(user, source = "account") {
3658
+ this.heard = user;
3659
+ for (const listener of [...this.listeners]) listener(user, source);
3660
+ }
3661
+ /** Hear every report; returns the unsubscribe function. */
3662
+ on(listener) {
3663
+ this.listen();
3664
+ this.listeners.add(listener);
3665
+ return () => {
3666
+ this.listeners.delete(listener);
3667
+ };
3668
+ }
3669
+ /** Listen to the window events (at once in a page; made before a window existed, on first use). */
3670
+ listen() {
3671
+ if (this.isListening || !isBrowserPage()) return;
3672
+ this.isListening = true;
3673
+ for (const eventName of ACCOUNT_EVENTS) window.addEventListener(eventName, (event) => {
3674
+ this.onEvent(event, "account");
3675
+ });
3676
+ window.addEventListener(SESSION_EVENT, (event) => {
3677
+ this.onEvent(event, "session");
3678
+ });
3679
+ }
3680
+ onEvent(event, source) {
3681
+ if (!(event instanceof CustomEvent)) return;
3682
+ const user = readUser(event.detail);
3683
+ if (user !== void 0) this.note(user, source);
3684
+ }
3685
+ };
3686
+ /** A page user from an account the hub sent (`look` when it has one). */
3687
+ function toPageUser(user) {
3688
+ return {
3689
+ id: user.id,
3690
+ look: lookOrNull(user.look)
3691
+ };
3692
+ }
3693
+ /** A non-empty look code, else `null`. */
3694
+ function lookOrNull(look) {
3695
+ return typeof look === "string" && look !== "" ? look : null;
3696
+ }
3697
+ /** The user in a `{ user }` detail: the user, `null` (signed out), or `undefined` (it doesn't say). */
3698
+ function readUser(detail) {
3699
+ const user = readField(detail, "user");
3700
+ if (user === null) return null;
3701
+ const id = readField(user, "id");
3702
+ if (typeof id !== "number" || !Number.isInteger(id)) return;
3703
+ return {
3704
+ id,
3705
+ look: lookOrNull(readField(user, "look"))
3706
+ };
3707
+ }
3708
+ let pageAccountSingleton = null;
3709
+ /** The page's account tracker (made on first use; the hub client makes it at load). */
3710
+ function pageAccount() {
3711
+ pageAccountSingleton ?? (pageAccountSingleton = new PageAccount());
3712
+ return pageAccountSingleton;
3713
+ }
3714
+ //#endregion
2847
3715
  //#region src/core/hub-progress.ts
2848
3716
  /** A profile with the game's own data typed as `TData`. */
2849
3717
  function typedProfile(profile) {
@@ -2861,9 +3729,29 @@ function typedSave(save) {
2861
3729
  }
2862
3730
  /** Accounts, profile, saves, stats and leaderboards. */
2863
3731
  var HubProgress = class extends HubConnection {
3732
+ constructor(..._args) {
3733
+ super(..._args);
3734
+ _defineProperty(this, "account", pageAccount());
3735
+ }
3736
+ /**
3737
+ * Your user id, or `null` when nobody is signed in (also before an offline
3738
+ * first visit has an account). Synchronous, and it follows the account as
3739
+ * the hub changes it (a new guest's account made by the menu, sign-in,
3740
+ * sign-out) with no `hub.me()` needed. Compare it with a seat's `userId`
3741
+ * to find yours.
3742
+ */
3743
+ userId() {
3744
+ const heard = this.account.user;
3745
+ const id = heard === void 0 ? this.store.getUser()?.id ?? null : heard?.id ?? null;
3746
+ return id === 0 ? null : id;
3747
+ }
2864
3748
  /** The current user (or `null`) and the games they have a profile in. */
2865
3749
  me() {
2866
- return this.withFallback(() => this.rest.call("getCurrentUser"), () => {
3750
+ return this.withFallback(async () => {
3751
+ const reply = await this.rest.call("getCurrentUser");
3752
+ this.account.note(reply.user === null ? null : toPageUser(reply.user));
3753
+ return reply;
3754
+ }, () => {
2867
3755
  const profile = this.store.getProfile();
2868
3756
  return {
2869
3757
  user: this.store.getUser(),
@@ -2880,16 +3768,14 @@ var HubProgress = class extends HubConnection {
2880
3768
  * sign-in. Idempotent. Offline, a local guest is used.
2881
3769
  */
2882
3770
  ensureGuest() {
2883
- return this.withFallback(() => this.rest.call("ensureGuestSession"), () => {
3771
+ return this.withFallback(async () => this.signedIn(await this.rest.call("ensureGuestSession")), () => {
2884
3772
  const known = this.store.getUser();
2885
3773
  if (known !== null) return { user: known };
2886
- const guest = {
3774
+ return this.signInLocally({
2887
3775
  id: 0,
2888
3776
  username: "Guest-local",
2889
3777
  guest: true
2890
- };
2891
- this.store.setUser(guest);
2892
- return { user: guest };
3778
+ });
2893
3779
  });
2894
3780
  }
2895
3781
  /**
@@ -2898,10 +3784,10 @@ var HubProgress = class extends HubConnection {
2898
3784
  * the name is taken; the player should `login` instead.
2899
3785
  */
2900
3786
  claim(username, password) {
2901
- return this.withFallback(() => this.rest.call("claimGuestAccount", {
3787
+ return this.withFallback(async () => this.signedIn(await this.rest.call("claimGuestAccount", {
2902
3788
  username,
2903
3789
  password
2904
- }), () => this.signInLocally({
3790
+ })), () => this.signInLocally({
2905
3791
  id: 0,
2906
3792
  username,
2907
3793
  guest: false
@@ -2909,35 +3795,53 @@ var HubProgress = class extends HubConnection {
2909
3795
  }
2910
3796
  /** Create an account and sign in. (Offline: a local user is stored.) */
2911
3797
  signup(username, password) {
2912
- return this.withFallback(() => this.rest.call("signUpWithPassword", {
3798
+ return this.withFallback(async () => this.signedIn(await this.rest.call("signUpWithPassword", {
2913
3799
  username,
2914
3800
  password
2915
- }), () => this.signInLocally({
3801
+ })), () => this.signInLocally({
2916
3802
  id: 0,
2917
3803
  username
2918
3804
  }));
2919
3805
  }
2920
3806
  /** Sign in. (Offline: a local user is stored so signed-in flows work.) */
2921
3807
  login(username, password) {
2922
- return this.withFallback(() => this.rest.call("logInWithPassword", {
3808
+ return this.withFallback(async () => this.signedIn(await this.rest.call("logInWithPassword", {
2923
3809
  username,
2924
3810
  password
2925
- }), () => this.signInLocally({
3811
+ })), () => this.signInLocally({
2926
3812
  id: 0,
2927
3813
  username
2928
3814
  }));
2929
3815
  }
2930
3816
  /** Sign out (ends the session shared by every game). */
2931
3817
  logout() {
2932
- return this.withFallback(() => this.rest.call("logOut"), () => {
2933
- this.store.clearUser();
3818
+ return this.withFallback(async () => {
3819
+ const reply = await this.rest.call("logOut");
3820
+ this.signedOut();
3821
+ return reply;
3822
+ }, () => {
3823
+ this.signedOut();
2934
3824
  return { ok: true };
2935
3825
  });
2936
3826
  }
2937
3827
  signInLocally(user) {
2938
- this.store.setUser(user);
3828
+ this.remember(user);
2939
3829
  return { user };
2940
3830
  }
3831
+ /** Remember who the hub signed in, so the cache's notes about the hub's copies are read for the right player. */
3832
+ signedIn(reply) {
3833
+ this.remember(reply.user);
3834
+ return reply;
3835
+ }
3836
+ /** The cache and the page (`hub.userId()`, `hub.avatar`) learn who is signed in. */
3837
+ remember(user) {
3838
+ this.store.setUser(user);
3839
+ this.account.note(toPageUser(user));
3840
+ }
3841
+ signedOut() {
3842
+ this.store.clearUser();
3843
+ this.account.note(null);
3844
+ }
2941
3845
  /** This game's profile for the current user, or `profile: null`. `TData` is the game's own data type. */
2942
3846
  getProfile() {
2943
3847
  return this.withFallback(async () => ({ profile: typedProfile((await this.rest.call("getGameProfile", this.gameFields())).profile) }), () => ({ profile: typedProfile(this.store.getProfile()) }));
@@ -2960,12 +3864,29 @@ var HubProgress = class extends HubConnection {
2960
3864
  listSaves() {
2961
3865
  return this.withFallback(() => this.rest.call("listSaves", this.gameFields()), () => ({ saves: this.store.listSaves() }));
2962
3866
  }
2963
- /** One slot's contents, or `save: null` when the slot is empty. `TData` is the game's own save type. */
3867
+ /**
3868
+ * One slot's contents, or `save: null` when the slot is empty. `TData` is
3869
+ * the game's own save type. Offline it answers from the device's copy, but
3870
+ * only when the device knows the hub's copy of the slot; otherwise (a fresh
3871
+ * device or cleared browser) it rejects with a {@link HubError} whose
3872
+ * `code` is `save_unknown` (`isOffline` is `true`), never a false "empty".
3873
+ */
2964
3874
  getSave(slot) {
2965
- return this.withFallback(async () => ({ save: typedSave((await this.rest.call("getSave", {
2966
- ...this.gameFields(),
2967
- slot
2968
- })).save) }), () => ({ save: typedSave(this.store.getSave(slot)) }));
3875
+ return this.withFallback(async () => {
3876
+ const { save } = await this.rest.call("getSave", {
3877
+ ...this.gameFields(),
3878
+ slot
3879
+ });
3880
+ if (this.outbox.count() === 0) this.store.cacheCloudSave(slot, save);
3881
+ return { save: typedSave(save) };
3882
+ }, () => ({ save: typedSave(this.offlineSave(slot)) }));
3883
+ }
3884
+ /** The device's copy of `slot`, when it can be trusted offline (see {@link getSave}). */
3885
+ offlineSave(slot) {
3886
+ const isLocalOnly = this.store.getUser()?.id === 0;
3887
+ const hasQueuedWrite = this.outbox.pending().some((op) => op.kind === PROGRESS_KINDS.save && isRecord(op.payload) && op.payload.slot === slot);
3888
+ if (!isLocalOnly && !hasQueuedWrite && this.store.cloudSave(slot) === void 0) throw new HubError(`Hub: offline, and this device hasn't seen save "${slot}" yet.`, void 0, { code: SAVE_UNKNOWN_CODE });
3889
+ return this.store.getSave(slot);
2969
3890
  }
2970
3891
  /** Create or overwrite a save slot. `saveData` is any JSON up to 64 KB. */
2971
3892
  putSave(slot, saveData, label) {
@@ -3376,7 +4297,7 @@ function mergeResumeLists(hub, copies, userId) {
3376
4297
  /** The query parameter (the contract's `RESUME_REQUEST_PARAM`; the browser client carries no contract code). */
3377
4298
  const RESUME_REQUEST_PARAM = "hub_resume";
3378
4299
  /** Where the answer is kept for the page. */
3379
- const PAGE_FLAG = "__HUB_RESUME_REQUESTED__";
4300
+ const PAGE_FLAG$2 = "__HUB_RESUME_REQUESTED__";
3380
4301
  /** Take `hub_resume` out of the address bar, keeping the rest of the URL and the history state. */
3381
4302
  function stripParam(url) {
3382
4303
  url.searchParams.delete(RESUME_REQUEST_PARAM);
@@ -3398,9 +4319,9 @@ function takeParam() {
3398
4319
  /** `true` when the page was opened to resume (synchronous, so a game can decide before it routes or draws). */
3399
4320
  function isResumeRequested() {
3400
4321
  if (!isBrowserPage()) return false;
3401
- if (Reflect.has(window, PAGE_FLAG)) return Reflect.get(window, PAGE_FLAG) === true;
4322
+ if (Reflect.has(window, PAGE_FLAG$2)) return Reflect.get(window, PAGE_FLAG$2) === true;
3402
4323
  const isRequested = takeParam();
3403
- Reflect.set(window, PAGE_FLAG, isRequested);
4324
+ Reflect.set(window, PAGE_FLAG$2, isRequested);
3404
4325
  return isRequested;
3405
4326
  }
3406
4327
  //#endregion
@@ -3413,14 +4334,17 @@ const RESUME_MAX_STATE_BYTES = 524288;
3413
4334
  const BLOCKED_HINTS = {
3414
4335
  daily: "a daily challenge is active. When the player leaves a daily without finishing it, call hub.daily.cancel().",
3415
4336
  race: "a race is on: races are never resumed.",
3416
- room: "the page is in a hub room: online games are never resumed."
4337
+ room: "the page is in a hub room: online games are never resumed.",
4338
+ turns: "an online game is open: the hub keeps its state, so nothing is saved on the page."
3417
4339
  };
3418
4340
  /** Longest label or title kept (the contract's `RESUME_TEXT_MAX`). */
3419
- const TEXT_MAX = 60;
4341
+ const TEXT_MAX$1 = 60;
4342
+ /** The label a daily goes back to when the game clears its run mid-daily (the day restarts from its seed). */
4343
+ const DAILY_RESTART_LABEL = "Started";
3420
4344
  /** A label or title trimmed and clipped, or `null` when blank. */
3421
- function shortText(text) {
4345
+ function shortText$1(text) {
3422
4346
  const trimmed = (text ?? "").trim();
3423
- return trimmed === "" ? null : trimmed.slice(0, TEXT_MAX);
4347
+ return trimmed === "" ? null : trimmed.slice(0, TEXT_MAX$1);
3424
4348
  }
3425
4349
  /** The UTF-8 size of `text`. */
3426
4350
  function byteSize(text) {
@@ -3474,8 +4398,9 @@ var HubResume = class {
3474
4398
  /**
3475
4399
  * Save the run (the latest save wins). Stored on the device at once; sent
3476
4400
  * after {@link RESUME_DEBOUNCE_MS} of quiet, or when the page is hidden.
3477
- * Returns `false` (and saves nothing) in a race, a daily challenge or an
3478
- * online room. Throws a `HubError` 413 over 512 KB, 400 without a label.
4401
+ * Returns `false` (and saves nothing) in a race or an online room; during
4402
+ * a daily challenge the save goes to the day (`hub.daily.save`). Throws a
4403
+ * `HubError` 413 over 512 KB, 400 without a label.
3479
4404
  */
3480
4405
  save(run) {
3481
4406
  const blocker = this.host.blocker();
@@ -3483,8 +4408,10 @@ var HubResume = class {
3483
4408
  this.warnBlocked(blocker);
3484
4409
  return false;
3485
4410
  }
4411
+ const daily = this.host.daily?.() ?? null;
4412
+ if (daily !== null) return daily.save(run);
3486
4413
  const slug = this.requireSlug();
3487
- const label = shortText(run.progress.label);
4414
+ const label = shortText$1(run.progress.label);
3488
4415
  if (label === null) throw new HubError("hub.resume.save: progress.label is required.", 400);
3489
4416
  const state = toStoredJson(run.state);
3490
4417
  if (byteSize(JSON.stringify(state)) > 524288) throw new HubError(`hub.resume.save: state exceeds ${String(RESUME_MAX_STATE_BYTES)} bytes.`, 413);
@@ -3495,13 +4422,14 @@ var HubResume = class {
3495
4422
  at: Date.now(),
3496
4423
  label,
3497
4424
  fraction,
3498
- title: shortText(run.title),
4425
+ title: shortText$1(run.title),
3499
4426
  version: Math.max(0, Math.floor(run.version ?? 0)),
3500
4427
  gameTitle: this.host.gameTitle(),
3501
4428
  state,
3502
4429
  isSynced: false
3503
4430
  };
3504
4431
  const isStored = this.host.local.write(slug, copy);
4432
+ this.host.onChange?.();
3505
4433
  this.schedule({
3506
4434
  action: "save",
3507
4435
  savedAt: copy.at,
@@ -3526,6 +4454,18 @@ var HubResume = class {
3526
4454
  /** Clear the run: call it when the run ends (won, lost or quit). Works offline. */
3527
4455
  async clear() {
3528
4456
  const slug = this.requireSlug();
4457
+ const daily = this.host.daily?.() ?? null;
4458
+ if (daily !== null) {
4459
+ daily.save({
4460
+ state: null,
4461
+ progress: { label: DAILY_RESTART_LABEL }
4462
+ });
4463
+ return;
4464
+ }
4465
+ await this.clearRun(slug);
4466
+ }
4467
+ /** Delete the game's saved run (never the day being played). */
4468
+ async clearRun(slug) {
3529
4469
  if (this.timer !== null) {
3530
4470
  clearTimeout(this.timer);
3531
4471
  this.timer = null;
@@ -3538,6 +4478,7 @@ var HubResume = class {
3538
4478
  at,
3539
4479
  isSynced: false
3540
4480
  });
4481
+ this.host.onChange?.();
3541
4482
  await this.host.send({
3542
4483
  action: "clear",
3543
4484
  before: at
@@ -3556,7 +4497,7 @@ var HubResume = class {
3556
4497
  run = mine?.status === "saved" && !isClearedElsewhere ? runFromCopy(mine) : null;
3557
4498
  }
3558
4499
  if (run === null || options.version === void 0 || run.version === options.version) return run;
3559
- if (run.version < options.version) await this.clear();
4500
+ if (run.version < options.version) await this.clearRun(slug);
3560
4501
  return null;
3561
4502
  }
3562
4503
  /** The saved state, or `null` (see {@link get} for the version rule). */
@@ -3604,39 +4545,949 @@ var HubResume = class {
3604
4545
  }
3605
4546
  };
3606
4547
  //#endregion
3607
- //#region src/core/ticket.ts
4548
+ //#region src/core/saved/daily-seed.ts
3608
4549
  /**
3609
- * The own-server ticket request (`hub.mp.ticket()`): a 60-second identity
3610
- * ticket for this game's own server, fetched straight from the hub. It must
3611
- * not depend on the social runtime: on the first visit after a deploy the
3612
- * service worker can still serve an older menu that never loads it. When the
3613
- * page has no session yet, a guest one is created and the request retried
3614
- * once.
4550
+ * The deterministic seed of a (game, day), the same FNV-1a as the
4551
+ * contract's `seedForDay` (the browser client carries no contract code; a
4552
+ * test keeps the two in step). Used for a day started offline.
3615
4553
  */
3616
- /** A REST client for the page's API base that uses the global fetch at call time. */
3617
- function pageRestClient() {
3618
- return createRestClient({
3619
- apiBase: detectApiBase(),
3620
- fetch: (input, init) => globalThis.fetch(input, init)
3621
- });
4554
+ /** The uint32 seed for `game` on `day`. */
4555
+ function dailySeed(game, day) {
4556
+ let hash = 2166136261;
4557
+ const seedInput = `${game}#${day}`;
4558
+ for (let i = 0; i < seedInput.length; i++) {
4559
+ hash ^= seedInput.charCodeAt(i);
4560
+ hash = Math.imul(hash, 16777619);
4561
+ }
4562
+ return hash >>> 0;
3622
4563
  }
3623
- /** One ticket request; a transport failure is an {@link OfflineError}. */
3624
- async function requestTicket(rest, slug) {
3625
- try {
3626
- return (await rest.call("getGameServerTicket", { slug })).ticket;
3627
- } catch (failure) {
3628
- throw failure instanceof HubError && failure.isOffline ? new OfflineError() : failure;
3629
- }
4564
+ //#endregion
4565
+ //#region src/core/saved/saved-order.ts
4566
+ /** The player must move in this online game now. */
4567
+ function isYourTurn(entry) {
4568
+ return entry.turns?.isMyTurn === true && entry.turns.phase === "playing";
3630
4569
  }
3631
- /** A ticket for this page's game; signs in a guest first when the page has no session. */
3632
- async function fetchGameServerTicket() {
3633
- const slug = detectSlug();
3634
- if (slug === null) throw new HubError("Hub: could not determine the game slug.");
3635
- const rest = pageRestClient();
3636
- try {
3637
- return await requestTicket(rest, slug);
3638
- } catch (failure) {
3639
- if (!(failure instanceof HubError) || failure.status !== 401) throw failure;
4570
+ /** Online games waiting on the player first (oldest turn first), then the newest. */
4571
+ function compareSaved(a, b) {
4572
+ const isATurn = isYourTurn(a);
4573
+ if (isATurn !== isYourTurn(b)) return isATurn ? -1 : 1;
4574
+ if (isATurn && a.turns !== void 0 && b.turns !== void 0 && a.turns.turnStartedAt !== b.turns.turnStartedAt) return a.turns.turnStartedAt - b.turns.turnStartedAt;
4575
+ if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt;
4576
+ return a.id < b.id ? 1 : -1;
4577
+ }
4578
+ /** The badge counts of a list of games in progress. */
4579
+ function countSaved(entries) {
4580
+ const counts = {
4581
+ yourTurn: 0,
4582
+ dailies: 0,
4583
+ runs: 0,
4584
+ waiting: 0,
4585
+ total: entries.length
4586
+ };
4587
+ for (const entry of entries) if (entry.kind === "daily") counts.dailies += 1;
4588
+ else if (entry.kind === "run") counts.runs += 1;
4589
+ else if (isYourTurn(entry)) counts.yourTurn += 1;
4590
+ else counts.waiting += 1;
4591
+ return counts;
4592
+ }
4593
+ //#endregion
4594
+ //#region src/core/saved/saved-list.ts
4595
+ /**
4596
+ * A player's games in progress: the hub's list merged with this device's
4597
+ * copies of runs (`hub.resume`) and daily challenges (`hub.daily.save`), so
4598
+ * one saved offline (or just before the tab closed) shows at once, and one
4599
+ * cleared or discarded offline disappears at once. Pure.
4600
+ */
4601
+ /** The id a run saved only on this device goes by until the hub has it. */
4602
+ function localRunId(slug) {
4603
+ return `local:${slug}:run`;
4604
+ }
4605
+ /** The id a daily started only on this device goes by until the hub has it. */
4606
+ function localDailyId(slug, day) {
4607
+ return `local:${slug}:${day}`;
4608
+ }
4609
+ /** A device run as an entry, with the game's catalog fields from the hub's entry when it has one. */
4610
+ function runEntry(slug, run, known) {
4611
+ return {
4612
+ id: known?.id ?? localRunId(slug),
4613
+ game: slug,
4614
+ gameTitle: known?.gameTitle ?? run.gameTitle,
4615
+ path: known?.path ?? `/${slug}`,
4616
+ hasImage: known?.hasImage ?? false,
4617
+ kind: "run",
4618
+ status: "active",
4619
+ title: run.title,
4620
+ progress: {
4621
+ label: run.label,
4622
+ fraction: run.fraction
4623
+ },
4624
+ version: run.version,
4625
+ createdAt: known?.createdAt ?? run.at,
4626
+ updatedAt: run.at,
4627
+ finishedAt: null,
4628
+ isLocalOnly: !run.isSynced
4629
+ };
4630
+ }
4631
+ /** One game's run: the hub's, the device's newer copy, or none (cleared on the device). */
4632
+ function mergeRun(slug, hub, copy, isHubKnown) {
4633
+ if (copy === void 0) return hub ?? null;
4634
+ if (copy.status === "cleared") return hub !== void 0 && hub.updatedAt > copy.at ? hub : null;
4635
+ if (hub !== void 0 && hub.updatedAt >= copy.at) return hub;
4636
+ return !copy.isSynced || !isHubKnown || hub !== void 0 ? runEntry(slug, copy, hub) : null;
4637
+ }
4638
+ /** A device daily as an entry. */
4639
+ function dailyEntry(daily, known) {
4640
+ return {
4641
+ id: daily.id,
4642
+ game: daily.game,
4643
+ gameTitle: known?.gameTitle ?? daily.gameTitle,
4644
+ path: known?.path ?? `/${daily.game}`,
4645
+ hasImage: known?.hasImage ?? false,
4646
+ kind: "daily",
4647
+ status: "active",
4648
+ title: daily.title,
4649
+ progress: {
4650
+ label: daily.label,
4651
+ fraction: daily.fraction
4652
+ },
4653
+ version: daily.version,
4654
+ createdAt: known?.createdAt ?? daily.at,
4655
+ updatedAt: daily.at,
4656
+ finishedAt: null,
4657
+ daily: {
4658
+ day: daily.day,
4659
+ seed: daily.seed
4660
+ },
4661
+ isLocalOnly: !daily.isSynced
4662
+ };
4663
+ }
4664
+ /** One daily: the hub's entry, the device's newer copy, or none (discarded or finished on the device). */
4665
+ function mergeDaily(hub, copy, isHubKnown) {
4666
+ if (copy === void 0) return hub ?? null;
4667
+ if (copy.status !== "active") return hub !== void 0 && hub.updatedAt > copy.at ? hub : null;
4668
+ if (hub !== void 0 && hub.updatedAt >= copy.at) return hub;
4669
+ return !copy.isSynced || !isHubKnown || hub !== void 0 ? dailyEntry(copy, hub) : null;
4670
+ }
4671
+ /** The key that pairs a hub daily with a device copy: its game and day. */
4672
+ const dailyKey = (game, day) => `${game}#${day}`;
4673
+ /**
4674
+ * The entries to show, in the shared list order. `hub` is the hub's list,
4675
+ * or `undefined` when it couldn't be reached; `copies` are the device's
4676
+ * (already only the player's); `game` limits the device copies to one game.
4677
+ */
4678
+ function mergeSavedLists(hub, copies, game) {
4679
+ const isHubKnown = hub !== void 0;
4680
+ const fromHub = hub ?? [];
4681
+ const runs = new Map(fromHub.filter((entry) => entry.kind === "run").map((entry) => [entry.game, entry]));
4682
+ const dailies = new Map(fromHub.flatMap((entry) => entry.kind === "daily" && entry.daily !== void 0 ? [[dailyKey(entry.game, entry.daily.day), entry]] : []));
4683
+ const runCopies = new Map([...copies.runs].filter(([slug]) => game === null || slug === game));
4684
+ const dailyCopies = new Map(copies.dailies.filter((copy) => game === null || copy.game === game).map((copy) => [dailyKey(copy.game, copy.day), copy]));
4685
+ const merged = fromHub.filter((entry) => entry.kind === "turns");
4686
+ for (const slug of /* @__PURE__ */ new Set([...runs.keys(), ...runCopies.keys()])) {
4687
+ const entry = mergeRun(slug, runs.get(slug), runCopies.get(slug), isHubKnown);
4688
+ if (entry !== null) merged.push(entry);
4689
+ }
4690
+ for (const key of /* @__PURE__ */ new Set([...dailies.keys(), ...dailyCopies.keys()])) {
4691
+ const entry = mergeDaily(dailies.get(key), dailyCopies.get(key), isHubKnown);
4692
+ if (entry !== null) merged.push(entry);
4693
+ }
4694
+ return merged.sort(compareSaved);
4695
+ }
4696
+ //#endregion
4697
+ //#region src/core/saved/daily-saves.ts
4698
+ /** How long saves must be quiet before the latest is sent, ms (the same as `hub.resume`). */
4699
+ const DAILY_SAVE_DEBOUNCE_MS = 3e3;
4700
+ /** Largest state, bytes of JSON (the contract's `SAVED_MAX_STATE_BYTES`). */
4701
+ const MAX_STATE_BYTES = 524288;
4702
+ /** Longest label or title kept. */
4703
+ const TEXT_MAX = 60;
4704
+ /** The label of a day started and not saved yet. */
4705
+ const STARTED_LABEL = "Started";
4706
+ /** A label or title trimmed and clipped, or `null` when blank. */
4707
+ function shortText(text) {
4708
+ const trimmed = (text ?? "").trim();
4709
+ return trimmed === "" ? null : trimmed.slice(0, TEXT_MAX);
4710
+ }
4711
+ /** A game's daily saves. */
4712
+ var DailySaves = class {
4713
+ constructor(host) {
4714
+ _defineProperty(this, "host", void 0);
4715
+ _defineProperty(this, "timers", /* @__PURE__ */ new Map());
4716
+ _defineProperty(this, "waiting", /* @__PURE__ */ new Map());
4717
+ _defineProperty(this, "isListening", false);
4718
+ this.host = host;
4719
+ }
4720
+ /**
4721
+ * The answer a device-only start gives while the hub can't be reached:
4722
+ * the day's copy here (its saved state) or a fresh one. No token: the
4723
+ * completion fetches one when the hub is back.
4724
+ */
4725
+ startLocally(day) {
4726
+ const userId = this.host.currentUserId();
4727
+ const existing = this.host.local.read(this.host.slug, day);
4728
+ const copy = existing !== null && existing.status === "active" && existing.userId === userId ? existing : {
4729
+ id: localDailyId(this.host.slug, day),
4730
+ game: this.host.slug,
4731
+ day,
4732
+ seed: dailySeed(this.host.slug, day),
4733
+ status: "active",
4734
+ userId,
4735
+ at: Date.now(),
4736
+ label: STARTED_LABEL,
4737
+ fraction: null,
4738
+ title: null,
4739
+ version: 0,
4740
+ gameTitle: this.host.gameTitle(),
4741
+ state: null,
4742
+ isSynced: false
4743
+ };
4744
+ if (copy !== existing) this.host.local.write(copy);
4745
+ return {
4746
+ token: "",
4747
+ day,
4748
+ seed: copy.seed,
4749
+ expiresAt: 0,
4750
+ alreadyCompleted: false,
4751
+ completedAt: null,
4752
+ meta: null,
4753
+ instanceId: copy.id,
4754
+ saved: copy.state,
4755
+ savedVersion: copy.version,
4756
+ resumed: existing !== null
4757
+ };
4758
+ }
4759
+ /** The hub started (or resumed) a day: keep a copy that knows its id, unless the device has a newer save. */
4760
+ remember(started) {
4761
+ if (started.instanceId === null) return started;
4762
+ const existing = this.host.local.read(this.host.slug, started.day);
4763
+ if (existing !== null && existing.status === "active" && existing.userId === this.host.currentUserId() && !existing.isSynced) {
4764
+ this.host.local.write({
4765
+ ...existing,
4766
+ id: started.instanceId
4767
+ });
4768
+ return {
4769
+ ...started,
4770
+ saved: existing.state,
4771
+ savedVersion: existing.version
4772
+ };
4773
+ }
4774
+ return started;
4775
+ }
4776
+ /** Save the active day's state: on the device now, to the hub soon. */
4777
+ save(challenge, input) {
4778
+ const label = shortText(input.progress.label);
4779
+ if (label === null) throw new HubError("hub.daily.save: progress.label is required.", 400);
4780
+ const state = toStoredJson(input.state);
4781
+ if (new TextEncoder().encode(JSON.stringify(state)).length > MAX_STATE_BYTES) throw new HubError(`hub.daily.save: state exceeds ${String(MAX_STATE_BYTES)} bytes.`, 413);
4782
+ if (challenge.instanceId === null) return false;
4783
+ const fraction = typeof input.progress.fraction === "number" && Number.isFinite(input.progress.fraction) ? Math.min(1, Math.max(0, input.progress.fraction)) : null;
4784
+ const copy = {
4785
+ id: challenge.instanceId,
4786
+ game: this.host.slug,
4787
+ day: challenge.day,
4788
+ seed: challenge.seed,
4789
+ status: "active",
4790
+ userId: this.host.currentUserId(),
4791
+ at: Date.now(),
4792
+ label,
4793
+ fraction,
4794
+ title: shortText(input.title),
4795
+ version: Math.max(0, Math.floor(input.version ?? 0)),
4796
+ gameTitle: this.host.gameTitle(),
4797
+ state,
4798
+ isSynced: false
4799
+ };
4800
+ if (!this.host.local.write(copy)) return false;
4801
+ this.schedule({
4802
+ action: "save",
4803
+ game: copy.game,
4804
+ day: copy.day,
4805
+ at: copy.at
4806
+ });
4807
+ return true;
4808
+ }
4809
+ /** Give the day up: the copy says so at once, the hub hears it now (or when back online). */
4810
+ async discard(challenge) {
4811
+ this.dropWaiting(challenge.day);
4812
+ const at = Date.now();
4813
+ const base = this.host.local.read(this.host.slug, challenge.day) ?? this.blankCopy(challenge);
4814
+ this.host.local.write({
4815
+ ...base,
4816
+ id: challenge.instanceId ?? base.id,
4817
+ status: "discarded",
4818
+ at,
4819
+ isSynced: false
4820
+ });
4821
+ await this.host.send({
4822
+ action: "discard",
4823
+ game: this.host.slug,
4824
+ day: challenge.day,
4825
+ at
4826
+ });
4827
+ }
4828
+ /** The day was won: no save is waiting for it any more, and the lists hide it until the hub confirms. */
4829
+ finished(day) {
4830
+ this.dropWaiting(day);
4831
+ const existing = this.host.local.read(this.host.slug, day);
4832
+ if (existing !== null) this.host.local.write({
4833
+ ...existing,
4834
+ status: "finished",
4835
+ at: Date.now(),
4836
+ isSynced: false
4837
+ });
4838
+ }
4839
+ /** The hub recorded the day: forget the copy. */
4840
+ completed(day) {
4841
+ this.host.local.remove(this.host.slug, day);
4842
+ }
4843
+ /** Send every waiting save now. */
4844
+ async flush() {
4845
+ const payloads = [...this.waiting.values()];
4846
+ for (const timer of this.timers.values()) clearTimeout(timer);
4847
+ this.timers.clear();
4848
+ this.waiting.clear();
4849
+ await Promise.all(payloads.map((payload) => this.host.send(payload)));
4850
+ }
4851
+ blankCopy(challenge) {
4852
+ return {
4853
+ id: challenge.instanceId ?? localDailyId(this.host.slug, challenge.day),
4854
+ game: this.host.slug,
4855
+ day: challenge.day,
4856
+ seed: challenge.seed,
4857
+ status: "active",
4858
+ userId: this.host.currentUserId(),
4859
+ at: Date.now(),
4860
+ label: STARTED_LABEL,
4861
+ fraction: null,
4862
+ title: null,
4863
+ version: 0,
4864
+ gameTitle: this.host.gameTitle(),
4865
+ state: null,
4866
+ isSynced: false
4867
+ };
4868
+ }
4869
+ dropWaiting(day) {
4870
+ const timer = this.timers.get(day);
4871
+ if (timer !== void 0) clearTimeout(timer);
4872
+ this.timers.delete(day);
4873
+ this.waiting.delete(day);
4874
+ }
4875
+ schedule(payload) {
4876
+ this.dropWaiting(payload.day);
4877
+ this.waiting.set(payload.day, payload);
4878
+ this.timers.set(payload.day, setTimeout(() => {
4879
+ this.timers.delete(payload.day);
4880
+ this.waiting.delete(payload.day);
4881
+ this.host.send(payload).catch(() => void 0);
4882
+ }, DAILY_SAVE_DEBOUNCE_MS));
4883
+ this.listenToPage();
4884
+ }
4885
+ /** Send waiting saves as soon as the page is hidden or closed. */
4886
+ listenToPage() {
4887
+ if (this.isListening || !isBrowserPage()) return;
4888
+ this.isListening = true;
4889
+ const flushNow = () => {
4890
+ this.flush().catch(() => void 0);
4891
+ };
4892
+ window.addEventListener("pagehide", flushNow);
4893
+ if (typeof document !== "undefined") document.addEventListener("visibilitychange", () => {
4894
+ if (document.visibilityState === "hidden") flushNow();
4895
+ });
4896
+ }
4897
+ };
4898
+ //#endregion
4899
+ //#region src/core/saved/saved-open.ts
4900
+ /** The instance id parameter (the contract's `SAVED_OPEN_PARAM`). */
4901
+ const SAVED_OPEN_PARAM = "hub_open";
4902
+ /** The instance kind parameter (the contract's `SAVED_KIND_PARAM`). */
4903
+ const SAVED_KIND_PARAM = "hub_kind";
4904
+ /** The window event the hub menu fires to open an instance of this page's game (cancelable). */
4905
+ const SAVED_OPEN_EVENT = "hub:saved-open";
4906
+ /** The window event the social runtime fires for each `ev:saved` (detail: the `SavedGame`). */
4907
+ const SAVED_CHANGE_EVENT = "hub:saved";
4908
+ /** Where the answer is kept for the page. */
4909
+ const PAGE_FLAG$1 = "__HUB_SAVED_OPENED__";
4910
+ const KINDS = [
4911
+ "daily",
4912
+ "run",
4913
+ "turns"
4914
+ ];
4915
+ /** `text` as a kind, or `null`. */
4916
+ function asKind(text) {
4917
+ return KINDS.find((kind) => kind === text) ?? null;
4918
+ }
4919
+ /** The address that opens `game` in its page. */
4920
+ function savedGameUrl(game) {
4921
+ const params = new URLSearchParams();
4922
+ if (game.kind === "run") params.set(RESUME_REQUEST_PARAM, "1");
4923
+ if (game.kind === "daily" && game.daily !== void 0) params.set("daily", game.daily.day);
4924
+ params.set(SAVED_OPEN_PARAM, game.id);
4925
+ params.set(SAVED_KIND_PARAM, game.kind);
4926
+ return `${game.path.replace(/\/+$/, "")}/?${params.toString()}`;
4927
+ }
4928
+ /** Read and strip `hub_open` / `hub_kind`; `null` when absent or malformed. */
4929
+ function takeParams() {
4930
+ try {
4931
+ const url = new URL(location.href);
4932
+ const id = url.searchParams.get(SAVED_OPEN_PARAM);
4933
+ const kind = asKind(url.searchParams.get(SAVED_KIND_PARAM));
4934
+ if (!url.searchParams.has("hub_open") && !url.searchParams.has("hub_kind")) return null;
4935
+ url.searchParams.delete(SAVED_OPEN_PARAM);
4936
+ url.searchParams.delete(SAVED_KIND_PARAM);
4937
+ try {
4938
+ history.replaceState(history.state, "", `${url.pathname}${url.search}${url.hash}`);
4939
+ } catch {}
4940
+ return id === null || id === "" || kind === null ? null : {
4941
+ id,
4942
+ kind
4943
+ };
4944
+ } catch {
4945
+ return null;
4946
+ }
4947
+ }
4948
+ /** A recorded answer read back. */
4949
+ function readRecorded(value) {
4950
+ const id = readField(value, "id");
4951
+ const kind = readField(value, "kind");
4952
+ const savedKind = asKind(typeof kind === "string" ? kind : null);
4953
+ return typeof id === "string" && savedKind !== null ? {
4954
+ id,
4955
+ kind: savedKind
4956
+ } : null;
4957
+ }
4958
+ /** The instance this page was opened for, or `null` (synchronous, so a game can route before it draws). */
4959
+ function savedOpenedOnPage() {
4960
+ if (!isBrowserPage()) return null;
4961
+ if (Reflect.has(window, PAGE_FLAG$1)) return readRecorded(Reflect.get(window, PAGE_FLAG$1));
4962
+ const opened = takeParams();
4963
+ Reflect.set(window, PAGE_FLAG$1, opened);
4964
+ return opened;
4965
+ }
4966
+ //#endregion
4967
+ //#region src/core/saved/hub-saved.ts
4968
+ /** How long changes must be quiet before `on('change')` listeners hear the new list, ms. */
4969
+ const SAVED_CHANGE_QUIET_MS = 300;
4970
+ /** Whether an entry is of one of `kinds` (all when absent). */
4971
+ function isOfKinds(entry, options) {
4972
+ return options.kinds === void 0 || options.kinds.includes(entry.kind);
4973
+ }
4974
+ /** `hub.saved`. */
4975
+ var HubSaved = class {
4976
+ constructor(host) {
4977
+ _defineProperty(this, "host", void 0);
4978
+ _defineProperty(this, "openHandlers", /* @__PURE__ */ new Map());
4979
+ _defineProperty(this, "changeListeners", /* @__PURE__ */ new Set());
4980
+ _defineProperty(this, "isListeningForOpen", false);
4981
+ _defineProperty(this, "isListeningForChange", false);
4982
+ _defineProperty(this, "changeTimer", null);
4983
+ this.host = host;
4984
+ }
4985
+ /**
4986
+ * The instance this page was opened for (`?hub_open=<id>&hub_kind=<kind>`,
4987
+ * taken out of the address bar), or `null`. Synchronous: read it before
4988
+ * routing or drawing a title screen.
4989
+ */
4990
+ get opened() {
4991
+ return savedOpenedOnPage();
4992
+ }
4993
+ /** This game's games in progress: online games waiting on the player first, then the newest. Works offline. */
4994
+ async list(options = {}) {
4995
+ const slug = this.host.slug;
4996
+ if (slug === null) return [];
4997
+ return mergeSavedLists(await this.host.fetchList(slug), this.host.deviceCopies(), slug).filter((entry) => isOfKinds(entry, options));
4998
+ }
4999
+ /** Every game's games in progress (the home page and the menu). Online, plus this device's copies. */
5000
+ async listAll() {
5001
+ return mergeSavedLists(await this.host.fetchList(null), this.host.deviceCopies(), null);
5002
+ }
5003
+ /** The badge counts across every game. */
5004
+ async counts() {
5005
+ return countSaved(await this.listAll());
5006
+ }
5007
+ /**
5008
+ * Switch to an instance of THIS game when the player picks it from the
5009
+ * hub menu, without a reload. Without a handler the page reloads with
5010
+ * `?hub_open=<id>`. With `{ kinds }` the handler takes only those kinds;
5011
+ * the others still reload the page (with `?hub_resume=1` or `?daily=` for
5012
+ * a saved run or a daily). Returns a function that removes the handler.
5013
+ */
5014
+ onOpen(handler, options = {}) {
5015
+ this.openHandlers.set(handler, options);
5016
+ this.listenForOpen();
5017
+ return () => {
5018
+ this.openHandlers.delete(handler);
5019
+ };
5020
+ }
5021
+ /** Open an instance: in this game through `onOpen` (or a reload), in another game by going there. */
5022
+ async open(id) {
5023
+ const entry = (await this.listAll()).find((candidate) => candidate.id === id);
5024
+ if (entry === void 0) return;
5025
+ this.openEntry(entry);
5026
+ }
5027
+ /** Open an entry already in hand (the hub's lists). */
5028
+ openEntry(entry) {
5029
+ if (entry.game === this.host.slug && isBrowserPage()) {
5030
+ const event = new CustomEvent(SAVED_OPEN_EVENT, {
5031
+ detail: { entry },
5032
+ cancelable: true
5033
+ });
5034
+ if (!window.dispatchEvent(event)) return;
5035
+ }
5036
+ this.host.navigate(savedGameUrl(entry));
5037
+ }
5038
+ /**
5039
+ * Discard a daily challenge or a saved run (the caller asks first; the
5040
+ * hub's own lists ask for themselves). Online games are left with
5041
+ * `hub.turns`, never discarded.
5042
+ */
5043
+ async discard(id) {
5044
+ const entry = (await this.listAll()).find((candidate) => candidate.id === id);
5045
+ if (entry === void 0 || entry.kind === "turns") return;
5046
+ await this.host.discard(entry);
5047
+ this.changed();
5048
+ }
5049
+ /** Hear when this game's list changes; returns a function that stops listening. */
5050
+ on(_eventName, listener) {
5051
+ this.changeListeners.add(listener);
5052
+ this.listenForChange();
5053
+ return () => {
5054
+ this.changeListeners.delete(listener);
5055
+ };
5056
+ }
5057
+ /** Something of this game's changed (a save here, an event from the hub): tell the listeners, once per quiet spell. */
5058
+ changed() {
5059
+ if (this.changeListeners.size === 0 || this.changeTimer !== null) return;
5060
+ this.changeTimer = setTimeout(() => {
5061
+ this.changeTimer = null;
5062
+ this.list().then((games) => {
5063
+ for (const listener of this.changeListeners) listener(games);
5064
+ }, () => void 0);
5065
+ }, 300);
5066
+ }
5067
+ listenForOpen() {
5068
+ if (this.isListeningForOpen || !isBrowserPage()) return;
5069
+ this.isListeningForOpen = true;
5070
+ window.addEventListener(SAVED_OPEN_EVENT, (event) => {
5071
+ const entry = event instanceof CustomEvent ? readEntry(event.detail) : null;
5072
+ const handlers = entry === null ? [] : [...this.openHandlers].filter(([, options]) => isOfKinds(entry, options)).map(([handler]) => handler);
5073
+ if (entry === null || entry.game !== this.host.slug || handlers.length === 0 || event.defaultPrevented) return;
5074
+ event.preventDefault();
5075
+ for (const handler of handlers) Promise.resolve(handler(entry)).catch(() => void 0);
5076
+ });
5077
+ }
5078
+ listenForChange() {
5079
+ if (this.isListeningForChange || !isBrowserPage()) return;
5080
+ this.isListeningForChange = true;
5081
+ window.addEventListener(SAVED_CHANGE_EVENT, (event) => {
5082
+ if ((event instanceof CustomEvent ? readField(event.detail, "game") : void 0) === this.host.slug) this.changed();
5083
+ });
5084
+ }
5085
+ };
5086
+ /** The entry in an open event's detail (it may come from another bundle), or `null`. */
5087
+ function readEntry(detail) {
5088
+ const entry = readField(detail, "entry");
5089
+ const id = readField(entry, "id");
5090
+ const game = readField(entry, "game");
5091
+ const kind = readField(entry, "kind");
5092
+ if (typeof id !== "string" || typeof game !== "string" || kind !== "daily" && kind !== "run" && kind !== "turns") return null;
5093
+ return isSavedEntry(entry) ? entry : null;
5094
+ }
5095
+ /** Shape check for an entry made by another copy of this client. */
5096
+ function isSavedEntry(candidate) {
5097
+ return typeof readField(candidate, "path") === "string" && typeof readField(candidate, "progress") === "object";
5098
+ }
5099
+ //#endregion
5100
+ //#region src/core/turns/turn-game.ts
5101
+ /** The window event the social runtime fires for each `ev:turn`. */
5102
+ const TURN_EVENT = "hub:turn";
5103
+ /** The runtime's live connection (re)opened: anything sent while it was down is fetched again. */
5104
+ const TURN_RESYNC_EVENT = "hub:turn-resync";
5105
+ /** The window event the social runtime fires for each `ev:turn.chat`. */
5106
+ const TURN_CHAT_EVENT = "hub:turn-chat";
5107
+ /** How often a page without the live runtime looks for moves, ms. */
5108
+ const TURN_POLL_MS = 2e4;
5109
+ /** One online game. `View`, `Move` and `Ev` (its events) are the game's own JSON types from its rules. */
5110
+ var TurnGame = class TurnGame {
5111
+ constructor(host, initial) {
5112
+ _defineProperty(this, "id", void 0);
5113
+ _defineProperty(this, "state", void 0);
5114
+ _defineProperty(this, "host", void 0);
5115
+ _defineProperty(this, "listeners", {
5116
+ move: /* @__PURE__ */ new Set(),
5117
+ turn: /* @__PURE__ */ new Set(),
5118
+ ended: /* @__PURE__ */ new Set(),
5119
+ chat: /* @__PURE__ */ new Set()
5120
+ });
5121
+ _defineProperty(this, "stopListening", []);
5122
+ _defineProperty(this, "isClosed", false);
5123
+ _defineProperty(this, "catchingUp", null);
5124
+ _defineProperty(this, "ownSeq", null);
5125
+ this.host = host;
5126
+ this.id = initial.id;
5127
+ this.state = initial;
5128
+ host.opened(this);
5129
+ this.listen();
5130
+ }
5131
+ /** Whose move it is, the seats and the deadline. */
5132
+ get summary() {
5133
+ return this.state.summary;
5134
+ }
5135
+ /** This seat's view, from the game's rules (`null` before the game starts). */
5136
+ get view() {
5137
+ return toGameData(this.state.view);
5138
+ }
5139
+ /** This player's seat, or `null` (they left). */
5140
+ get mySeat() {
5141
+ return this.state.mySeat;
5142
+ }
5143
+ /** The end, once the game is over. */
5144
+ get outcome() {
5145
+ return this.state.outcome;
5146
+ }
5147
+ /** The last chat lines, oldest first. */
5148
+ get chat() {
5149
+ return this.state.chat;
5150
+ }
5151
+ /** `on`: friends may type (moderated); `quick`: quick-chat and emotes only; `off`. */
5152
+ get chatMode() {
5153
+ return this.state.chatMode;
5154
+ }
5155
+ /** The link others can take a seat with (the creator's, while seats are open), else `null`. */
5156
+ get link() {
5157
+ return this.state.link === null ? null : new URL(this.state.link, location.origin).href;
5158
+ }
5159
+ /** Creator: start the game (empty seats go to the computer when the game allows). */
5160
+ async start() {
5161
+ this.update(await this.host.start(this.id));
5162
+ }
5163
+ /**
5164
+ * Make a move. Rejects with a `HubError`: 403 `not_your_turn`, 409
5165
+ * `stale` (the game moved on: the view is refreshed first), 422 `illegal`
5166
+ * with the rules' own code in `rule`.
5167
+ */
5168
+ async move(move) {
5169
+ this.ownSeq = this.state.summary.seq + 1;
5170
+ try {
5171
+ const answer = await this.host.move(this.id, this.state.summary.seq, toPayload(move));
5172
+ this.update(answer.game);
5173
+ return {
5174
+ seq: answer.game.summary.seq,
5175
+ view: this.view,
5176
+ events: answer.events.map((event) => toGameData(event))
5177
+ };
5178
+ } catch (failure) {
5179
+ if (failure instanceof HubError && failure.code === "stale") await this.refresh();
5180
+ throw failure;
5181
+ } finally {
5182
+ this.ownSeq = null;
5183
+ }
5184
+ }
5185
+ /** The moves after `sinceSeq`, with their events as this seat may see them ("what happened while you were away"). */
5186
+ async history(sinceSeq) {
5187
+ return (await this.host.history(this.id, sinceSeq)).map((move) => ({
5188
+ seq: move.seq,
5189
+ seat: move.seat,
5190
+ events: move.events.map((event) => toGameData(event))
5191
+ }));
5192
+ }
5193
+ /** Leave: the computer plays the seat (or, without bots, the others win). The player can't come back. */
5194
+ async leave() {
5195
+ await this.host.leave(this.id);
5196
+ this.close();
5197
+ }
5198
+ /** Remind whoever must move (once a day). */
5199
+ nudge() {
5200
+ return this.host.nudge(this.id);
5201
+ }
5202
+ /** This game's "your turn" alerts. */
5203
+ setNotify(mode) {
5204
+ return this.host.setNotify(this.id, mode);
5205
+ }
5206
+ /** A finished game: a new one with the same friends (they get an invite). */
5207
+ async rematch() {
5208
+ return new TurnGame(this.host, await this.host.rematch(this.id));
5209
+ }
5210
+ /** Creator, before the start: add an invite link (`link: true`, then read `link`) or list the open seats for anyone (`public`). */
5211
+ async share(share) {
5212
+ this.update(await this.host.share(this.id, share));
5213
+ }
5214
+ /** Invite friends to free seats (before the start). */
5215
+ async invite(userIds) {
5216
+ this.update(await this.host.invite(this.id, userIds));
5217
+ }
5218
+ /** The turn's deadline passed (or its player is suspended): let the computer play it. */
5219
+ async letComputerPlay() {
5220
+ this.update(await this.host.autoPlay(this.id));
5221
+ }
5222
+ /** Say something: `{ text }` between friends (moderated), `{ quick }` (a quick-chat phrase id) with anyone. */
5223
+ say(send) {
5224
+ return this.host.chat(this.id, send);
5225
+ }
5226
+ /** Fetch the game again, and replay moves missed since the last one this page saw. */
5227
+ async refresh() {
5228
+ const before = this.state.summary.seq;
5229
+ const fresh = await this.host.get(this.id);
5230
+ if (fresh.summary.seq > before) {
5231
+ const missed = await this.host.history(this.id, before);
5232
+ this.update(fresh);
5233
+ for (const move of missed) this.emit("move", {
5234
+ seq: move.seq,
5235
+ seat: move.seat,
5236
+ view: this.view,
5237
+ events: move.events.map((event) => toGameData(event))
5238
+ });
5239
+ } else this.update(fresh);
5240
+ }
5241
+ /** Hear moves, turn changes, the end and chat; returns a function that stops listening. */
5242
+ on(eventName, listener) {
5243
+ const set = this.listeners[eventName];
5244
+ set.add(listener);
5245
+ return () => {
5246
+ set.delete(listener);
5247
+ };
5248
+ }
5249
+ /** Stop live updates for this page (the game itself goes on). */
5250
+ close() {
5251
+ if (this.isClosed) return;
5252
+ this.isClosed = true;
5253
+ for (const stop of this.stopListening) stop();
5254
+ this.host.closed(this);
5255
+ }
5256
+ /** Take a fresh answer from the hub; fire `turn` and `ended` when they changed. */
5257
+ update(next) {
5258
+ const previous = this.state;
5259
+ this.state = next;
5260
+ if (previous.summary.seq !== next.summary.seq || previous.summary.phase !== next.summary.phase || !sameSeats(previous.summary, next.summary) || previous.chatMode !== next.chatMode || previous.link !== next.link) this.emit("turn", next.summary);
5261
+ if (previous.summary.phase !== "ended" && next.summary.phase === "ended") this.emit("ended", {
5262
+ outcome: next.outcome,
5263
+ view: this.view
5264
+ });
5265
+ }
5266
+ emit(eventName, payload) {
5267
+ const set = this.listeners[eventName];
5268
+ for (const listener of set) listener(payload);
5269
+ }
5270
+ /** A live `hub:turn` for this game: a move (or a missed run of them), or another change. */
5271
+ onLive(detail) {
5272
+ const seq = readField(detail, "seq");
5273
+ const view = readField(detail, "view");
5274
+ const summary = readField(detail, "summary");
5275
+ if (typeof seq !== "number" || !isSummary(summary) || view === void 0) return;
5276
+ if (seq > this.state.summary.seq + 1) {
5277
+ this.catchUp();
5278
+ return;
5279
+ }
5280
+ const seat = readField(detail, "seat");
5281
+ const events = readField(detail, "events");
5282
+ const outcome = readField(detail, "outcome");
5283
+ const isMove = typeof seat === "number" && seq === this.state.summary.seq + 1 && seq !== this.ownSeq;
5284
+ if (summary.phase !== this.state.summary.phase || !sameSeating(this.state.summary, summary)) this.catchUp();
5285
+ this.update({
5286
+ ...this.state,
5287
+ summary,
5288
+ view: toPayloadJson(view),
5289
+ outcome: isOutcome(outcome) ? outcome : this.state.outcome
5290
+ });
5291
+ if (isMove) this.emit("move", {
5292
+ seq,
5293
+ seat,
5294
+ view: this.view,
5295
+ events: Array.isArray(events) ? events.map((event) => toGameData(toPayloadJson(event))) : []
5296
+ });
5297
+ }
5298
+ /** Replay what was missed (one catch-up at a time). */
5299
+ catchUp() {
5300
+ this.catchingUp ?? (this.catchingUp = this.refresh().catch(() => void 0).finally(() => {
5301
+ this.catchingUp = null;
5302
+ }));
5303
+ return this.catchingUp;
5304
+ }
5305
+ listen() {
5306
+ if (!isBrowserPage()) return;
5307
+ const onTurn = (event) => {
5308
+ if (!(event instanceof CustomEvent) || readField(event.detail, "id") !== this.id) return;
5309
+ const detail = event.detail;
5310
+ if (typeof detail === "object" && detail !== null) this.onLive(detail);
5311
+ };
5312
+ const onChat = (event) => {
5313
+ if (event instanceof CustomEvent && readField(event.detail, "id") === this.id) {
5314
+ const line = readField(event.detail, "line");
5315
+ if (isChatLine(line)) {
5316
+ this.state = {
5317
+ ...this.state,
5318
+ chat: [...this.state.chat, line].slice(-50)
5319
+ };
5320
+ this.emit("chat", line);
5321
+ }
5322
+ }
5323
+ };
5324
+ const onVisible = () => {
5325
+ if (document.visibilityState === "visible") this.catchUp();
5326
+ };
5327
+ const onResync = () => {
5328
+ this.catchUp();
5329
+ };
5330
+ window.addEventListener(TURN_EVENT, onTurn);
5331
+ window.addEventListener(TURN_CHAT_EVENT, onChat);
5332
+ window.addEventListener(TURN_RESYNC_EVENT, onResync);
5333
+ document.addEventListener("visibilitychange", onVisible);
5334
+ const poll = setInterval(() => {
5335
+ if (!this.host.isLive() && document.visibilityState === "visible") this.catchUp();
5336
+ }, TURN_POLL_MS);
5337
+ this.stopListening.push(() => {
5338
+ window.removeEventListener(TURN_EVENT, onTurn);
5339
+ }, () => {
5340
+ window.removeEventListener(TURN_CHAT_EVENT, onChat);
5341
+ }, () => {
5342
+ window.removeEventListener(TURN_RESYNC_EVENT, onResync);
5343
+ }, () => {
5344
+ document.removeEventListener("visibilitychange", onVisible);
5345
+ }, () => {
5346
+ clearInterval(poll);
5347
+ });
5348
+ }
5349
+ };
5350
+ /** Every seat matches its counterpart by `same`. */
5351
+ function eachSeat(a, b, same) {
5352
+ return a.seats.length === b.seats.length && a.seats.every((seat, index) => {
5353
+ const other = b.seats[index];
5354
+ return other !== void 0 && same(seat, other);
5355
+ });
5356
+ }
5357
+ /** The same players in the same seats with the same statuses (who sits where). */
5358
+ function sameSeating(a, b) {
5359
+ return eachSeat(a, b, (seat, other) => seat.status === other.status && seat.userId === other.userId);
5360
+ }
5361
+ /** The same seating, and the same looks (a player's new avatar is a change: `turn` fires). */
5362
+ function sameSeats(a, b) {
5363
+ return sameSeating(a, b) && eachSeat(a, b, (seat, other) => seat.look === other.look);
5364
+ }
5365
+ /** A game's move as the JSON the hub takes. */
5366
+ function toPayload(move) {
5367
+ return toPayloadJson(move);
5368
+ }
5369
+ /** Any JSON-safe value as `Json` (a JSON round trip). */
5370
+ function toPayloadJson(value) {
5371
+ const parsed = parseJsonText(JSON.stringify(value ?? null));
5372
+ return isJson(parsed) ? parsed : null;
5373
+ }
5374
+ /** Shape checks for a live event's parts (it comes from another bundle). */
5375
+ function isSummary(value) {
5376
+ return typeof readField(value, "phase") === "string" && Array.isArray(readField(value, "seats")) && Array.isArray(readField(value, "toAct"));
5377
+ }
5378
+ function isOutcome(value) {
5379
+ return value === null || Array.isArray(readField(value, "winners"));
5380
+ }
5381
+ function isChatLine(value) {
5382
+ return typeof readField(value, "text") === "string" && typeof readField(value, "from") === "number";
5383
+ }
5384
+ //#endregion
5385
+ //#region src/core/turns/hub-turns.ts
5386
+ /** The query parameter an invite link carries (`/<slug>/?hub_turn_link=<key>`); the client strips it. */
5387
+ const TURN_LINK_PARAM = "hub_turn_link";
5388
+ /** Where the answer is kept for the page. */
5389
+ const PAGE_FLAG = "__HUB_TURN_LINK__";
5390
+ /** Read and strip `hub_turn_link`, once per page. */
5391
+ function linkOnPage() {
5392
+ if (!isBrowserPage()) return null;
5393
+ if (Reflect.has(window, PAGE_FLAG)) {
5394
+ const recorded = Reflect.get(window, PAGE_FLAG);
5395
+ return typeof recorded === "string" ? recorded : null;
5396
+ }
5397
+ let key = null;
5398
+ try {
5399
+ const url = new URL(location.href);
5400
+ key = url.searchParams.get(TURN_LINK_PARAM);
5401
+ if (key !== null) {
5402
+ url.searchParams.delete(TURN_LINK_PARAM);
5403
+ history.replaceState(history.state, "", `${url.pathname}${url.search}${url.hash}`);
5404
+ }
5405
+ } catch {}
5406
+ Reflect.set(window, PAGE_FLAG, key);
5407
+ return key;
5408
+ }
5409
+ /** Whether `idOrKey` is a game id (`sg_…`) rather than a link key. */
5410
+ function isGameId(idOrKey) {
5411
+ return idOrKey.startsWith("sg_");
5412
+ }
5413
+ /** `hub.turns`. */
5414
+ var HubTurns = class {
5415
+ constructor(host) {
5416
+ _defineProperty(this, "host", void 0);
5417
+ this.host = host;
5418
+ }
5419
+ /** The invite link key this page was opened with (`?hub_turn_link=`), taken out of the address bar; `null` when none. Synchronous. */
5420
+ get link() {
5421
+ return linkOnPage();
5422
+ }
5423
+ /** Make a game in this page's game (its lobby): the player takes the first seat. */
5424
+ async create(options = {}) {
5425
+ const body = {
5426
+ game: this.host.slug ?? "",
5427
+ ...options.mode === void 0 ? {} : { mode: options.mode },
5428
+ ...options.options === void 0 ? {} : { options: toStoredJson(options.options) },
5429
+ ...options.invite === void 0 ? {} : { invite: [...options.invite] },
5430
+ ...options.link === void 0 ? {} : { link: options.link },
5431
+ ...options.public === void 0 ? {} : { public: options.public },
5432
+ ...options.seats === void 0 ? {} : { seats: options.seats },
5433
+ ...options.bots === void 0 ? {} : { bots: options.bots },
5434
+ ...options.deadline === void 0 ? {} : { deadline: options.deadline },
5435
+ ...options.autoStart === void 0 ? {} : { autoStart: options.autoStart }
5436
+ };
5437
+ return new TurnGame(this.host, await this.host.create(body));
5438
+ }
5439
+ /** Take a seat: by the game's id (an invite, an open seat) or by an invite link's key. */
5440
+ async join(idOrLinkKey) {
5441
+ const joined = isGameId(idOrLinkKey) ? await this.host.join(idOrLinkKey) : await this.host.joinByLink(idOrLinkKey);
5442
+ return new TurnGame(this.host, joined);
5443
+ }
5444
+ /** Open a game the player sits in (from `hub.saved.opened`, the Continue list, an invite). */
5445
+ async open(id) {
5446
+ return new TurnGame(this.host, await this.host.get(id));
5447
+ }
5448
+ /** This game's online games the player sits in (the same rows as `hub.saved.list({ kinds: ['turns'] })`). */
5449
+ list() {
5450
+ return this.host.list();
5451
+ }
5452
+ /** Open-seat games of this game anyone may join (none across a block). */
5453
+ openLobbies() {
5454
+ return this.host.listOpen(this.host.slug);
5455
+ }
5456
+ };
5457
+ //#endregion
5458
+ //#region src/core/ticket.ts
5459
+ /**
5460
+ * The own-server ticket request (`hub.mp.ticket()`): a 60-second identity
5461
+ * ticket for this game's own server, fetched straight from the hub. It must
5462
+ * not depend on the social runtime: on the first visit after a deploy the
5463
+ * service worker can still serve an older menu that never loads it. When the
5464
+ * page has no session yet, a guest one is created and the request retried
5465
+ * once.
5466
+ */
5467
+ /** A REST client for the page's API base that uses the global fetch at call time. */
5468
+ function pageRestClient() {
5469
+ return createRestClient({
5470
+ apiBase: detectApiBase(),
5471
+ fetch: (input, init) => globalThis.fetch(input, init)
5472
+ });
5473
+ }
5474
+ /** One ticket request; a transport failure is an {@link OfflineError}. */
5475
+ async function requestTicket(rest, slug) {
5476
+ try {
5477
+ return (await rest.call("getGameServerTicket", { slug })).ticket;
5478
+ } catch (failure) {
5479
+ throw failure instanceof HubError && failure.isOffline ? new OfflineError() : failure;
5480
+ }
5481
+ }
5482
+ /** A ticket for this page's game; signs in a guest first when the page has no session. */
5483
+ async function fetchGameServerTicket() {
5484
+ const slug = detectSlug();
5485
+ if (slug === null) throw new HubError("Hub: could not determine the game slug.");
5486
+ const rest = pageRestClient();
5487
+ try {
5488
+ return await requestTicket(rest, slug);
5489
+ } catch (failure) {
5490
+ if (!(failure instanceof HubError) || failure.status !== 401) throw failure;
3640
5491
  await rest.call("ensureGuestSession");
3641
5492
  return requestTicket(rest, slug);
3642
5493
  }
@@ -3746,6 +5597,11 @@ var Hub = class extends HubEconomy {
3746
5597
  super(..._args);
3747
5598
  _defineProperty(this, "clientVersion", HUB_CLIENT_VERSION);
3748
5599
  _defineProperty(this, "resumeApi", void 0);
5600
+ _defineProperty(this, "avatarApi", void 0);
5601
+ _defineProperty(this, "savedApi", void 0);
5602
+ _defineProperty(this, "dailySavesApi", void 0);
5603
+ _defineProperty(this, "turnsApi", void 0);
5604
+ _defineProperty(this, "openTurnGames", /* @__PURE__ */ new Set());
3749
5605
  }
3750
5606
  /**
3751
5607
  * The hub's version and the client versions it supports (`GET /_api/version`),
@@ -3820,6 +5676,128 @@ var Hub = class extends HubEconomy {
3820
5676
  this.resumeApi ?? (this.resumeApi = new HubResume(this.resumeHost()));
3821
5677
  return this.resumeApi;
3822
5678
  }
5679
+ /**
5680
+ * Players' avatars: `hub.avatar.mine()`, `of(userId)`, `from(seat)`,
5681
+ * `bot(seed)`, `on('change')`, `openDesigner()` and `load()` for the renderer
5682
+ * (docs: "Avatars"). No art or renderer until `load()`.
5683
+ */
5684
+ get avatar() {
5685
+ this.avatarApi ?? (this.avatarApi = new HubAvatar({
5686
+ currentUserId: () => this.userId(),
5687
+ onAccount: (listener) => this.account.on(listener),
5688
+ fetchMine: () => this.rest.call("getMyAvatar"),
5689
+ fetchCodes: async (ids) => (await this.rest.call("getAvatars", { ids: [...ids] })).codes,
5690
+ queueSave: async (code, savedAt) => {
5691
+ await this.mutateOffline("hub.avatar", {
5692
+ code,
5693
+ savedAt
5694
+ }, () => void 0);
5695
+ }
5696
+ }));
5697
+ return this.avatarApi;
5698
+ }
5699
+ /**
5700
+ * Every game the player has in progress, of every kind: `hub.saved.list()`
5701
+ * for this game's "Continue" list, `opened` and `onOpen` to open the one
5702
+ * the player picked (docs: "Saved games").
5703
+ */
5704
+ get saved() {
5705
+ this.savedApi ?? (this.savedApi = new HubSaved(this.savedHost()));
5706
+ return this.savedApi;
5707
+ }
5708
+ /**
5709
+ * Online games played over minutes or days, run by the hub from the
5710
+ * game's rules module (`game.turns`): `hub.turns.create`, `join`, `open`
5711
+ * (docs/multiplayer.md "Turn games").
5712
+ */
5713
+ get turns() {
5714
+ this.turnsApi ?? (this.turnsApi = new HubTurns(this.turnsHost()));
5715
+ return this.turnsApi;
5716
+ }
5717
+ /** What `hub.turns` needs from this client: its REST calls. */
5718
+ turnsHost() {
5719
+ const { rest } = this;
5720
+ return {
5721
+ slug: this.slug,
5722
+ create: (body) => rest.call("createTurnGame", body),
5723
+ join: (id) => rest.call("joinTurnGame", { id }),
5724
+ joinByLink: (key) => rest.call("joinTurnGameByLink", { key }),
5725
+ get: (id) => rest.call("getTurnGame", { id }),
5726
+ start: (id) => rest.call("startTurnGame", { id }),
5727
+ move: (id, seq, move) => rest.call("moveTurnGame", {
5728
+ id,
5729
+ seq,
5730
+ move
5731
+ }),
5732
+ history: async (id, since) => (await rest.call("getTurnGameHistory", {
5733
+ id,
5734
+ since
5735
+ })).moves,
5736
+ leave: async (id) => {
5737
+ await rest.call("leaveTurnGame", { id });
5738
+ },
5739
+ nudge: async (id) => {
5740
+ await rest.call("nudgeTurnGame", { id });
5741
+ },
5742
+ setNotify: async (id, mode) => {
5743
+ await rest.call("setTurnGameNotify", {
5744
+ id,
5745
+ mode
5746
+ });
5747
+ },
5748
+ rematch: (id) => rest.call("rematchTurnGame", { id }),
5749
+ invite: (id, userIds) => rest.call("inviteToTurnGame", {
5750
+ id,
5751
+ userIds: [...userIds]
5752
+ }),
5753
+ share: (id, share) => rest.call("shareTurnGame", {
5754
+ id,
5755
+ ...share
5756
+ }),
5757
+ autoPlay: (id) => rest.call("autoPlayTurn", { id }),
5758
+ chat: (id, send) => rest.call("chatInTurnGame", {
5759
+ id,
5760
+ ...send
5761
+ }),
5762
+ listOpen: async (game) => (await rest.call("listOpenTurnGames", game === null ? {} : { game })).games,
5763
+ list: () => this.saved.list({ kinds: ["turns"] }),
5764
+ opened: (game) => {
5765
+ this.openTurnGames.add(game);
5766
+ },
5767
+ closed: (game) => {
5768
+ this.openTurnGames.delete(game);
5769
+ },
5770
+ isLive: () => pageBridge.current() !== null
5771
+ };
5772
+ }
5773
+ /** What `hub.saved` needs from this client. */
5774
+ savedHost() {
5775
+ return {
5776
+ slug: this.slug,
5777
+ fetchList: (game) => this.withFallback(async () => (await this.rest.call("listMySavedGames", game === null ? {} : { game })).games, () => void 0),
5778
+ deviceCopies: () => {
5779
+ const userId = this.store.getUser()?.id ?? null;
5780
+ return {
5781
+ runs: new Map([...this.resumeStore.all()].filter(([, copy]) => copy.userId === userId)),
5782
+ dailies: this.dailyStore.all(userId)
5783
+ };
5784
+ },
5785
+ discard: (entry) => this.discardSaved(entry),
5786
+ navigate: (url) => {
5787
+ location.assign(url);
5788
+ }
5789
+ };
5790
+ }
5791
+ /** Discard a daily or run: this game's run through `hub.resume` (works offline), others on the hub. */
5792
+ async discardSaved(entry) {
5793
+ if (entry.kind === "run" && entry.game === this.slug) {
5794
+ await this.resume.clear();
5795
+ return;
5796
+ }
5797
+ if (!entry.id.startsWith("local:")) await this.rest.call("discardSavedGame", { id: entry.id });
5798
+ if (entry.kind === "run") this.resumeStore.remove(entry.game);
5799
+ else if (entry.daily !== void 0) this.dailyStore.remove(entry.game, entry.daily.day);
5800
+ }
3823
5801
  /** What `hub.resume` needs from this client. */
3824
5802
  resumeHost() {
3825
5803
  return {
@@ -3829,7 +5807,12 @@ var Hub = class extends HubEconomy {
3829
5807
  gameTitle: () => detectGameTitle(this.slug ?? ""),
3830
5808
  blocker: () => {
3831
5809
  if (raceForwarder.current() !== null) return "race";
3832
- return this.daily.active === null ? isInHubRoom() ? "room" : null : "daily";
5810
+ if (this.openTurnGames.size > 0) return "turns";
5811
+ return isInHubRoom() ? "room" : null;
5812
+ },
5813
+ daily: () => {
5814
+ const daily = this.daily;
5815
+ return daily.active === null ? null : daily;
3833
5816
  },
3834
5817
  send: async (payload) => {
3835
5818
  await this.mutateOffline(RESUME_KIND, payload, () => void 0);
@@ -3845,24 +5828,58 @@ var Hub = class extends HubEconomy {
3845
5828
  fetchList: () => this.withFallback(async () => (await this.rest.call("listMyResumeSlots")).games, () => void 0),
3846
5829
  clearOther: async (slug) => {
3847
5830
  await this.rest.call("clearResumeSlot", { slug });
5831
+ },
5832
+ onChange: () => {
5833
+ this.savedApi?.changed();
3848
5834
  }
3849
5835
  };
3850
5836
  }
3851
- /** What the daily system needs from this client: its REST calls and window events. */
5837
+ /** This game's daily saves (device copies and their sends). */
5838
+ dailySaves() {
5839
+ const slug = this.slug;
5840
+ if (slug === null) throw new HubError("hub.daily: could not determine the game slug.");
5841
+ this.dailySavesApi ?? (this.dailySavesApi = new DailySaves({
5842
+ slug,
5843
+ local: this.dailyStore,
5844
+ currentUserId: () => this.store.getUser()?.id ?? null,
5845
+ gameTitle: () => detectGameTitle(slug),
5846
+ send: async (payload) => {
5847
+ await this.mutateOffline("hub.saved", payload, () => void 0);
5848
+ this.savedApi?.changed();
5849
+ }
5850
+ }));
5851
+ return this.dailySavesApi;
5852
+ }
5853
+ /** What the daily system needs from this client: its REST calls, the device copies and window events. */
3852
5854
  dailyHost() {
3853
5855
  return {
3854
5856
  slug: this.slug,
3855
5857
  status: () => this.rest.call("getGameDailyChallenges", this.gameFields()),
3856
5858
  all: () => this.rest.call("listDailyChallengesForAllGames"),
3857
- start: (day) => this.rest.call("startDailyChallenge", {
5859
+ start: (day) => this.withFallback(async () => this.dailySaves().remember(await this.rest.call("startDailyChallenge", {
3858
5860
  ...this.gameFields(),
3859
5861
  day
3860
- }),
3861
- complete: (day, token, meta) => this.mutateOffline(DAILY_KIND, {
3862
- day,
3863
- token,
3864
- meta
3865
- }, () => null, (result) => readDailyCompletion(result)),
5862
+ })), () => this.dailySaves().startLocally(day)),
5863
+ resume: async (instanceId) => this.dailySaves().remember(await this.rest.call("resumeDailyChallenge", { id: instanceId })),
5864
+ complete: async (day, token, meta) => {
5865
+ this.dailySaves().finished(day);
5866
+ const completed = await this.mutateOffline(DAILY_KIND, {
5867
+ day,
5868
+ token,
5869
+ meta
5870
+ }, () => null, (result) => readDailyCompletion(result));
5871
+ if (completed !== null) this.dailySaves().completed(day);
5872
+ this.savedApi?.changed();
5873
+ return completed;
5874
+ },
5875
+ save: (challenge, input) => {
5876
+ const isSaved = this.dailySaves().save(challenge, input);
5877
+ this.savedApi?.changed();
5878
+ return isSaved;
5879
+ },
5880
+ flush: () => this.dailySaves().flush(),
5881
+ discard: (challenge) => this.dailySaves().discard(challenge),
5882
+ inProgress: () => this.saved.list({ kinds: ["daily"] }),
3866
5883
  emit: (eventName, detail) => {
3867
5884
  emitWindowEvent(eventName, detail);
3868
5885
  }
@@ -3877,11 +5894,12 @@ linkRealtimeToHub({
3877
5894
  /** A ready-to-use client, with the slug and API base detected from the page. */
3878
5895
  const hub = new Hub();
3879
5896
  isResumeRequested();
5897
+ savedOpenedOnPage();
3880
5898
  /** Create a client for another slug or API base, or with other offline behaviour. */
3881
5899
  function createHub(options = {}) {
3882
5900
  return new Hub(options);
3883
5901
  }
3884
5902
  //#endregion
3885
- export { RESUME_DEBOUNCE_MS as a, OUTBOX_FULL_CODE as c, OfflineApi as d, ContentRejectedError as f, UNSUPPORTED_BY_HUB as h, HubResume as i, OUTBOX_MAX_CHARS as l, OfflineError as m, createHub as n, RESUME_MAX_STATE_BYTES as o, HubError as p, hub as r, CLIENT_VERSION_HEADER as s, Hub as t, OUTBOX_MAX_ENTRIES as u };
5903
+ export { UNSUPPORTED_BY_HUB as A, OUTBOX_MAX_CHARS as C, HubError as D, ContentRejectedError as E, OfflineError as O, OUTBOX_FULL_CODE as S, OfflineApi as T, savedGameUrl as _, TURN_LINK_PARAM as a, RESUME_MAX_STATE_BYTES as b, TURN_POLL_MS as c, HubSaved as d, SAVED_CHANGE_QUIET_MS as f, SAVED_OPEN_PARAM as g, SAVED_OPEN_EVENT as h, HubTurns as i, HubAvatar as j, SAVE_UNKNOWN_CODE as k, TURN_RESYNC_EVENT as l, SAVED_KIND_PARAM as m, createHub as n, TURN_CHAT_EVENT as o, SAVED_CHANGE_EVENT as p, hub as r, TURN_EVENT as s, Hub as t, TurnGame as u, HubResume as v, OUTBOX_MAX_ENTRIES as w, CLIENT_VERSION_HEADER as x, RESUME_DEBOUNCE_MS as y };
3886
5904
 
3887
5905
  //# sourceMappingURL=src.js.map