@diffenderfer-games/hub 0.0.0-stage → 2.0.0-next.1

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 (164) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +191 -3
  4. package/dist/auto.d.ts +36 -0
  5. package/dist/auto.d.ts.map +1 -0
  6. package/dist/auto.js +27 -0
  7. package/dist/auto.js.map +1 -0
  8. package/dist/daily.d.ts +2 -0
  9. package/dist/daily.js +1 -0
  10. package/dist/doctor/cli.mjs +268 -0
  11. package/dist/doctor/cli.mjs.map +1 -0
  12. package/dist/index.d.ts +12 -0
  13. package/dist/index.js +6 -0
  14. package/dist/input.d.ts +2 -0
  15. package/dist/input.js +3 -0
  16. package/dist/mp.d.ts +4 -0
  17. package/dist/mp.js +1 -0
  18. package/dist/race.d.ts +4 -0
  19. package/dist/race.js +1 -0
  20. package/dist/rooms.d.ts +4 -0
  21. package/dist/rooms.js +1 -0
  22. package/dist/shared/bridge.js +209 -0
  23. package/dist/shared/bridge.js.map +1 -0
  24. package/dist/shared/client-version.gen.js +7 -0
  25. package/dist/shared/client-version.gen.js.map +1 -0
  26. package/dist/shared/daily.d.ts +123 -0
  27. package/dist/shared/daily.d.ts.map +1 -0
  28. package/dist/shared/daily.js +191 -0
  29. package/dist/shared/daily.js.map +1 -0
  30. package/dist/shared/dom.js +48 -0
  31. package/dist/shared/dom.js.map +1 -0
  32. package/dist/shared/globals.js +102 -0
  33. package/dist/shared/globals.js.map +1 -0
  34. package/dist/shared/index.d.ts +706 -0
  35. package/dist/shared/index.d.ts.map +1 -0
  36. package/dist/shared/input.d.ts +730 -0
  37. package/dist/shared/input.d.ts.map +1 -0
  38. package/dist/shared/input.js +2718 -0
  39. package/dist/shared/input.js.map +1 -0
  40. package/dist/shared/latest-state.js +17 -0
  41. package/dist/shared/latest-state.js.map +1 -0
  42. package/dist/shared/mp.d.ts +9 -0
  43. package/dist/shared/mp.d.ts.map +1 -0
  44. package/dist/shared/mp.js +30 -0
  45. package/dist/shared/mp.js.map +1 -0
  46. package/dist/shared/multiplayer-types.d.ts +47 -0
  47. package/dist/shared/multiplayer-types.d.ts.map +1 -0
  48. package/dist/shared/page-stack.js +207 -0
  49. package/dist/shared/page-stack.js.map +1 -0
  50. package/dist/shared/race-types.d.ts +126 -0
  51. package/dist/shared/race-types.d.ts.map +1 -0
  52. package/dist/shared/race.d.ts +9 -0
  53. package/dist/shared/race.d.ts.map +1 -0
  54. package/dist/shared/race.js +53 -0
  55. package/dist/shared/race.js.map +1 -0
  56. package/dist/shared/room-types.d.ts +125 -0
  57. package/dist/shared/room-types.d.ts.map +1 -0
  58. package/dist/shared/rooms.d.ts +9 -0
  59. package/dist/shared/rooms.d.ts.map +1 -0
  60. package/dist/shared/rooms.js +62 -0
  61. package/dist/shared/rooms.js.map +1 -0
  62. package/dist/shared/runtime-opener.js +84 -0
  63. package/dist/shared/runtime-opener.js.map +1 -0
  64. package/dist/shared/social.d.ts +148 -0
  65. package/dist/shared/social.d.ts.map +1 -0
  66. package/dist/shared/social.js +73 -0
  67. package/dist/shared/social.js.map +1 -0
  68. package/dist/shared/src.js +2164 -0
  69. package/dist/shared/src.js.map +1 -0
  70. package/dist/shared/wire-types.d.ts +51 -0
  71. package/dist/shared/wire-types.d.ts.map +1 -0
  72. package/dist/social.d.ts +6 -0
  73. package/dist/social.js +3 -0
  74. package/dist/types.d.ts +1 -0
  75. package/dist/types.js +0 -0
  76. package/package.json +89 -6
  77. package/src/auto.ts +39 -0
  78. package/src/core/connection.ts +299 -0
  79. package/src/core/errors.ts +147 -0
  80. package/src/core/game-data.ts +38 -0
  81. package/src/core/hub-economy.ts +179 -0
  82. package/src/core/hub-progress.ts +205 -0
  83. package/src/core/hub.ts +131 -0
  84. package/src/core/menu-control.ts +61 -0
  85. package/src/core/offline/events.ts +50 -0
  86. package/src/core/offline/favorites.ts +21 -0
  87. package/src/core/offline/local-store.ts +261 -0
  88. package/src/core/offline/queue.ts +171 -0
  89. package/src/core/page.ts +74 -0
  90. package/src/core/rest/endpoint-types.ts +22 -0
  91. package/src/core/rest/rest-client.ts +152 -0
  92. package/src/core/rest/routes.gen.ts +90 -0
  93. package/src/core/storage/json-guards.ts +74 -0
  94. package/src/core/storage/key-value.ts +66 -0
  95. package/src/core/ticket.ts +43 -0
  96. package/src/core/types.ts +134 -0
  97. package/src/core/version/client-version.gen.ts +3 -0
  98. package/src/core/version/hub-version.ts +62 -0
  99. package/src/core/version/semver.ts +97 -0
  100. package/src/daily/daily-system.ts +161 -0
  101. package/src/daily/page-daily.ts +36 -0
  102. package/src/daily/types.ts +67 -0
  103. package/src/daily.ts +10 -0
  104. package/src/doctor/checks.ts +78 -0
  105. package/src/doctor/cli.ts +98 -0
  106. package/src/doctor/hub-version-fetch.ts +43 -0
  107. package/src/doctor/tsconfig.json +12 -0
  108. package/src/globals.ts +115 -0
  109. package/src/index.ts +50 -0
  110. package/src/input/device-state.ts +146 -0
  111. package/src/input/edges.ts +55 -0
  112. package/src/input/gamepad.ts +61 -0
  113. package/src/input/input-events.ts +32 -0
  114. package/src/input/input-registry.ts +164 -0
  115. package/src/input/input-system.ts +393 -0
  116. package/src/input/legacy-controls.ts +64 -0
  117. package/src/input/nav-state.ts +191 -0
  118. package/src/input/navigator.ts +171 -0
  119. package/src/input/on-screen-controls.ts +101 -0
  120. package/src/input/page-control.ts +43 -0
  121. package/src/input/page-input.ts +193 -0
  122. package/src/input/player-remaps.ts +77 -0
  123. package/src/input/source-values.ts +116 -0
  124. package/src/input/spatial.ts +47 -0
  125. package/src/input/types.ts +331 -0
  126. package/src/input/zero-config.ts +177 -0
  127. package/src/input.ts +22 -0
  128. package/src/mp.ts +10 -0
  129. package/src/overlay/controls-panel.ts +163 -0
  130. package/src/overlay/dom.ts +46 -0
  131. package/src/overlay/layout-clusters.ts +162 -0
  132. package/src/overlay/layout-geometry.ts +136 -0
  133. package/src/overlay/layout.ts +203 -0
  134. package/src/overlay/listener-scope.ts +23 -0
  135. package/src/overlay/on-screen-keyboard.ts +137 -0
  136. package/src/overlay/overlay.ts +208 -0
  137. package/src/overlay/styles.ts +54 -0
  138. package/src/overlay/virtual-controls.ts +262 -0
  139. package/src/race.ts +10 -0
  140. package/src/realtime/bridge.ts +121 -0
  141. package/src/realtime/cross-bundle.ts +29 -0
  142. package/src/realtime/fallback-notice.ts +48 -0
  143. package/src/realtime/hub-link.ts +65 -0
  144. package/src/realtime/latest-state.ts +33 -0
  145. package/src/realtime/multiplayer-forwarder.ts +28 -0
  146. package/src/realtime/multiplayer-types.ts +52 -0
  147. package/src/realtime/page-runtime.ts +60 -0
  148. package/src/realtime/race-forwarder.ts +59 -0
  149. package/src/realtime/race-types.ts +132 -0
  150. package/src/realtime/room-types.ts +125 -0
  151. package/src/realtime/rooms-forwarder.ts +79 -0
  152. package/src/realtime/runtime-api.ts +49 -0
  153. package/src/realtime/runtime-opener.ts +61 -0
  154. package/src/realtime/social-forwarder.ts +72 -0
  155. package/src/realtime/social-types.ts +78 -0
  156. package/src/realtime/types.ts +53 -0
  157. package/src/realtime/wire-types.ts +72 -0
  158. package/src/rooms.ts +10 -0
  159. package/src/social.ts +18 -0
  160. package/src/types.ts +13 -0
  161. package/src/uistack/page-stack.ts +45 -0
  162. package/src/uistack/play-suspension.ts +82 -0
  163. package/src/uistack/types.ts +76 -0
  164. package/src/uistack/ui-stack.ts +144 -0
@@ -0,0 +1,2164 @@
1
+ import { r as _defineProperty } from "./globals.js";
2
+ import { n as getDaily } from "./daily.js";
3
+ import { t as HUB_CLIENT_VERSION } from "./client-version.gen.js";
4
+ import { n as getInput } from "./input.js";
5
+ import { t as getUiStack } from "./page-stack.js";
6
+ import { d as linkRealtimeToHub } from "./bridge.js";
7
+ import { t as multiplayerForwarder } from "./mp.js";
8
+ import { t as raceForwarder } from "./race.js";
9
+ import { t as roomsForwarder } from "./rooms.js";
10
+ 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) {
45
+ try {
46
+ return JSON.parse(text);
47
+ } catch {
48
+ return;
49
+ }
50
+ }
51
+ //#endregion
52
+ //#region src/core/errors.ts
53
+ /**
54
+ * The code a client gives a call the hub does not have yet (a newer client on
55
+ * an older hub, plan §4.3.3). Deploy the hub first, or use an older client.
56
+ */
57
+ const UNSUPPORTED_BY_HUB = "unsupported_by_hub";
58
+ /**
59
+ * Thrown by every hub call that fails. `status` is the HTTP status when the
60
+ * hub answered (401 = not signed in, 404 = unknown game or board, 413 = too
61
+ * large), or `undefined` for a transport failure (offline, no host). Social
62
+ * and multiplayer errors also carry {@link HubErrorDetails}.
63
+ */
64
+ var HubError = class extends Error {
65
+ /** An error with a player-safe `message`, the HTTP `status` (if any) and the body's extras. */
66
+ constructor(message, status, details = {}) {
67
+ super(message);
68
+ _defineProperty(this, "status", void 0);
69
+ _defineProperty(this, "code", void 0);
70
+ _defineProperty(this, "reason", void 0);
71
+ _defineProperty(this, "hint", void 0);
72
+ _defineProperty(this, "retryAfterMs", void 0);
73
+ _defineProperty(this, "until", void 0);
74
+ this.name = "HubError";
75
+ this.status = status;
76
+ this.code = details.code;
77
+ this.reason = details.reason;
78
+ this.hint = details.hint;
79
+ this.retryAfterMs = details.retryAfterMs;
80
+ this.until = details.until;
81
+ }
82
+ /** `true` when the failure was a network or transport error, not an HTTP status. */
83
+ get isOffline() {
84
+ return this.status === void 0;
85
+ }
86
+ };
87
+ /** Thrown when an action that genuinely needs the hub is attempted offline. */
88
+ var OfflineError = class extends HubError {
89
+ /** An offline error; the default message is "This needs a connection.". */
90
+ constructor(message = "This needs a connection.") {
91
+ super(message);
92
+ this.name = "OfflineError";
93
+ }
94
+ };
95
+ /**
96
+ * Text was refused by the safety filter (HTTP 422, or a socket ack with
97
+ * `code: 'content_rejected'`). Keep the player's draft and show `hint`; when
98
+ * `reason` is `muted` or `rate_limited`, `retryAfterMs` says how long to wait.
99
+ */
100
+ var ContentRejectedError = class extends HubError {
101
+ /** A content reject with the hub's message, status and extras. */
102
+ constructor(message, status, details) {
103
+ super(message, status, details);
104
+ this.name = "ContentRejectedError";
105
+ }
106
+ };
107
+ /** Read the fields this client understands from an error body (or any thrown object). */
108
+ function readErrorFields(body) {
109
+ if (!isRecord(body)) return {};
110
+ return {
111
+ error: optionalString(body.error),
112
+ code: optionalString(body.code),
113
+ reason: optionalString(body.reason),
114
+ hint: optionalString(body.hint),
115
+ retryAfterMs: optionalNumber(body.retryAfterMs),
116
+ until: optionalNumber(body.until)
117
+ };
118
+ }
119
+ /** The right error class for a failure: a 422 or `content_rejected` is a {@link ContentRejectedError}. */
120
+ function errorFor(message, status, details) {
121
+ return status === 422 || details.code === "content_rejected" ? new ContentRejectedError(message, status, details) : new HubError(message, status, details);
122
+ }
123
+ /**
124
+ * Re-create an error from another bundle (the social runtime, or another
125
+ * client copy on the page) as this bundle's class, so `instanceof` works in
126
+ * games.
127
+ */
128
+ function toHubError(thrown) {
129
+ if (thrown instanceof HubError) return thrown;
130
+ if (!isRecord(thrown)) return new HubError(String(thrown));
131
+ const fields = readErrorFields(thrown);
132
+ const message = optionalString(thrown.message) ?? "Request failed";
133
+ if (fields.code === "offline") return new OfflineError(message);
134
+ return errorFor(message, optionalNumber(thrown.status), fields);
135
+ }
136
+ //#endregion
137
+ //#region src/core/storage/key-value.ts
138
+ /** A store backed by a `Storage` (`localStorage`). */
139
+ function storageBacked(storage) {
140
+ return {
141
+ read: (key) => storage.getItem(key),
142
+ write: (key, text) => {
143
+ try {
144
+ storage.setItem(key, text);
145
+ } catch {}
146
+ },
147
+ remove: (key) => {
148
+ storage.removeItem(key);
149
+ },
150
+ keys: () => Object.keys(storage)
151
+ };
152
+ }
153
+ /** A store held in memory for this page only. */
154
+ function createMemoryStore() {
155
+ const entries = /* @__PURE__ */ new Map();
156
+ return {
157
+ read: (key) => entries.get(key) ?? null,
158
+ write: (key, text) => {
159
+ entries.set(key, text);
160
+ },
161
+ remove: (key) => {
162
+ entries.delete(key);
163
+ },
164
+ keys: () => [...entries.keys()]
165
+ };
166
+ }
167
+ /** The page's `localStorage`, or `undefined` when it is missing or throws (privacy mode). */
168
+ function pageStorage() {
169
+ try {
170
+ return typeof localStorage === "undefined" ? void 0 : localStorage;
171
+ } catch {
172
+ return;
173
+ }
174
+ }
175
+ /** `localStorage` when available, else a fresh in-memory store. */
176
+ function createPageStore() {
177
+ const storage = pageStorage();
178
+ return storage === void 0 ? createMemoryStore() : storageBacked(storage);
179
+ }
180
+ /** The well-formed events of a stored buffer. */
181
+ function readEvents(stored) {
182
+ if (!Array.isArray(stored)) return [];
183
+ return stored.flatMap((entry) => {
184
+ if (!isRecord(entry)) return [];
185
+ const type = optionalString(entry.type);
186
+ const ts = optionalNumber(entry.ts);
187
+ return type === void 0 || ts === void 0 ? [] : [{
188
+ type,
189
+ ts
190
+ }];
191
+ });
192
+ }
193
+ /** The buffer with one more event, trimmed to {@link EVENT_BUFFER_CAP}: oldest heartbeats go first, then the oldest events. */
194
+ function appendEvent(events, incoming) {
195
+ let buffered = [...events, incoming];
196
+ let excess = buffered.length - 100;
197
+ if (excess <= 0) return buffered;
198
+ buffered = buffered.filter((event) => {
199
+ if (excess > 0 && event.type === "heartbeat") {
200
+ excess -= 1;
201
+ return false;
202
+ }
203
+ return true;
204
+ });
205
+ return buffered.length > 100 ? buffered.slice(buffered.length - 100) : buffered;
206
+ }
207
+ //#endregion
208
+ //#region src/core/offline/queue.ts
209
+ /** The score options in a stored op, or `undefined` when they are malformed. */
210
+ function readScoreOptions(stored) {
211
+ const title = optionalString(stored.title);
212
+ const sort = stored.sort === "asc" || stored.sort === "desc" ? stored.sort : void 0;
213
+ if (stored.meta !== void 0 && !isJson(stored.meta)) return;
214
+ return {
215
+ ...title === void 0 ? {} : { title },
216
+ ...sort === void 0 ? {} : { sort },
217
+ ...stored.meta === void 0 ? {} : { meta: stored.meta }
218
+ };
219
+ }
220
+ /** The profile patch in a stored op, or `undefined` when it is malformed. */
221
+ function readProfilePatch(stored) {
222
+ if (stored.displayName !== void 0 && !isNullableString(stored.displayName) || stored.data !== void 0 && !isJson(stored.data)) return;
223
+ const patch = {};
224
+ if (stored.displayName !== void 0) patch.displayName = stored.displayName;
225
+ if (stored.data !== void 0) patch.data = stored.data;
226
+ return patch;
227
+ }
228
+ /** One stored op's write, or `undefined` when it is not a known, well-formed op. */
229
+ function readOpInput(stored) {
230
+ const slot = optionalString(stored.slot);
231
+ const key = optionalString(stored.key);
232
+ const statValue = optionalNumber(stored.value);
233
+ switch (stored.kind) {
234
+ case "putSave": return slot !== void 0 && isJson(stored.data) && isNullableString(stored.label) ? {
235
+ kind: "putSave",
236
+ slot,
237
+ data: stored.data,
238
+ label: stored.label
239
+ } : void 0;
240
+ case "deleteSave": return slot === void 0 ? void 0 : {
241
+ kind: "deleteSave",
242
+ slot
243
+ };
244
+ case "setStat":
245
+ case "maxStat": return key !== void 0 && statValue !== void 0 ? {
246
+ kind: stored.kind,
247
+ key,
248
+ value: statValue
249
+ } : void 0;
250
+ case "incrStat": {
251
+ const amount = optionalNumber(stored.amount);
252
+ return key !== void 0 && amount !== void 0 ? {
253
+ kind: "incrStat",
254
+ key,
255
+ amount
256
+ } : void 0;
257
+ }
258
+ case "submitScore": {
259
+ const score = optionalNumber(stored.score);
260
+ const opts = isRecord(stored.opts) ? readScoreOptions(stored.opts) : void 0;
261
+ return key !== void 0 && score !== void 0 && opts !== void 0 ? {
262
+ kind: "submitScore",
263
+ key,
264
+ score,
265
+ opts
266
+ } : void 0;
267
+ }
268
+ case "setProfile": {
269
+ const patch = isRecord(stored.patch) ? readProfilePatch(stored.patch) : void 0;
270
+ return patch === void 0 ? void 0 : {
271
+ kind: "setProfile",
272
+ patch
273
+ };
274
+ }
275
+ default: return;
276
+ }
277
+ }
278
+ /** The well-formed ops of a stored queue, in order; anything malformed is dropped. */
279
+ function readQueue(stored) {
280
+ if (!Array.isArray(stored)) return [];
281
+ return stored.flatMap((entry) => {
282
+ if (!isRecord(entry)) return [];
283
+ const input = readOpInput(entry);
284
+ const id = optionalString(entry.id);
285
+ const ts = optionalNumber(entry.ts);
286
+ return input === void 0 || id === void 0 || ts === void 0 ? [] : [{
287
+ ...input,
288
+ id,
289
+ ts
290
+ }];
291
+ });
292
+ }
293
+ /** The better of two scores on a board sorted `sort` (`desc` by default). */
294
+ function betterScore(first, second, sort) {
295
+ return sort === "asc" ? Math.min(first, second) : Math.max(first, second);
296
+ }
297
+ /** The op that `queued` becomes when `incoming` collapses into it, or `undefined` when they don't collapse. */
298
+ function collapsedOp(queued, incoming) {
299
+ if (queued.kind === "incrStat" && incoming.kind === "incrStat" && queued.key === incoming.key) return {
300
+ ...queued,
301
+ amount: queued.amount + incoming.amount
302
+ };
303
+ if (queued.kind === "maxStat" && incoming.kind === "maxStat" && queued.key === incoming.key) return {
304
+ ...queued,
305
+ value: Math.max(queued.value, incoming.value)
306
+ };
307
+ if (queued.kind === "setStat" && incoming.kind === "setStat" && queued.key === incoming.key) return {
308
+ ...queued,
309
+ value: incoming.value
310
+ };
311
+ if (queued.kind === "submitScore" && incoming.kind === "submitScore" && queued.key === incoming.key) return {
312
+ ...queued,
313
+ score: betterScore(queued.score, incoming.score, incoming.opts.sort),
314
+ opts: incoming.opts
315
+ };
316
+ if (queued.kind === "setProfile" && incoming.kind === "setProfile") return {
317
+ ...queued,
318
+ patch: {
319
+ ...queued.patch,
320
+ ...incoming.patch
321
+ }
322
+ };
323
+ }
324
+ /** Whether an op writes save slot `slot`. */
325
+ function writesSlot(op, slot) {
326
+ return (op.kind === "putSave" || op.kind === "deleteSave") && op.slot === slot;
327
+ }
328
+ /**
329
+ * The queue with `incoming` added: a save or delete replaces any queued
330
+ * write to the same slot (and goes last); a compatible op merges into the
331
+ * first queued one in place; anything else is appended.
332
+ */
333
+ function enqueueOp(queue, incoming) {
334
+ if (incoming.kind === "putSave" || incoming.kind === "deleteSave") return [...queue.filter((op) => !writesSlot(op, incoming.slot)), incoming];
335
+ const mergeAt = queue.findIndex((op) => collapsedOp(op, incoming) !== void 0);
336
+ if (mergeAt < 0) return [...queue, incoming];
337
+ return queue.map((op, i) => i === mergeAt ? collapsedOp(op, incoming) ?? op : op);
338
+ }
339
+ //#endregion
340
+ //#region src/core/offline/local-store.ts
341
+ /** The key of the locally known user, shared by every game (like the real session). */
342
+ const USER_KEY = "hub:user";
343
+ function readUser(stored) {
344
+ if (!isRecord(stored)) return null;
345
+ const id = optionalNumber(stored.id);
346
+ const username = optionalString(stored.username);
347
+ if (id === void 0 || username === void 0) return null;
348
+ return typeof stored.guest === "boolean" ? {
349
+ id,
350
+ username,
351
+ guest: stored.guest
352
+ } : {
353
+ id,
354
+ username
355
+ };
356
+ }
357
+ function readSave(stored) {
358
+ if (!isRecord(stored)) return null;
359
+ const slot = optionalString(stored.slot);
360
+ const updatedAt = optionalNumber(stored.updatedAt);
361
+ if (slot === void 0 || updatedAt === void 0 || !isNullableString(stored.label) || !isJson(stored.data)) return null;
362
+ return {
363
+ slot,
364
+ label: stored.label,
365
+ data: stored.data,
366
+ updatedAt
367
+ };
368
+ }
369
+ function readProfile(stored) {
370
+ if (!isRecord(stored)) return null;
371
+ const game = optionalString(stored.game);
372
+ const updatedAt = optionalNumber(stored.updatedAt);
373
+ if (game === void 0 || updatedAt === void 0 || !isNullableString(stored.displayName) || !isJson(stored.data)) return null;
374
+ return {
375
+ game,
376
+ displayName: stored.displayName,
377
+ data: stored.data,
378
+ updatedAt
379
+ };
380
+ }
381
+ function readLocalBoard(stored) {
382
+ if (!isRecord(stored) || !isRecord(stored.board)) return null;
383
+ const key = optionalString(stored.board.key);
384
+ const title = optionalString(stored.board.title);
385
+ const sortDir = stored.board.sortDir === "asc" || stored.board.sortDir === "desc" ? stored.board.sortDir : void 0;
386
+ const best = optionalNumber(stored.best);
387
+ if (key === void 0 || title === void 0 || sortDir === void 0 || best === void 0) return null;
388
+ return {
389
+ board: {
390
+ key,
391
+ title,
392
+ sortDir
393
+ },
394
+ best
395
+ };
396
+ }
397
+ /** One game's offline cache, queue and event buffer. */
398
+ var LocalStore = class {
399
+ /** The cache for game `slug` in `store` (default: `localStorage`, or memory without it). */
400
+ constructor(slug, store = createPageStore()) {
401
+ _defineProperty(this, "prefix", void 0);
402
+ _defineProperty(this, "store", void 0);
403
+ _defineProperty(this, "sequence", 0);
404
+ this.prefix = `hub:${slug}:`;
405
+ this.store = store;
406
+ }
407
+ readStored(key) {
408
+ const text = this.store.read(key);
409
+ return text === null ? void 0 : parseJsonText(text);
410
+ }
411
+ writeStored(key, stored) {
412
+ this.store.write(key, JSON.stringify(stored));
413
+ }
414
+ /** The locally known user. */
415
+ getUser() {
416
+ return readUser(this.readStored(USER_KEY));
417
+ }
418
+ /** Remember the user (offline sign-in, or a snapshot). */
419
+ setUser(user) {
420
+ this.writeStored(USER_KEY, user);
421
+ }
422
+ /** Forget the user (offline sign-out). */
423
+ clearUser() {
424
+ this.store.remove(USER_KEY);
425
+ }
426
+ /** This game's cached profile. */
427
+ getProfile() {
428
+ return readProfile(this.readStored(`${this.prefix}profile`));
429
+ }
430
+ /** Store this game's profile and return it. */
431
+ setProfile(game, displayName, profileData) {
432
+ const profile = {
433
+ game,
434
+ displayName,
435
+ data: profileData,
436
+ updatedAt: Date.now()
437
+ };
438
+ this.writeStored(`${this.prefix}profile`, profile);
439
+ return profile;
440
+ }
441
+ /** Every cached save's metadata, newest first. */
442
+ listSaves() {
443
+ return this.store.keys().filter((key) => key.startsWith(`${this.prefix}save:`)).flatMap((key) => {
444
+ const save = readSave(this.readStored(key));
445
+ return save === null ? [] : [{
446
+ slot: save.slot,
447
+ label: save.label,
448
+ updatedAt: save.updatedAt
449
+ }];
450
+ }).sort((first, second) => second.updatedAt - first.updatedAt);
451
+ }
452
+ /** One cached save, or `null`. */
453
+ getSave(slot) {
454
+ return readSave(this.readStored(`${this.prefix}save:${slot}`));
455
+ }
456
+ /** Store a save and return its `updatedAt`. */
457
+ putSave(slot, saveData, label) {
458
+ const updatedAt = Date.now();
459
+ this.writeStored(`${this.prefix}save:${slot}`, {
460
+ slot,
461
+ label,
462
+ data: saveData,
463
+ updatedAt
464
+ });
465
+ return updatedAt;
466
+ }
467
+ /** Forget a save. */
468
+ deleteSave(slot) {
469
+ this.store.remove(`${this.prefix}save:${slot}`);
470
+ }
471
+ /** This game's cached stats. */
472
+ getStats() {
473
+ const stored = this.readStored(`${this.prefix}stats`);
474
+ return isNumberRecord(stored) ? { ...stored } : {};
475
+ }
476
+ /** Apply one stat update and return the stat's new value. */
477
+ updateStat(key, amount, mode) {
478
+ const stats = this.getStats();
479
+ const current = stats[key] ?? 0;
480
+ const updated = mode === "incr" ? current + amount : mode === "max" ? Math.max(current, amount) : amount;
481
+ this.writeStored(`${this.prefix}stats`, {
482
+ ...stats,
483
+ [key]: updated
484
+ });
485
+ return updated;
486
+ }
487
+ /** The boards this player has a local best on (offline boards only know the player's own best). */
488
+ listBoards() {
489
+ return this.store.keys().filter((key) => key.startsWith(`${this.prefix}lb:`)).flatMap((key) => {
490
+ const local = readLocalBoard(this.readStored(key));
491
+ return local === null ? [] : [local.board];
492
+ });
493
+ }
494
+ /** One board's local best, or `null`. */
495
+ getBoard(key) {
496
+ return readLocalBoard(this.readStored(`${this.prefix}lb:${key}`));
497
+ }
498
+ /** Record a score locally, keeping the best one; returns the board and best. */
499
+ submitScore(key, score, options) {
500
+ const existing = this.getBoard(key);
501
+ const sortDir = existing?.board.sortDir ?? (options.sort === "asc" ? "asc" : "desc");
502
+ const board = existing?.board ?? {
503
+ key,
504
+ title: options.title !== void 0 && options.title !== "" ? options.title : key,
505
+ sortDir
506
+ };
507
+ const best = existing === null ? score : betterScore(existing.best, score, sortDir);
508
+ this.writeStored(`${this.prefix}lb:${key}`, {
509
+ board,
510
+ best
511
+ });
512
+ return {
513
+ board,
514
+ best
515
+ };
516
+ }
517
+ /** The queued writes, oldest first. */
518
+ getQueue() {
519
+ return readQueue(this.readStored(`${this.prefix}queue`));
520
+ }
521
+ /** Replace the queue. */
522
+ setQueue(queue) {
523
+ this.writeStored(`${this.prefix}queue`, queue);
524
+ }
525
+ /** How many writes are queued. */
526
+ queueLength() {
527
+ return this.getQueue().length;
528
+ }
529
+ /** Queue a write, collapsing it with a compatible queued one. */
530
+ enqueue(input) {
531
+ this.sequence += 1;
532
+ const now = Date.now();
533
+ this.setQueue(enqueueOp(this.getQueue(), {
534
+ ...input,
535
+ id: `${String(now)}-${String(this.sequence)}`,
536
+ ts: now
537
+ }));
538
+ }
539
+ /** The buffered analytics events, oldest first. */
540
+ getEvents() {
541
+ return readEvents(this.readStored(`${this.prefix}events`));
542
+ }
543
+ /** Replace the event buffer. */
544
+ setEvents(events) {
545
+ this.writeStored(`${this.prefix}events`, events);
546
+ }
547
+ /** Buffer one event (capped; heartbeats are dropped first). */
548
+ bufferEvent(type) {
549
+ this.setEvents(appendEvent(this.getEvents(), {
550
+ type,
551
+ ts: Date.now()
552
+ }));
553
+ }
554
+ /** Fan a `/me/snapshot` reply out across every game's cache keys. */
555
+ hydrate(snapshot) {
556
+ if (snapshot.user !== null) this.writeStored(USER_KEY, snapshot.user);
557
+ for (const [slug, game] of Object.entries(snapshot.games)) {
558
+ const gamePrefix = `hub:${slug}:`;
559
+ if (game.profile !== null) this.writeStored(`${gamePrefix}profile`, game.profile);
560
+ for (const save of game.saves) this.writeStored(`${gamePrefix}save:${save.slot}`, save);
561
+ this.writeStored(`${gamePrefix}stats`, game.stats);
562
+ this.writeStored(`${gamePrefix}inventory`, game.inventory);
563
+ }
564
+ for (const gameRecords of snapshot.records) for (const record of gameRecords.records) this.writeStored(`hub:${gameRecords.slug}:lb:${record.key}`, {
565
+ board: {
566
+ key: record.key,
567
+ title: record.title,
568
+ sortDir: record.sortDir
569
+ },
570
+ best: record.score
571
+ });
572
+ this.writeStored("hub:records", snapshot.records);
573
+ this.writeStored("hub:leaderboards", snapshot.leaderboards);
574
+ }
575
+ };
576
+ //#endregion
577
+ //#region src/core/page.ts
578
+ /** Whether this code runs in a browser page. */
579
+ function isBrowserPage() {
580
+ return typeof window !== "undefined";
581
+ }
582
+ /** The injected config's slug and API base, read defensively (empty outside a hub page). */
583
+ function injectedConfig() {
584
+ if (!isBrowserPage() || !isRecord(window.__HUB__)) return {};
585
+ const slug = optionalString(window.__HUB__.slug);
586
+ const apiBase = optionalString(window.__HUB__.apiBase);
587
+ return {
588
+ ...slug === void 0 ? {} : { slug },
589
+ ...apiBase === void 0 ? {} : { apiBase }
590
+ };
591
+ }
592
+ /** The game's slug: the injected one, else the first URL path segment, else `null`. */
593
+ function detectSlug() {
594
+ const injectedSlug = injectedConfig().slug;
595
+ if (injectedSlug !== void 0 && injectedSlug !== "") return injectedSlug;
596
+ if (typeof location === "undefined") return null;
597
+ return location.pathname.split("/").find((segment) => segment !== "") ?? null;
598
+ }
599
+ /** An API base made absolute against the page origin (`http://localhost` outside a browser), without a trailing slash. */
600
+ function absoluteApiBase(apiBase) {
601
+ const origin = typeof location === "undefined" ? "http://localhost" : location.origin;
602
+ return new URL(apiBase, origin).toString().replace(/\/$/, "");
603
+ }
604
+ /** The page's API base: the injected one, else `/_api`, made absolute. */
605
+ function detectApiBase() {
606
+ return absoluteApiBase(injectedConfig().apiBase ?? "/_api");
607
+ }
608
+ /** Dispatch a `CustomEvent` on `window` (no-op outside a browser; listener errors never reach the caller). */
609
+ function emitWindowEvent(eventName, detail) {
610
+ if (!isBrowserPage()) return;
611
+ try {
612
+ window.dispatchEvent(new CustomEvent(eventName, { detail }));
613
+ } catch {}
614
+ }
615
+ //#endregion
616
+ //#region src/core/rest/routes.gen.ts
617
+ /** Every public REST endpoint's method, path (under `/_api`) and query keys, from the contract. */
618
+ const ROUTES = {
619
+ ensureGuestSession: {
620
+ method: "POST",
621
+ path: "/auth/guest",
622
+ query: []
623
+ },
624
+ signUpWithPassword: {
625
+ method: "POST",
626
+ path: "/auth/signup",
627
+ query: []
628
+ },
629
+ logInWithPassword: {
630
+ method: "POST",
631
+ path: "/auth/login",
632
+ query: []
633
+ },
634
+ logOut: {
635
+ method: "POST",
636
+ path: "/auth/logout",
637
+ query: []
638
+ },
639
+ getCurrentUser: {
640
+ method: "GET",
641
+ path: "/me",
642
+ query: []
643
+ },
644
+ claimGuestAccount: {
645
+ method: "POST",
646
+ path: "/auth/claim",
647
+ query: []
648
+ },
649
+ listGames: {
650
+ method: "GET",
651
+ path: "/games",
652
+ query: []
653
+ },
654
+ listWhatsNewNotes: {
655
+ method: "GET",
656
+ path: "/changes",
657
+ query: ["game", "since"]
658
+ },
659
+ listFavoriteGames: {
660
+ method: "GET",
661
+ path: "/favorites",
662
+ query: []
663
+ },
664
+ setGameFavorite: {
665
+ method: "PUT",
666
+ path: "/favorites/:slug",
667
+ query: []
668
+ },
669
+ markWhatsNewSeen: {
670
+ method: "POST",
671
+ path: "/changes/seen",
672
+ query: []
673
+ },
674
+ getGameStats: {
675
+ method: "GET",
676
+ path: "/games/:slug/stats",
677
+ query: ["user"]
678
+ },
679
+ getGameProfile: {
680
+ method: "GET",
681
+ path: "/games/:slug/profile",
682
+ query: []
683
+ },
684
+ setGameProfile: {
685
+ method: "PUT",
686
+ path: "/games/:slug/profile",
687
+ query: []
688
+ },
689
+ listSaves: {
690
+ method: "GET",
691
+ path: "/games/:slug/saves",
692
+ query: []
693
+ },
694
+ getSave: {
695
+ method: "GET",
696
+ path: "/games/:slug/saves/:slot",
697
+ query: []
698
+ },
699
+ writeSave: {
700
+ method: "PUT",
701
+ path: "/games/:slug/saves/:slot",
702
+ query: []
703
+ },
704
+ deleteSave: {
705
+ method: "DELETE",
706
+ path: "/games/:slug/saves/:slot",
707
+ query: []
708
+ },
709
+ updateGameStat: {
710
+ method: "POST",
711
+ path: "/games/:slug/stats",
712
+ query: []
713
+ },
714
+ resetMyGameProgress: {
715
+ method: "POST",
716
+ path: "/games/:slug/reset",
717
+ query: []
718
+ },
719
+ listMyLeaderboardRecords: {
720
+ method: "GET",
721
+ path: "/me/records",
722
+ query: ["game"]
723
+ },
724
+ getMyOfflineSnapshot: {
725
+ method: "GET",
726
+ path: "/me/snapshot",
727
+ query: ["top"]
728
+ },
729
+ listTopOfEveryLeaderboard: {
730
+ method: "GET",
731
+ path: "/leaderboards",
732
+ query: ["top"]
733
+ },
734
+ listGameLeaderboards: {
735
+ method: "GET",
736
+ path: "/games/:slug/leaderboards",
737
+ query: []
738
+ },
739
+ getLeaderboard: {
740
+ method: "GET",
741
+ path: "/games/:slug/leaderboards/:key",
742
+ query: ["limit"]
743
+ },
744
+ submitScore: {
745
+ method: "POST",
746
+ path: "/games/:slug/leaderboards/:key/scores",
747
+ query: []
748
+ },
749
+ listDailyChallengesForAllGames: {
750
+ method: "GET",
751
+ path: "/daily",
752
+ query: []
753
+ },
754
+ getGameDailyChallenges: {
755
+ method: "GET",
756
+ path: "/games/:slug/daily",
757
+ query: []
758
+ },
759
+ startDailyChallenge: {
760
+ method: "POST",
761
+ path: "/games/:slug/daily/:day/start",
762
+ query: []
763
+ },
764
+ completeDailyChallenge: {
765
+ method: "POST",
766
+ path: "/games/:slug/daily/:day/complete",
767
+ query: []
768
+ },
769
+ getMyInventory: {
770
+ method: "GET",
771
+ path: "/games/:slug/inventory",
772
+ query: []
773
+ },
774
+ grantInventoryItems: {
775
+ method: "POST",
776
+ path: "/games/:slug/inventory/grant",
777
+ query: []
778
+ },
779
+ exchangeInventoryItems: {
780
+ method: "POST",
781
+ path: "/games/:slug/inventory/exchange",
782
+ query: []
783
+ },
784
+ getPlayerInventory: {
785
+ method: "GET",
786
+ path: "/games/:slug/players/:userId/inventory",
787
+ query: []
788
+ },
789
+ listTradePartners: {
790
+ method: "GET",
791
+ path: "/games/:slug/trades/players",
792
+ query: []
793
+ },
794
+ listMyTrades: {
795
+ method: "GET",
796
+ path: "/games/:slug/trades",
797
+ query: ["box", "status"]
798
+ },
799
+ declineTrade: {
800
+ method: "POST",
801
+ path: "/games/:slug/trades/:id/decline",
802
+ query: []
803
+ },
804
+ cancelTrade: {
805
+ method: "POST",
806
+ path: "/games/:slug/trades/:id/cancel",
807
+ query: []
808
+ },
809
+ createTrade: {
810
+ method: "POST",
811
+ path: "/games/:slug/trades",
812
+ query: []
813
+ },
814
+ acceptTrade: {
815
+ method: "POST",
816
+ path: "/games/:slug/trades/:id/accept",
817
+ query: []
818
+ },
819
+ counterTradeOffer: {
820
+ method: "POST",
821
+ path: "/games/:slug/trades/:id/counter",
822
+ query: []
823
+ },
824
+ getSiteAnalytics: {
825
+ method: "GET",
826
+ path: "/analytics",
827
+ query: []
828
+ },
829
+ reportClientError: {
830
+ method: "POST",
831
+ path: "/client-log",
832
+ query: []
833
+ },
834
+ recordGameEvent: {
835
+ method: "POST",
836
+ path: "/games/:slug/events",
837
+ query: []
838
+ },
839
+ getGameAnalytics: {
840
+ method: "GET",
841
+ path: "/games/:slug/analytics",
842
+ query: []
843
+ },
844
+ getSocialConfig: {
845
+ method: "GET",
846
+ path: "/social/config",
847
+ query: []
848
+ },
849
+ getOnlineCounts: {
850
+ method: "GET",
851
+ path: "/social/counts",
852
+ query: []
853
+ },
854
+ getMySocialProfile: {
855
+ method: "GET",
856
+ path: "/social/me",
857
+ query: []
858
+ },
859
+ listFriendsAndRequests: {
860
+ method: "GET",
861
+ path: "/social/friends",
862
+ query: []
863
+ },
864
+ findPlayerByName: {
865
+ method: "GET",
866
+ path: "/social/players/by-name/:name",
867
+ query: []
868
+ },
869
+ getPlayerCard: {
870
+ method: "GET",
871
+ path: "/social/players/:id",
872
+ query: []
873
+ },
874
+ updateMySocialProfile: {
875
+ method: "PUT",
876
+ path: "/social/me",
877
+ query: []
878
+ },
879
+ setParentChatLock: {
880
+ method: "POST",
881
+ path: "/social/chat-lock",
882
+ query: []
883
+ },
884
+ sendFriendRequest: {
885
+ method: "POST",
886
+ path: "/social/friends",
887
+ query: []
888
+ },
889
+ acceptFriendRequest: {
890
+ method: "POST",
891
+ path: "/social/friends/:id/accept",
892
+ query: []
893
+ },
894
+ declineFriendRequest: {
895
+ method: "POST",
896
+ path: "/social/friends/:id/decline",
897
+ query: []
898
+ },
899
+ removeFriend: {
900
+ method: "DELETE",
901
+ path: "/social/friends/:id",
902
+ query: []
903
+ },
904
+ setFriendNotifications: {
905
+ method: "PUT",
906
+ path: "/social/friends/:id/notify",
907
+ query: []
908
+ },
909
+ blockPlayer: {
910
+ method: "POST",
911
+ path: "/social/blocks",
912
+ query: []
913
+ },
914
+ unblockPlayer: {
915
+ method: "DELETE",
916
+ path: "/social/blocks/:id",
917
+ query: []
918
+ },
919
+ listRecentPlayers: {
920
+ method: "GET",
921
+ path: "/social/recent",
922
+ query: []
923
+ },
924
+ listConversations: {
925
+ method: "GET",
926
+ path: "/social/conversations",
927
+ query: []
928
+ },
929
+ listDirectMessages: {
930
+ method: "GET",
931
+ path: "/social/messages/:userId",
932
+ query: ["before"]
933
+ },
934
+ sendDirectMessage: {
935
+ method: "POST",
936
+ path: "/social/messages/:userId",
937
+ query: []
938
+ },
939
+ markDirectMessagesRead: {
940
+ method: "POST",
941
+ path: "/social/messages/:userId/read",
942
+ query: []
943
+ },
944
+ getNotificationPreferences: {
945
+ method: "GET",
946
+ path: "/social/notify",
947
+ query: []
948
+ },
949
+ unsubscribeFromPush: {
950
+ method: "DELETE",
951
+ path: "/social/push",
952
+ query: []
953
+ },
954
+ setNotificationPreferences: {
955
+ method: "PUT",
956
+ path: "/social/notify",
957
+ query: []
958
+ },
959
+ subscribeToPush: {
960
+ method: "POST",
961
+ path: "/social/push",
962
+ query: []
963
+ },
964
+ fileReport: {
965
+ method: "POST",
966
+ path: "/social/reports",
967
+ query: []
968
+ },
969
+ listPublicSuggestions: {
970
+ method: "GET",
971
+ path: "/suggestions",
972
+ query: ["sort"]
973
+ },
974
+ submitSuggestion: {
975
+ method: "POST",
976
+ path: "/suggestions",
977
+ query: []
978
+ },
979
+ voteForSuggestion: {
980
+ method: "POST",
981
+ path: "/suggestions/:id/vote",
982
+ query: []
983
+ },
984
+ removeSuggestionVote: {
985
+ method: "DELETE",
986
+ path: "/suggestions/:id/vote",
987
+ query: []
988
+ },
989
+ withdrawSuggestion: {
990
+ method: "DELETE",
991
+ path: "/suggestions/:id",
992
+ query: []
993
+ },
994
+ sendGameInvite: {
995
+ method: "POST",
996
+ path: "/mp/invites",
997
+ query: []
998
+ },
999
+ acceptGameInvite: {
1000
+ method: "POST",
1001
+ path: "/mp/invites/:id/accept",
1002
+ query: []
1003
+ },
1004
+ declineGameInvite: {
1005
+ method: "POST",
1006
+ path: "/mp/invites/:id/decline",
1007
+ query: []
1008
+ },
1009
+ cancelGameInvite: {
1010
+ method: "DELETE",
1011
+ path: "/mp/invites/:id",
1012
+ query: []
1013
+ },
1014
+ redeemLaunchToken: {
1015
+ method: "GET",
1016
+ path: "/mp/launch/:token",
1017
+ query: []
1018
+ },
1019
+ setPartyJoinInfo: {
1020
+ method: "POST",
1021
+ path: "/mp/parties/:id/join-info",
1022
+ query: []
1023
+ },
1024
+ leaveParty: {
1025
+ method: "POST",
1026
+ path: "/mp/parties/:id/leave",
1027
+ query: []
1028
+ },
1029
+ launchIntoFriendsRoom: {
1030
+ method: "POST",
1031
+ path: "/mp/join",
1032
+ query: []
1033
+ },
1034
+ getGameServerTicket: {
1035
+ method: "POST",
1036
+ path: "/mp/ticket",
1037
+ query: []
1038
+ },
1039
+ listMyRaceHistory: {
1040
+ method: "GET",
1041
+ path: "/race/history",
1042
+ query: [
1043
+ "game",
1044
+ "with",
1045
+ "before",
1046
+ "limit"
1047
+ ]
1048
+ },
1049
+ getHubVersion: {
1050
+ method: "GET",
1051
+ path: "/version",
1052
+ query: []
1053
+ }
1054
+ };
1055
+ //#endregion
1056
+ //#region src/core/rest/rest-client.ts
1057
+ /**
1058
+ * The REST transport: a small fetch router over the contract's route table
1059
+ * (plan §12 A10). It splits one merged fields object into the path, query
1060
+ * and JSON body the way ayepi's client does, stamps `x-hub-client`, and turns
1061
+ * failures into {@link HubError}s. It carries no schemas: replies are typed
1062
+ * from the contract and trusted, as ayepi's zod-free client trusts them.
1063
+ */
1064
+ /** The header that tells the hub which client version is calling. */
1065
+ const CLIENT_VERSION_HEADER = "x-hub-client";
1066
+ /** What both hubs answer for a route they do not have. */
1067
+ const NO_SUCH_ENDPOINT = "No such API endpoint.";
1068
+ /** One field value as URL text. */
1069
+ function urlText(fieldValue) {
1070
+ if (typeof fieldValue === "string") return fieldValue;
1071
+ if (typeof fieldValue === "number" || typeof fieldValue === "boolean") return String(fieldValue);
1072
+ return JSON.stringify(fieldValue);
1073
+ }
1074
+ /**
1075
+ * Split merged fields into path, query and body for one endpoint: `:name`
1076
+ * segments take their field, query keys go to the query string, and the
1077
+ * rest is the JSON body. POST and PUT always send a body (`{}` when empty),
1078
+ * as the v1 client did; DELETE sends one only when it has fields.
1079
+ */
1080
+ function planRequest(name, fields) {
1081
+ const route = ROUTES[name];
1082
+ const remaining = new Map(Object.entries(isRecord(fields) ? fields : {}).filter(([, fieldValue]) => fieldValue !== void 0));
1083
+ const path = route.path.replace(/:([A-Za-z]+)/g, (_segment, paramName) => {
1084
+ const paramValue = remaining.get(paramName);
1085
+ remaining.delete(paramName);
1086
+ return encodeURIComponent(urlText(paramValue));
1087
+ });
1088
+ const queryKeys = route.method === "GET" ? [...remaining.keys()] : route.query;
1089
+ const query = new URLSearchParams();
1090
+ for (const key of queryKeys) {
1091
+ const queryValue = remaining.get(key);
1092
+ remaining.delete(key);
1093
+ if (queryValue !== void 0 && queryValue !== null) query.set(key, urlText(queryValue));
1094
+ }
1095
+ const queryText = query.toString();
1096
+ const hasBody = route.method === "POST" || route.method === "PUT" || route.method === "DELETE" && remaining.size > 0;
1097
+ return {
1098
+ pathAndQuery: queryText === "" ? path : `${path}?${queryText}`,
1099
+ method: route.method,
1100
+ bodyText: hasBody ? JSON.stringify(Object.fromEntries(remaining)) : void 0
1101
+ };
1102
+ }
1103
+ /**
1104
+ * The reply body, typed by the contract. This is the client's one trust
1105
+ * boundary on replies: there is no zod in the browser bundle (plan D16).
1106
+ */
1107
+ async function readReply(response) {
1108
+ try {
1109
+ return await response.json();
1110
+ } catch {
1111
+ throw new HubError("The hub sent a reply that is not JSON.", response.status);
1112
+ }
1113
+ }
1114
+ /** The error for a failed response. */
1115
+ async function failureOf(name, response) {
1116
+ let fields = readErrorFields(void 0);
1117
+ try {
1118
+ fields = readErrorFields(await response.json());
1119
+ } catch {}
1120
+ if (response.status === 404 && fields.error === NO_SUCH_ENDPOINT) return new HubError(`This hub does not have "${name}" yet: deploy the hub first, or use an older client.`, 404, { code: UNSUPPORTED_BY_HUB });
1121
+ return errorFor(fields.error ?? `Request failed (${response.status})`, response.status, fields);
1122
+ }
1123
+ /** A REST client for one API base. */
1124
+ function createRestClient(options) {
1125
+ async function send(name, fields) {
1126
+ const planned = planRequest(name, fields);
1127
+ let response;
1128
+ try {
1129
+ response = await options.fetch(options.apiBase + planned.pathAndQuery, {
1130
+ method: planned.method,
1131
+ credentials: "same-origin",
1132
+ headers: {
1133
+ "content-type": "application/json",
1134
+ [CLIENT_VERSION_HEADER]: HUB_CLIENT_VERSION
1135
+ },
1136
+ ...planned.bodyText === void 0 ? {} : { body: planned.bodyText }
1137
+ });
1138
+ } catch (failure) {
1139
+ throw new HubError(`Hub unreachable: ${failure instanceof Error ? failure.message : String(failure)}`);
1140
+ }
1141
+ options.onReachable?.();
1142
+ if (!response.ok) throw await failureOf(name, response);
1143
+ return readReply(response);
1144
+ }
1145
+ return {
1146
+ apiBase: options.apiBase,
1147
+ call: (name, ...fieldsArgument) => send(name, fieldsArgument[0])
1148
+ };
1149
+ }
1150
+ //#endregion
1151
+ //#region src/core/connection.ts
1152
+ /**
1153
+ * The machinery under every `Hub` call: reaching the REST API, falling back
1154
+ * to the offline cache, queueing writes made offline and replaying them,
1155
+ * buffering analytics, warming the cache for every game, and reporting
1156
+ * online/offline/sync status (to `onStatus` listeners and as `hub:*` window
1157
+ * events, which the injected menu listens for).
1158
+ */
1159
+ /** How long `prefetchAll()` waits between snapshots unless forced. */
1160
+ const PREFETCH_INTERVAL_MS = 3e4;
1161
+ /** Whether a failure means "the hub can't be reached" (rather than a refusal). */
1162
+ function isTransportFailure(failure) {
1163
+ return failure instanceof HubError && failure.isOffline;
1164
+ }
1165
+ /** The connection, offline cache and sync machinery shared by every `Hub` method. */
1166
+ var HubConnection = class {
1167
+ /** A connection for `options` (slug and API base default to the page's). */
1168
+ constructor(options = {}) {
1169
+ _defineProperty(this, "slug", void 0);
1170
+ _defineProperty(this, "apiBase", void 0);
1171
+ _defineProperty(this, "rest", void 0);
1172
+ _defineProperty(this, "store", void 0);
1173
+ _defineProperty(this, "offlineMode", void 0);
1174
+ _defineProperty(this, "isReachable", true);
1175
+ _defineProperty(this, "isSyncing", false);
1176
+ _defineProperty(this, "lastPrefetchAt", 0);
1177
+ _defineProperty(this, "statusListeners", /* @__PURE__ */ new Set());
1178
+ this.slug = options.slug ?? detectSlug();
1179
+ this.apiBase = options.apiBase === void 0 ? detectApiBase() : absoluteApiBase(options.apiBase);
1180
+ this.offlineMode = options.offline ?? "auto";
1181
+ this.rest = createRestClient({
1182
+ apiBase: this.apiBase,
1183
+ fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
1184
+ onReachable: () => {
1185
+ this.setReachable(true);
1186
+ }
1187
+ });
1188
+ this.store = new LocalStore(this.slug ?? "app");
1189
+ if (typeof navigator !== "undefined" && "onLine" in navigator) this.isReachable = navigator.onLine;
1190
+ if (isBrowserPage()) {
1191
+ window.addEventListener("online", () => {
1192
+ this.setReachable(true);
1193
+ });
1194
+ window.addEventListener("offline", () => {
1195
+ this.setReachable(false);
1196
+ });
1197
+ }
1198
+ }
1199
+ /** `true` when calls are being served from the offline cache rather than the hub. */
1200
+ get isOffline() {
1201
+ return this.offlineMode === "always" || this.offlineMode === "auto" && !this.isReachable;
1202
+ }
1203
+ /** `true` while the hub is reachable. */
1204
+ get isOnline() {
1205
+ return this.isReachable;
1206
+ }
1207
+ /** Queued writes plus buffered analytics events waiting to sync. */
1208
+ pending() {
1209
+ return this.store.queueLength() + this.store.getEvents().length;
1210
+ }
1211
+ /** Subscribe to connection and sync status changes; returns the unsubscribe function. */
1212
+ onStatus(listener) {
1213
+ this.statusListeners.add(listener);
1214
+ return () => {
1215
+ this.statusListeners.delete(listener);
1216
+ };
1217
+ }
1218
+ /** Tell `onStatus` listeners and the page (`hub:status`) the current status. */
1219
+ notifyStatus() {
1220
+ const status = {
1221
+ online: this.isReachable,
1222
+ offline: this.isOffline,
1223
+ pending: this.pending()
1224
+ };
1225
+ for (const listener of this.statusListeners) try {
1226
+ listener(status);
1227
+ } catch {}
1228
+ emitWindowEvent("hub:status", status);
1229
+ }
1230
+ /** Record reachability; coming back online syncs the queue and re-warms the cache. */
1231
+ setReachable(isReachableNow) {
1232
+ if (isReachableNow === this.isReachable) return;
1233
+ this.isReachable = isReachableNow;
1234
+ emitWindowEvent(isReachableNow ? "hub:online" : "hub:offline", { pending: this.pending() });
1235
+ this.notifyStatus();
1236
+ if (isReachableNow) {
1237
+ this.sync();
1238
+ this.prefetchAll().catch(() => void 0);
1239
+ }
1240
+ }
1241
+ /** `{ slug }` for this game's endpoints; throws when the slug is unknown. */
1242
+ gameFields() {
1243
+ if (this.slug === null) throw new HubError("Hub: could not determine the game slug.");
1244
+ return { slug: this.slug };
1245
+ }
1246
+ /**
1247
+ * Run the network call, falling back to the offline answer when the hub
1248
+ * can't be reached (per the offline mode). Real API errors (with an HTTP
1249
+ * status) always reach the caller.
1250
+ */
1251
+ async withFallback(networkCall, offlineAnswer) {
1252
+ if (this.isOffline) return offlineAnswer();
1253
+ try {
1254
+ return await networkCall();
1255
+ } catch (failure) {
1256
+ if (!isTransportFailure(failure) || this.offlineMode === "never") throw failure;
1257
+ this.setReachable(false);
1258
+ return offlineAnswer();
1259
+ }
1260
+ }
1261
+ /** Send one queued write. */
1262
+ async sendOp(op) {
1263
+ const game = this.gameFields();
1264
+ switch (op.kind) {
1265
+ case "putSave":
1266
+ await this.rest.call("writeSave", {
1267
+ ...game,
1268
+ slot: op.slot,
1269
+ data: op.data,
1270
+ label: op.label
1271
+ });
1272
+ return;
1273
+ case "deleteSave":
1274
+ await this.rest.call("deleteSave", {
1275
+ ...game,
1276
+ slot: op.slot
1277
+ });
1278
+ return;
1279
+ case "setStat":
1280
+ await this.rest.call("updateGameStat", {
1281
+ ...game,
1282
+ key: op.key,
1283
+ value: op.value,
1284
+ mode: "set"
1285
+ });
1286
+ return;
1287
+ case "maxStat":
1288
+ await this.rest.call("updateGameStat", {
1289
+ ...game,
1290
+ key: op.key,
1291
+ value: op.value,
1292
+ mode: "max"
1293
+ });
1294
+ return;
1295
+ case "incrStat":
1296
+ await this.rest.call("updateGameStat", {
1297
+ ...game,
1298
+ key: op.key,
1299
+ value: op.amount,
1300
+ mode: "incr"
1301
+ });
1302
+ return;
1303
+ case "submitScore":
1304
+ await this.rest.call("submitScore", {
1305
+ ...game,
1306
+ key: op.key,
1307
+ score: op.score,
1308
+ ...op.opts
1309
+ });
1310
+ return;
1311
+ case "setProfile":
1312
+ await this.rest.call("setGameProfile", {
1313
+ ...game,
1314
+ ...op.patch
1315
+ });
1316
+ return;
1317
+ }
1318
+ }
1319
+ /**
1320
+ * Apply a write to the offline cache at once, then send it now (online
1321
+ * with an empty queue) or queue it behind the others. Resolves with the
1322
+ * local answer; a real API error on the direct path reaches the caller and
1323
+ * is not queued.
1324
+ */
1325
+ async mutateQueued(input, localAnswer) {
1326
+ const answer = localAnswer();
1327
+ if (this.isOffline || this.store.queueLength() > 0) {
1328
+ this.store.enqueue(input);
1329
+ this.notifyStatus();
1330
+ if (!this.isOffline) this.sync();
1331
+ return answer;
1332
+ }
1333
+ try {
1334
+ await this.sendOp(input);
1335
+ return answer;
1336
+ } catch (failure) {
1337
+ if (!isTransportFailure(failure)) throw failure;
1338
+ this.setReachable(false);
1339
+ this.store.enqueue(input);
1340
+ this.notifyStatus();
1341
+ return answer;
1342
+ }
1343
+ }
1344
+ /**
1345
+ * Replay the queue, then the buffered events, in order. Stops at the first
1346
+ * transport failure (offline again: the rest stays queued); a write the hub
1347
+ * refuses is dropped and reported as a conflict.
1348
+ */
1349
+ async sync() {
1350
+ if (this.isSyncing || this.isOffline || this.slug === null) return {
1351
+ flushed: 0,
1352
+ conflicts: []
1353
+ };
1354
+ this.isSyncing = true;
1355
+ let flushed = 0;
1356
+ const conflicts = [];
1357
+ try {
1358
+ let queue = this.store.getQueue();
1359
+ for (let op = queue[0]; op !== void 0; op = queue[0]) {
1360
+ try {
1361
+ await this.sendOp(op);
1362
+ flushed += 1;
1363
+ } catch (failure) {
1364
+ if (isTransportFailure(failure)) break;
1365
+ conflicts.push({
1366
+ op,
1367
+ error: failure instanceof Error ? failure.message : String(failure)
1368
+ });
1369
+ }
1370
+ queue = queue.slice(1);
1371
+ this.store.setQueue(queue);
1372
+ }
1373
+ await this.flushEvents();
1374
+ } finally {
1375
+ this.isSyncing = false;
1376
+ }
1377
+ emitWindowEvent("hub:sync", {
1378
+ pending: this.pending(),
1379
+ flushed,
1380
+ conflicts
1381
+ });
1382
+ this.notifyStatus();
1383
+ return {
1384
+ flushed,
1385
+ conflicts
1386
+ };
1387
+ }
1388
+ /** Send the buffered analytics events; a refused one is dropped (telemetry never blocks). */
1389
+ async flushEvents() {
1390
+ let events = this.store.getEvents();
1391
+ for (let event = events[0]; event !== void 0; event = events[0]) {
1392
+ try {
1393
+ await this.rest.call("recordGameEvent", {
1394
+ ...this.gameFields(),
1395
+ type: event.type
1396
+ });
1397
+ } catch (failure) {
1398
+ if (isTransportFailure(failure)) return;
1399
+ }
1400
+ events = events.slice(1);
1401
+ this.store.setEvents(events);
1402
+ }
1403
+ }
1404
+ /**
1405
+ * Warm the offline cache for every game the player has touched, in one
1406
+ * request, so going offline later still shows complete progress.
1407
+ * Throttled to once per 30 s unless `isForced`; `null` when offline or throttled.
1408
+ */
1409
+ async prefetchAll(isForced = false) {
1410
+ if (this.isOffline || !isForced && Date.now() - this.lastPrefetchAt < PREFETCH_INTERVAL_MS) return null;
1411
+ let snapshot;
1412
+ try {
1413
+ snapshot = await this.rest.call("getMyOfflineSnapshot");
1414
+ } catch (failure) {
1415
+ if (isTransportFailure(failure)) return null;
1416
+ throw failure;
1417
+ }
1418
+ this.lastPrefetchAt = Date.now();
1419
+ this.store.hydrate(snapshot);
1420
+ emitWindowEvent("hub:prefetch", { games: Object.keys(snapshot.games).length });
1421
+ return snapshot;
1422
+ }
1423
+ };
1424
+ //#endregion
1425
+ //#region src/core/game-data.ts
1426
+ /** Always true: a game's payload is sent as JSON (`JSON.stringify` drops what JSON can't hold). */
1427
+ function isSentAsJson(_payload) {
1428
+ return true;
1429
+ }
1430
+ /** Always true: JSON the game stored is read back as the game's type. */
1431
+ function isGameOwnData(_stored) {
1432
+ return true;
1433
+ }
1434
+ /** A game's payload as the JSON the hub stores. */
1435
+ function toStoredJson(payload) {
1436
+ if (isSentAsJson(payload)) return payload;
1437
+ throw new TypeError("unreachable: every payload is sent as JSON");
1438
+ }
1439
+ /** JSON the game stored, typed as the game's own type. */
1440
+ function toGameData(stored) {
1441
+ if (isGameOwnData(stored)) return stored;
1442
+ throw new TypeError("unreachable: stored game data is the game's own");
1443
+ }
1444
+ //#endregion
1445
+ //#region src/core/hub-progress.ts
1446
+ /** A profile with the game's own data typed as `TData`. */
1447
+ function typedProfile(profile) {
1448
+ return profile === null ? null : {
1449
+ ...profile,
1450
+ data: toGameData(profile.data)
1451
+ };
1452
+ }
1453
+ /** A save with the game's own data typed as `TData`. */
1454
+ function typedSave(save) {
1455
+ return save === null ? null : {
1456
+ ...save,
1457
+ data: toGameData(save.data)
1458
+ };
1459
+ }
1460
+ /** Accounts, profile, saves, stats and leaderboards. */
1461
+ var HubProgress = class extends HubConnection {
1462
+ /** The current user (or `null`) and the games they have a profile in. */
1463
+ me() {
1464
+ return this.withFallback(() => this.rest.call("getCurrentUser"), () => {
1465
+ const profile = this.store.getProfile();
1466
+ return {
1467
+ user: this.store.getUser(),
1468
+ profiles: profile === null ? [] : [{
1469
+ game: profile.game,
1470
+ displayName: profile.displayName
1471
+ }]
1472
+ };
1473
+ });
1474
+ }
1475
+ /**
1476
+ * Make sure a session exists, creating a guest (a random name, no
1477
+ * password) when the player isn't signed in, so progress saves without a
1478
+ * sign-in. Idempotent. Offline, a local guest is used.
1479
+ */
1480
+ ensureGuest() {
1481
+ return this.withFallback(() => this.rest.call("ensureGuestSession"), () => {
1482
+ const known = this.store.getUser();
1483
+ if (known !== null) return { user: known };
1484
+ const guest = {
1485
+ id: 0,
1486
+ username: "Guest-local",
1487
+ guest: true
1488
+ };
1489
+ this.store.setUser(guest);
1490
+ return { user: guest };
1491
+ });
1492
+ }
1493
+ /**
1494
+ * Turn the current guest into a registered account (the same identity, so
1495
+ * all progress carries over). Rejects with 409 when already registered or
1496
+ * the name is taken; the player should `login` instead.
1497
+ */
1498
+ claim(username, password) {
1499
+ return this.withFallback(() => this.rest.call("claimGuestAccount", {
1500
+ username,
1501
+ password
1502
+ }), () => this.signInLocally({
1503
+ id: 0,
1504
+ username,
1505
+ guest: false
1506
+ }));
1507
+ }
1508
+ /** Create an account and sign in. (Offline: a local user is stored.) */
1509
+ signup(username, password) {
1510
+ return this.withFallback(() => this.rest.call("signUpWithPassword", {
1511
+ username,
1512
+ password
1513
+ }), () => this.signInLocally({
1514
+ id: 0,
1515
+ username
1516
+ }));
1517
+ }
1518
+ /** Sign in. (Offline: a local user is stored so signed-in flows work.) */
1519
+ login(username, password) {
1520
+ return this.withFallback(() => this.rest.call("logInWithPassword", {
1521
+ username,
1522
+ password
1523
+ }), () => this.signInLocally({
1524
+ id: 0,
1525
+ username
1526
+ }));
1527
+ }
1528
+ /** Sign out (ends the session shared by every game). */
1529
+ logout() {
1530
+ return this.withFallback(() => this.rest.call("logOut"), () => {
1531
+ this.store.clearUser();
1532
+ return { ok: true };
1533
+ });
1534
+ }
1535
+ signInLocally(user) {
1536
+ this.store.setUser(user);
1537
+ return { user };
1538
+ }
1539
+ /** This game's profile for the current user, or `profile: null`. `TData` is the game's own data type. */
1540
+ getProfile() {
1541
+ return this.withFallback(async () => ({ profile: typedProfile((await this.rest.call("getGameProfile", this.gameFields())).profile) }), () => ({ profile: typedProfile(this.store.getProfile()) }));
1542
+ }
1543
+ /** Create or update this game's profile for the current user. */
1544
+ setProfile(patch) {
1545
+ const queued = {};
1546
+ if (patch.displayName !== void 0) queued.displayName = patch.displayName;
1547
+ if (patch.data !== void 0) queued.data = toStoredJson(patch.data);
1548
+ return this.mutateQueued({
1549
+ kind: "setProfile",
1550
+ patch: queued
1551
+ }, () => {
1552
+ const stored = this.store.setProfile(this.slug ?? "app", patch.displayName ?? null, queued.data ?? {});
1553
+ return { profile: {
1554
+ ...stored,
1555
+ data: toGameData(stored.data)
1556
+ } };
1557
+ });
1558
+ }
1559
+ /** Metadata for every save slot (no payloads), newest first. */
1560
+ listSaves() {
1561
+ return this.withFallback(() => this.rest.call("listSaves", this.gameFields()), () => ({ saves: this.store.listSaves() }));
1562
+ }
1563
+ /** One slot's contents, or `save: null` when the slot is empty. `TData` is the game's own save type. */
1564
+ getSave(slot) {
1565
+ return this.withFallback(async () => ({ save: typedSave((await this.rest.call("getSave", {
1566
+ ...this.gameFields(),
1567
+ slot
1568
+ })).save) }), () => ({ save: typedSave(this.store.getSave(slot)) }));
1569
+ }
1570
+ /** Create or overwrite a save slot. `saveData` is any JSON up to 64 KB. */
1571
+ putSave(slot, saveData, label) {
1572
+ const stored = toStoredJson(saveData);
1573
+ return this.mutateQueued({
1574
+ kind: "putSave",
1575
+ slot,
1576
+ data: stored,
1577
+ label: label ?? null
1578
+ }, () => ({
1579
+ ok: true,
1580
+ updatedAt: this.store.putSave(slot, stored, label ?? null)
1581
+ }));
1582
+ }
1583
+ /** Delete a save slot. */
1584
+ deleteSave(slot) {
1585
+ return this.mutateQueued({
1586
+ kind: "deleteSave",
1587
+ slot
1588
+ }, () => {
1589
+ this.store.deleteSave(slot);
1590
+ return { ok: true };
1591
+ });
1592
+ }
1593
+ /** Numeric stats for the current user, or another player's via `userId`. */
1594
+ getStats(userId) {
1595
+ return this.withFallback(() => this.rest.call("getGameStats", {
1596
+ ...this.gameFields(),
1597
+ ...userId === void 0 ? {} : { user: userId }
1598
+ }), () => ({ stats: this.store.getStats() }));
1599
+ }
1600
+ /** Overwrite a stat. */
1601
+ setStat(key, value) {
1602
+ return this.mutateQueued({
1603
+ kind: "setStat",
1604
+ key,
1605
+ value
1606
+ }, () => ({
1607
+ key,
1608
+ value: this.store.updateStat(key, value, "set")
1609
+ }));
1610
+ }
1611
+ /** Add to a stat (creating it at `amount` when absent). */
1612
+ incrStat(key, amount = 1) {
1613
+ return this.mutateQueued({
1614
+ kind: "incrStat",
1615
+ key,
1616
+ amount
1617
+ }, () => ({
1618
+ key,
1619
+ value: this.store.updateStat(key, amount, "incr")
1620
+ }));
1621
+ }
1622
+ /** Keep the larger of the stored and the given value. */
1623
+ maxStat(key, value) {
1624
+ return this.mutateQueued({
1625
+ kind: "maxStat",
1626
+ key,
1627
+ value
1628
+ }, () => ({
1629
+ key,
1630
+ value: this.store.updateStat(key, value, "max")
1631
+ }));
1632
+ }
1633
+ /** Submit a score. The board is created on first use; only a player's best score sticks. */
1634
+ submitScore(boardKey, score, opts = {}) {
1635
+ const queued = {
1636
+ ...opts.title === void 0 ? {} : { title: opts.title },
1637
+ ...opts.sort === void 0 ? {} : { sort: opts.sort },
1638
+ ...opts.meta === void 0 ? {} : { meta: toStoredJson(opts.meta) }
1639
+ };
1640
+ return this.mutateQueued({
1641
+ kind: "submitScore",
1642
+ key: boardKey,
1643
+ score,
1644
+ opts: queued
1645
+ }, () => ({
1646
+ ...this.store.submitScore(boardKey, score, queued),
1647
+ rank: 1
1648
+ }));
1649
+ }
1650
+ /** One board's ranked entries (top `limit`, default 50). Offline: only the player's own best. */
1651
+ getLeaderboard(boardKey, limit = 50) {
1652
+ return this.withFallback(() => this.rest.call("getLeaderboard", {
1653
+ ...this.gameFields(),
1654
+ key: boardKey,
1655
+ limit
1656
+ }), () => {
1657
+ const local = this.store.getBoard(boardKey);
1658
+ if (local === null) return {
1659
+ board: {
1660
+ key: boardKey,
1661
+ title: boardKey,
1662
+ sortDir: "desc"
1663
+ },
1664
+ entries: []
1665
+ };
1666
+ const user = this.store.getUser();
1667
+ return {
1668
+ board: local.board,
1669
+ entries: [{
1670
+ rank: 1,
1671
+ userId: user?.id ?? 0,
1672
+ username: user?.username ?? "you",
1673
+ score: local.best,
1674
+ meta: null
1675
+ }]
1676
+ };
1677
+ });
1678
+ }
1679
+ /** The boards this game owns (keys and titles, no entries). */
1680
+ listLeaderboards() {
1681
+ return this.withFallback(() => this.rest.call("listGameLeaderboards", this.gameFields()), () => ({ boards: this.store.listBoards() }));
1682
+ }
1683
+ };
1684
+ //#endregion
1685
+ //#region src/core/offline/favorites.ts
1686
+ /**
1687
+ * Starred games are site-wide, not per game, so they live under one global
1688
+ * key (`hub:favorites`, the v1 client's) rather than in a game's cache. The
1689
+ * list mirrors the hub's so the menu still shows it offline.
1690
+ */
1691
+ /** The key of the mirrored favorites list. */
1692
+ const FAVORITES_KEY = "hub:favorites";
1693
+ /** The mirrored favorite slugs (empty when none or unreadable). */
1694
+ function readLocalFavorites() {
1695
+ const text = createPageStore().read(FAVORITES_KEY);
1696
+ return text === null ? [] : stringsIn(parseJsonText(text));
1697
+ }
1698
+ /** Mirror the hub's favorites list. */
1699
+ function writeLocalFavorites(games) {
1700
+ createPageStore().write(FAVORITES_KEY, JSON.stringify(games));
1701
+ }
1702
+ //#endregion
1703
+ //#region src/core/hub-economy.ts
1704
+ /**
1705
+ * Analytics, the catalog, favorites, records, inventory and trades. Analytics
1706
+ * calls are fire-and-forget and never throw (telemetry must not break a game);
1707
+ * offline they are buffered and sent when the hub is back. Method names are
1708
+ * the v1 client's, unchanged.
1709
+ */
1710
+ /** Analytics, catalog, favorites, records, inventory and trades. */
1711
+ var HubEconomy = class extends HubProgress {
1712
+ /** Record a "play". Call once when the game starts. */
1713
+ recordPlay() {
1714
+ return this.event("play");
1715
+ }
1716
+ /** Record a "heartbeat". Call periodically while the game is active. */
1717
+ heartbeat() {
1718
+ return this.event("heartbeat");
1719
+ }
1720
+ /** Record a custom event. Never throws; offline, events are buffered and sent on reconnect. */
1721
+ async event(type) {
1722
+ if (this.slug === null) return;
1723
+ if (this.isOffline) {
1724
+ this.store.bufferEvent(type);
1725
+ this.notifyStatus();
1726
+ return;
1727
+ }
1728
+ try {
1729
+ await this.rest.call("recordGameEvent", {
1730
+ ...this.gameFields(),
1731
+ type
1732
+ });
1733
+ } catch (failure) {
1734
+ if (failure instanceof HubError && failure.isOffline) {
1735
+ this.setReachable(false);
1736
+ this.store.bufferEvent(type);
1737
+ }
1738
+ }
1739
+ }
1740
+ /** This game's play counts. */
1741
+ analytics() {
1742
+ return this.withFallback(() => this.rest.call("getGameAnalytics", this.gameFields()), () => ({
1743
+ slug: this.slug ?? "app",
1744
+ totalPlays: 0,
1745
+ uniquePlayers: 0,
1746
+ activeNow: 0
1747
+ }));
1748
+ }
1749
+ /** Catalog-wide play counts with a per-game breakdown. */
1750
+ globalAnalytics() {
1751
+ return this.withFallback(() => this.rest.call("getSiteAnalytics"), () => ({
1752
+ global: {
1753
+ totalPlays: 0,
1754
+ uniquePlayers: 0,
1755
+ activeNow: 0
1756
+ },
1757
+ games: []
1758
+ }));
1759
+ }
1760
+ /** The listed games, for "jump to another game" navigation. */
1761
+ listGames() {
1762
+ return this.withFallback(() => this.rest.call("listGames"), () => ({ games: [] }));
1763
+ }
1764
+ /** Every board of every game, each with its top `top` entries (default 5). */
1765
+ allLeaderboards(top = 5) {
1766
+ return this.withFallback(() => this.rest.call("listTopOfEveryLeaderboard", { top }), () => ({ games: [] }));
1767
+ }
1768
+ /**
1769
+ * The starred games (slugs), shown first in the menu's game list. Kept per
1770
+ * account by the hub and mirrored to `localStorage`, so it shows offline.
1771
+ */
1772
+ favorites() {
1773
+ return this.withFallback(async () => {
1774
+ const reply = await this.rest.call("listFavoriteGames");
1775
+ writeLocalFavorites(reply.games);
1776
+ return reply;
1777
+ }, () => ({ games: readLocalFavorites() }));
1778
+ }
1779
+ /** Star (`isOn = true`) or unstar a game. Needs a connection. */
1780
+ async setFavorite(slug, isOn) {
1781
+ const reply = await this.rest.call("setGameFavorite", {
1782
+ slug,
1783
+ on: isOn
1784
+ });
1785
+ writeLocalFavorites(reply.games);
1786
+ return reply;
1787
+ }
1788
+ /** The player's leaderboard standings, grouped by game (every game, or one via `forSlug`). Empty offline or signed out. */
1789
+ myRecords(forSlug) {
1790
+ return this.withFallback(() => this.rest.call("listMyLeaderboardRecords", forSlug === void 0 || forSlug === "" ? {} : { game: forSlug }), () => ({ games: [] }));
1791
+ }
1792
+ /** This game's inventory and coin balance for the current user. */
1793
+ getInventory() {
1794
+ return this.withFallback(() => this.rest.call("getMyInventory", this.gameFields()), () => ({
1795
+ items: [],
1796
+ coins: 0
1797
+ }));
1798
+ }
1799
+ /** Another player's available holdings (to compose a trade). */
1800
+ playerInventory(userId) {
1801
+ return this.withFallback(() => this.rest.call("getPlayerInventory", {
1802
+ ...this.gameFields(),
1803
+ userId
1804
+ }), () => ({
1805
+ items: [],
1806
+ coins: 0
1807
+ }));
1808
+ }
1809
+ /** Add goods to the player (gameplay rewards); answers the new holdings. */
1810
+ grant(bundle) {
1811
+ return this.rest.call("grantInventoryItems", {
1812
+ ...this.gameFields(),
1813
+ ...bundle
1814
+ });
1815
+ }
1816
+ /** An atomic swap with the house: remove `take`, add `give`. */
1817
+ exchange(take, give) {
1818
+ return this.rest.call("exchangeInventoryItems", {
1819
+ ...this.gameFields(),
1820
+ ...take === void 0 ? {} : { take },
1821
+ ...give === void 0 ? {} : { give }
1822
+ });
1823
+ }
1824
+ /** Sell items to the house for coins. */
1825
+ sell(items, coins) {
1826
+ return this.exchange({ items }, { coins });
1827
+ }
1828
+ /** Buy items from the house with coins. */
1829
+ buy(coins, items) {
1830
+ return this.exchange({ coins }, { items });
1831
+ }
1832
+ /** Wipe the player's progress in this game (saves, stats, inventory and coins). */
1833
+ resetGame() {
1834
+ return this.rest.call("resetMyGameProgress", this.gameFields());
1835
+ }
1836
+ /** Players this player can trade with in this game. */
1837
+ listPlayers() {
1838
+ return this.withFallback(() => this.rest.call("listTradePartners", this.gameFields()), () => ({ players: [] }));
1839
+ }
1840
+ /** The player's trades (`box`: `incoming` or `outgoing`; `status` filters). */
1841
+ listTrades(opts = {}) {
1842
+ return this.withFallback(() => this.rest.call("listMyTrades", {
1843
+ ...this.gameFields(),
1844
+ ...opts
1845
+ }), () => ({ offers: [] }));
1846
+ }
1847
+ /** Offer a trade to another player (by id or name). */
1848
+ createTrade(toUser, give, want, opts = {}) {
1849
+ return this.rest.call("createTrade", {
1850
+ ...this.gameFields(),
1851
+ toUser: String(toUser),
1852
+ ...give === void 0 ? {} : { give },
1853
+ ...want === void 0 ? {} : { want },
1854
+ ...opts
1855
+ });
1856
+ }
1857
+ /** Accept a trade offered to the player. */
1858
+ acceptTrade(id) {
1859
+ return this.rest.call("acceptTrade", {
1860
+ ...this.gameFields(),
1861
+ id: Number(id)
1862
+ });
1863
+ }
1864
+ /** Decline a trade offered to the player. */
1865
+ declineTrade(id) {
1866
+ return this.rest.call("declineTrade", {
1867
+ ...this.gameFields(),
1868
+ id: Number(id)
1869
+ });
1870
+ }
1871
+ /** Cancel a trade the player offered. */
1872
+ cancelTrade(id) {
1873
+ return this.rest.call("cancelTrade", {
1874
+ ...this.gameFields(),
1875
+ id: Number(id)
1876
+ });
1877
+ }
1878
+ /** Answer a trade with a counter-offer. */
1879
+ counterTrade(id, give, want, opts = {}) {
1880
+ return this.rest.call("counterTradeOffer", {
1881
+ ...this.gameFields(),
1882
+ id: Number(id),
1883
+ ...give === void 0 ? {} : { give },
1884
+ ...want === void 0 ? {} : { want },
1885
+ ...opts
1886
+ });
1887
+ }
1888
+ };
1889
+ //#endregion
1890
+ //#region src/core/menu-control.ts
1891
+ /**
1892
+ * `hub.menu`: show or hide the injected hub menu button. The menu
1893
+ * (`/_hub/menu.js`) may load before or after the game, and the game may
1894
+ * bundle its own client copy, so the shared state is a window global and a
1895
+ * window event rather than an object:
1896
+ *
1897
+ * - `window.__HUB_MENU_VISIBLE__` (boolean; absent means visible);
1898
+ * - `hub:menu-visibility` (`CustomEvent`, detail `{ visible }`).
1899
+ *
1900
+ * The menu reads the global on boot and listens for the event.
1901
+ */
1902
+ /** The page's menu control (one per page; every client copy shares the window state). */
1903
+ const hubMenu = {
1904
+ setVisible(isVisible) {
1905
+ if (!isBrowserPage()) return;
1906
+ const wasVisible = window.__HUB_MENU_VISIBLE__;
1907
+ if (wasVisible === isVisible || wasVisible === void 0 && isVisible) return;
1908
+ window.__HUB_MENU_VISIBLE__ = isVisible;
1909
+ emitWindowEvent("hub:menu-visibility", { visible: isVisible });
1910
+ },
1911
+ show() {
1912
+ hubMenu.setVisible(true);
1913
+ },
1914
+ hide() {
1915
+ hubMenu.setVisible(false);
1916
+ },
1917
+ get visible() {
1918
+ return !isBrowserPage() || window.__HUB_MENU_VISIBLE__ !== false;
1919
+ }
1920
+ };
1921
+ //#endregion
1922
+ //#region src/core/ticket.ts
1923
+ /**
1924
+ * The own-server ticket request (`hub.mp.ticket()`): a 60-second identity
1925
+ * ticket for this game's own server, fetched straight from the hub. It must
1926
+ * not depend on the social runtime: on the first visit after a deploy the
1927
+ * service worker can still serve an older menu that never loads it. When the
1928
+ * page has no session yet, a guest one is created and the request retried
1929
+ * once.
1930
+ */
1931
+ /** A REST client for the page's API base that uses the global fetch at call time. */
1932
+ function pageRestClient() {
1933
+ return createRestClient({
1934
+ apiBase: detectApiBase(),
1935
+ fetch: (input, init) => globalThis.fetch(input, init)
1936
+ });
1937
+ }
1938
+ /** One ticket request; a transport failure is an {@link OfflineError}. */
1939
+ async function requestTicket(rest, slug) {
1940
+ try {
1941
+ return (await rest.call("getGameServerTicket", { slug })).ticket;
1942
+ } catch (failure) {
1943
+ throw failure instanceof HubError && failure.isOffline ? new OfflineError() : failure;
1944
+ }
1945
+ }
1946
+ /** A ticket for this page's game; signs in a guest first when the page has no session. */
1947
+ async function fetchGameServerTicket() {
1948
+ const slug = detectSlug();
1949
+ if (slug === null) throw new HubError("Hub: could not determine the game slug.");
1950
+ const rest = pageRestClient();
1951
+ try {
1952
+ return await requestTicket(rest, slug);
1953
+ } catch (failure) {
1954
+ if (!(failure instanceof HubError) || failure.status !== 401) throw failure;
1955
+ await rest.call("ensureGuestSession");
1956
+ return requestTicket(rest, slug);
1957
+ }
1958
+ }
1959
+ //#endregion
1960
+ //#region src/core/version/semver.ts
1961
+ const VERSION_PATTERN = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
1962
+ /** Parse a version (`2`, `2.1`, `2.1.0`, `2.1.0-next.3`); `undefined` when it is not one. */
1963
+ function parseVersion(text) {
1964
+ const match = VERSION_PATTERN.exec(text.trim());
1965
+ if (match === null) return;
1966
+ const [, major = "0", minor = "0", patch = "0", prerelease] = match;
1967
+ return {
1968
+ release: [
1969
+ Number(major),
1970
+ Number(minor),
1971
+ Number(patch)
1972
+ ],
1973
+ prerelease: prerelease === void 0 ? [] : prerelease.split(".")
1974
+ };
1975
+ }
1976
+ /** Compare two prerelease identifiers: numbers by value and before words, words as text. */
1977
+ function compareIdentifiers(first, second) {
1978
+ const isFirstNumeric = /^\d+$/.test(first);
1979
+ const isSecondNumeric = /^\d+$/.test(second);
1980
+ if (isFirstNumeric && isSecondNumeric) return Number(first) - Number(second);
1981
+ if (isFirstNumeric !== isSecondNumeric) return isFirstNumeric ? -1 : 1;
1982
+ return first < second ? -1 : first > second ? 1 : 0;
1983
+ }
1984
+ /** Negative when `first` is older, positive when newer, 0 when equal. */
1985
+ function compareVersions(first, second) {
1986
+ for (let i = 0; i < 3; i += 1) {
1987
+ const difference = (first.release[i] ?? 0) - (second.release[i] ?? 0);
1988
+ if (difference !== 0) return difference;
1989
+ }
1990
+ if (first.prerelease.length === 0 || second.prerelease.length === 0) return second.prerelease.length - first.prerelease.length;
1991
+ const length = Math.max(first.prerelease.length, second.prerelease.length);
1992
+ for (let i = 0; i < length; i += 1) {
1993
+ const firstIdentifier = first.prerelease[i];
1994
+ const secondIdentifier = second.prerelease[i];
1995
+ if (firstIdentifier === void 0 || secondIdentifier === void 0) return firstIdentifier === void 0 ? -1 : 1;
1996
+ const difference = compareIdentifiers(firstIdentifier, secondIdentifier);
1997
+ if (difference !== 0) return difference;
1998
+ }
1999
+ return 0;
2000
+ }
2001
+ const COMPARATOR_PATTERN = /^(>=|<=|>|<|=)?\s*(.+)$/;
2002
+ /** Whether `version` meets one comparator (`>=2.0.0`); a malformed comparator never holds. */
2003
+ function meetsComparator(version, comparator) {
2004
+ const match = COMPARATOR_PATTERN.exec(comparator);
2005
+ const bound = match?.[2] === void 0 ? void 0 : parseVersion(match[2]);
2006
+ if (match === null || bound === void 0) return false;
2007
+ const order = compareVersions(version, bound);
2008
+ switch (match[1]) {
2009
+ case ">=": return order >= 0;
2010
+ case "<=": return order <= 0;
2011
+ case ">": return order > 0;
2012
+ case "<": return order < 0;
2013
+ default: return order === 0;
2014
+ }
2015
+ }
2016
+ /** Whether `versionText` is in `range`; a malformed version or empty range never is. */
2017
+ function satisfiesRange(versionText, range) {
2018
+ const version = parseVersion(versionText);
2019
+ if (version === void 0) return false;
2020
+ return range.split("||").some((alternative) => {
2021
+ const comparators = alternative.trim().split(/\s+/).filter((comparator) => comparator !== "");
2022
+ return comparators.length > 0 && comparators.every((comparator) => meetsComparator(version, comparator));
2023
+ });
2024
+ }
2025
+ //#endregion
2026
+ //#region src/core/version/hub-version.ts
2027
+ /** How `version` fares against a hub's answer (`null`: a hub from before versioning). */
2028
+ function clientSupport(hubVersion, version = HUB_CLIENT_VERSION) {
2029
+ if (hubVersion === null) return "unknown";
2030
+ if (!satisfiesRange(version, hubVersion.supported)) return "unsupported";
2031
+ return satisfiesRange(version, hubVersion.deprecated) ? "deprecated" : "supported";
2032
+ }
2033
+ const versionsByApiBase = /* @__PURE__ */ new Map();
2034
+ /**
2035
+ * The hub's version, fetched once per page and API base. Resolves `null` for
2036
+ * a hub from before versioning (it has no `/version` route); a failed
2037
+ * request is not cached, so a later call tries again.
2038
+ */
2039
+ function readHubVersion(rest) {
2040
+ const cached = versionsByApiBase.get(rest.apiBase);
2041
+ if (cached !== void 0) return cached;
2042
+ const pending = fetchHubVersion(rest);
2043
+ versionsByApiBase.set(rest.apiBase, pending);
2044
+ return pending;
2045
+ }
2046
+ /** One `GET /version`; `null` when the hub has no such route. */
2047
+ async function fetchHubVersion(rest) {
2048
+ try {
2049
+ return await rest.call("getHubVersion");
2050
+ } catch (failure) {
2051
+ if (failure instanceof HubError && failure.code === "unsupported_by_hub") return null;
2052
+ versionsByApiBase.delete(rest.apiBase);
2053
+ throw failure;
2054
+ }
2055
+ }
2056
+ //#endregion
2057
+ //#region src/core/hub.ts
2058
+ /** The hub client. Use the ready-made {@link hub}, or `createHub()` for another slug or API base. */
2059
+ var Hub = class extends HubEconomy {
2060
+ constructor(..._args) {
2061
+ super(..._args);
2062
+ _defineProperty(this, "clientVersion", HUB_CLIENT_VERSION);
2063
+ }
2064
+ /**
2065
+ * The hub's version and the client versions it supports (`GET /_api/version`),
2066
+ * read once per page. `null` for a hub from before versioning.
2067
+ */
2068
+ hubVersion() {
2069
+ return readHubVersion(this.rest);
2070
+ }
2071
+ /** How the hub regards this client: `supported`, `deprecated`, `unsupported`, or `unknown` (an older hub). */
2072
+ async clientSupport() {
2073
+ return clientSupport(await this.hubVersion());
2074
+ }
2075
+ /**
2076
+ * The input system (keyboard, touch and gamepad): `hub.input.define({ ... })`,
2077
+ * `hub.input.value('jump')`. Page-wide; remaps are saved locally and to the
2078
+ * reserved `__controls` save slot.
2079
+ */
2080
+ get input() {
2081
+ return getInput({
2082
+ slug: this.slug,
2083
+ saveToCloud: (overrides) => {
2084
+ this.putSave("__controls", overrides).catch(() => void 0);
2085
+ }
2086
+ });
2087
+ }
2088
+ /** Hub UI covering the game (menu, dialogs, chat): open/close events and `autoPause`. Page-wide. */
2089
+ get overlay() {
2090
+ return getUiStack();
2091
+ }
2092
+ /** Show or hide the injected hub menu button (page-wide). */
2093
+ get menu() {
2094
+ return hubMenu;
2095
+ }
2096
+ /**
2097
+ * Daily challenges: call `hub.daily.define({ play })` once at load and
2098
+ * `hub.daily.complete({ ... })` on finish. Page-wide, shared with the
2099
+ * injected menu, which can start a clicked day on the same instance.
2100
+ */
2101
+ get daily() {
2102
+ return getDaily(this.dailyHost());
2103
+ }
2104
+ /** Tell friends what the player is doing in this game. */
2105
+ get presence() {
2106
+ return presenceForwarder;
2107
+ }
2108
+ /** Friends, players and the hub's social dialogs. */
2109
+ get social() {
2110
+ return socialForwarder;
2111
+ }
2112
+ /** Invites, launches, the leave guard and own-server tickets. */
2113
+ get mp() {
2114
+ return multiplayerForwarder;
2115
+ }
2116
+ /** Hub relay rooms, for games without a server. */
2117
+ get rooms() {
2118
+ return roomsForwarder;
2119
+ }
2120
+ /** Toast behaviour during intense play. */
2121
+ get notify() {
2122
+ return notifyForwarder;
2123
+ }
2124
+ /** Races: a single-player game becomes multiplayer by racing. */
2125
+ get race() {
2126
+ return raceForwarder;
2127
+ }
2128
+ /** What the daily system needs from this client: its REST calls and window events. */
2129
+ dailyHost() {
2130
+ return {
2131
+ slug: this.slug,
2132
+ status: () => this.rest.call("getGameDailyChallenges", this.gameFields()),
2133
+ all: () => this.rest.call("listDailyChallengesForAllGames"),
2134
+ start: (day) => this.rest.call("startDailyChallenge", {
2135
+ ...this.gameFields(),
2136
+ day
2137
+ }),
2138
+ complete: (day, token, meta) => this.rest.call("completeDailyChallenge", {
2139
+ ...this.gameFields(),
2140
+ day,
2141
+ token,
2142
+ meta
2143
+ }),
2144
+ emit: (eventName, detail) => {
2145
+ emitWindowEvent(eventName, detail);
2146
+ }
2147
+ };
2148
+ }
2149
+ };
2150
+ linkRealtimeToHub({
2151
+ toHubError,
2152
+ createOfflineError: (message) => new OfflineError(message),
2153
+ getGameServerTicket: fetchGameServerTicket
2154
+ });
2155
+ /** A ready-to-use client, with the slug and API base detected from the page. */
2156
+ const hub = new Hub();
2157
+ /** Create a client for another slug or API base, or with other offline behaviour. */
2158
+ function createHub(options = {}) {
2159
+ return new Hub(options);
2160
+ }
2161
+ //#endregion
2162
+ export { ContentRejectedError as a, UNSUPPORTED_BY_HUB as c, CLIENT_VERSION_HEADER as i, createHub as n, HubError as o, hub as r, OfflineError as s, Hub as t };
2163
+
2164
+ //# sourceMappingURL=src.js.map