@diffenderfer-games/hub 2.0.0-next.0 → 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 (98) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -21
  3. package/README.md +191 -139
  4. package/dist/doctor/cli.mjs +49 -19
  5. package/dist/doctor/cli.mjs.map +1 -1
  6. package/dist/shared/client-version.gen.js +1 -1
  7. package/dist/shared/daily.js +1 -1
  8. package/dist/shared/globals.js +8 -3
  9. package/dist/shared/globals.js.map +1 -1
  10. package/dist/shared/index.d.ts +1 -1
  11. package/package.json +89 -89
  12. package/src/auto.ts +39 -39
  13. package/src/core/connection.ts +299 -299
  14. package/src/core/errors.ts +147 -147
  15. package/src/core/game-data.ts +38 -38
  16. package/src/core/hub-economy.ts +179 -179
  17. package/src/core/hub-progress.ts +205 -205
  18. package/src/core/hub.ts +131 -131
  19. package/src/core/menu-control.ts +61 -61
  20. package/src/core/offline/events.ts +50 -50
  21. package/src/core/offline/favorites.ts +21 -21
  22. package/src/core/offline/local-store.ts +261 -261
  23. package/src/core/offline/queue.ts +171 -171
  24. package/src/core/page.ts +74 -74
  25. package/src/core/rest/endpoint-types.ts +22 -22
  26. package/src/core/rest/rest-client.ts +152 -152
  27. package/src/core/storage/json-guards.ts +74 -74
  28. package/src/core/storage/key-value.ts +66 -66
  29. package/src/core/ticket.ts +43 -43
  30. package/src/core/types.ts +134 -134
  31. package/src/core/version/client-version.gen.ts +1 -1
  32. package/src/core/version/hub-version.ts +62 -62
  33. package/src/core/version/semver.ts +97 -97
  34. package/src/daily/daily-system.ts +161 -161
  35. package/src/daily/page-daily.ts +36 -36
  36. package/src/daily/types.ts +67 -67
  37. package/src/daily.ts +10 -10
  38. package/src/doctor/checks.ts +78 -76
  39. package/src/doctor/cli.ts +98 -106
  40. package/src/doctor/hub-version-fetch.ts +43 -0
  41. package/src/doctor/tsconfig.json +12 -12
  42. package/src/globals.ts +115 -110
  43. package/src/index.ts +50 -50
  44. package/src/input/device-state.ts +146 -146
  45. package/src/input/edges.ts +55 -55
  46. package/src/input/gamepad.ts +61 -61
  47. package/src/input/input-events.ts +32 -32
  48. package/src/input/input-registry.ts +164 -164
  49. package/src/input/input-system.ts +393 -393
  50. package/src/input/legacy-controls.ts +64 -64
  51. package/src/input/nav-state.ts +191 -191
  52. package/src/input/navigator.ts +171 -171
  53. package/src/input/on-screen-controls.ts +101 -101
  54. package/src/input/page-control.ts +43 -43
  55. package/src/input/page-input.ts +193 -193
  56. package/src/input/player-remaps.ts +77 -77
  57. package/src/input/source-values.ts +116 -116
  58. package/src/input/spatial.ts +47 -47
  59. package/src/input/types.ts +331 -331
  60. package/src/input/zero-config.ts +177 -177
  61. package/src/input.ts +22 -22
  62. package/src/mp.ts +10 -10
  63. package/src/overlay/controls-panel.ts +163 -163
  64. package/src/overlay/dom.ts +46 -46
  65. package/src/overlay/layout-clusters.ts +162 -162
  66. package/src/overlay/layout-geometry.ts +136 -136
  67. package/src/overlay/layout.ts +203 -203
  68. package/src/overlay/listener-scope.ts +23 -23
  69. package/src/overlay/on-screen-keyboard.ts +137 -137
  70. package/src/overlay/overlay.ts +208 -208
  71. package/src/overlay/styles.ts +9 -9
  72. package/src/overlay/virtual-controls.ts +262 -262
  73. package/src/race.ts +10 -10
  74. package/src/realtime/bridge.ts +121 -121
  75. package/src/realtime/cross-bundle.ts +29 -29
  76. package/src/realtime/fallback-notice.ts +48 -48
  77. package/src/realtime/hub-link.ts +65 -65
  78. package/src/realtime/latest-state.ts +33 -33
  79. package/src/realtime/multiplayer-forwarder.ts +28 -28
  80. package/src/realtime/multiplayer-types.ts +52 -52
  81. package/src/realtime/page-runtime.ts +60 -60
  82. package/src/realtime/race-forwarder.ts +59 -59
  83. package/src/realtime/race-types.ts +132 -132
  84. package/src/realtime/room-types.ts +125 -125
  85. package/src/realtime/rooms-forwarder.ts +79 -79
  86. package/src/realtime/runtime-api.ts +49 -49
  87. package/src/realtime/runtime-opener.ts +61 -61
  88. package/src/realtime/social-forwarder.ts +72 -72
  89. package/src/realtime/social-types.ts +78 -78
  90. package/src/realtime/types.ts +53 -53
  91. package/src/realtime/wire-types.ts +72 -72
  92. package/src/rooms.ts +10 -10
  93. package/src/social.ts +18 -18
  94. package/src/types.ts +13 -13
  95. package/src/uistack/page-stack.ts +45 -45
  96. package/src/uistack/play-suspension.ts +82 -82
  97. package/src/uistack/types.ts +76 -76
  98. package/src/uistack/ui-stack.ts +144 -144
@@ -1,261 +1,261 @@
1
- /**
2
- * The offline cache: what the client serves when the hub can't be reached,
3
- * and where queued writes and buffered events wait. Keys are the v1 client's
4
- * (`hub:user`, `hub:<slug>:save:<slot>`, `hub:<slug>:queue`, ...), so a page
5
- * upgraded from a vendored copy keeps that copy's cache and queues.
6
- */
7
- import type { Board, Json, SortDir, Stats } from '@diffenderfer-games/hub-contract';
8
- import { isJson, isNullableString, isNumberRecord, isRecord, optionalNumber, optionalString, parseJsonText } from '../storage/json-guards';
9
- import { createPageStore, type KeyValueStore } from '../storage/key-value';
10
- import type { Profile, Save, SaveMeta, SnapshotResponse, User } from '../types';
11
- import { appendEvent, readEvents, type BufferedEvent } from './events';
12
- import { betterScore, enqueueOp, readQueue, type QueueOp, type QueueOpInput, type QueuedScoreOptions } from './queue';
13
-
14
- /** The key of the locally known user, shared by every game (like the real session). */
15
- export const USER_KEY = 'hub:user';
16
-
17
- /** A board this player has a local best on. */
18
- export interface LocalBoard {
19
- /** The board. */
20
- readonly board: Board;
21
- /** This player's best score. */
22
- readonly best: number;
23
- }
24
-
25
- /** How a stat update combines with the stored value. */
26
- export type StatUpdateMode = 'set' | 'incr' | 'max';
27
-
28
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: a cached user read back from localStorage
29
- function readUser(stored: unknown): User | null {
30
- if (!isRecord(stored)) {
31
- return null;
32
- }
33
- const id = optionalNumber(stored.id);
34
- const username = optionalString(stored.username);
35
- if (id === undefined || username === undefined) {
36
- return null;
37
- }
38
- return typeof stored.guest === 'boolean' ? { id, username, guest: stored.guest } : { id, username };
39
- }
40
-
41
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: a cached save read back from localStorage
42
- function readSave(stored: unknown): Save | null {
43
- if (!isRecord(stored)) {
44
- return null;
45
- }
46
- const slot = optionalString(stored.slot);
47
- const updatedAt = optionalNumber(stored.updatedAt);
48
- if (slot === undefined || updatedAt === undefined || !isNullableString(stored.label) || !isJson(stored.data)) {
49
- return null;
50
- }
51
- return { slot, label: stored.label, data: stored.data, updatedAt };
52
- }
53
-
54
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: a cached profile read back from localStorage
55
- function readProfile(stored: unknown): Profile | null {
56
- if (!isRecord(stored)) {
57
- return null;
58
- }
59
- const game = optionalString(stored.game);
60
- const updatedAt = optionalNumber(stored.updatedAt);
61
- if (game === undefined || updatedAt === undefined || !isNullableString(stored.displayName) || !isJson(stored.data)) {
62
- return null;
63
- }
64
- return { game, displayName: stored.displayName, data: stored.data, updatedAt };
65
- }
66
-
67
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: a cached board read back from localStorage
68
- function readLocalBoard(stored: unknown): LocalBoard | null {
69
- if (!isRecord(stored) || !isRecord(stored.board)) {
70
- return null;
71
- }
72
- const key = optionalString(stored.board.key);
73
- const title = optionalString(stored.board.title);
74
- const sortDir = stored.board.sortDir === 'asc' || stored.board.sortDir === 'desc' ? stored.board.sortDir : undefined;
75
- const best = optionalNumber(stored.best);
76
- if (key === undefined || title === undefined || sortDir === undefined || best === undefined) {
77
- return null;
78
- }
79
- return { board: { key, title, sortDir }, best };
80
- }
81
-
82
- /** One game's offline cache, queue and event buffer. */
83
- export class LocalStore {
84
- private readonly prefix: string;
85
- private readonly store: KeyValueStore;
86
- private sequence = 0;
87
-
88
- /** The cache for game `slug` in `store` (default: `localStorage`, or memory without it). */
89
- constructor(slug: string, store: KeyValueStore = createPageStore()) {
90
- this.prefix = `hub:${slug}:`;
91
- this.store = store;
92
- }
93
-
94
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: JSON read back from storage, checked by each caller's guard
95
- private readStored(key: string): unknown {
96
- const text = this.store.read(key);
97
- return text === null ? undefined : parseJsonText(text);
98
- }
99
-
100
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: any JSON-serializable value the cache stores
101
- private writeStored(key: string, stored: unknown): void {
102
- this.store.write(key, JSON.stringify(stored));
103
- }
104
-
105
- /** The locally known user. */
106
- getUser(): User | null {
107
- return readUser(this.readStored(USER_KEY));
108
- }
109
-
110
- /** Remember the user (offline sign-in, or a snapshot). */
111
- setUser(user: User): void {
112
- this.writeStored(USER_KEY, user);
113
- }
114
-
115
- /** Forget the user (offline sign-out). */
116
- clearUser(): void {
117
- this.store.remove(USER_KEY);
118
- }
119
-
120
- /** This game's cached profile. */
121
- getProfile(): Profile | null {
122
- return readProfile(this.readStored(`${this.prefix}profile`));
123
- }
124
-
125
- /** Store this game's profile and return it. */
126
- setProfile(game: string, displayName: string | null, profileData: Json): Profile {
127
- const profile: Profile = { game, displayName, data: profileData, updatedAt: Date.now() };
128
- this.writeStored(`${this.prefix}profile`, profile);
129
- return profile;
130
- }
131
-
132
- /** Every cached save's metadata, newest first. */
133
- listSaves(): SaveMeta[] {
134
- return this.store.keys()
135
- .filter((key) => key.startsWith(`${this.prefix}save:`))
136
- .flatMap((key) => {
137
- const save = readSave(this.readStored(key));
138
- return save === null ? [] : [{ slot: save.slot, label: save.label, updatedAt: save.updatedAt }];
139
- })
140
- .sort((first, second) => second.updatedAt - first.updatedAt);
141
- }
142
-
143
- /** One cached save, or `null`. */
144
- getSave(slot: string): Save | null {
145
- return readSave(this.readStored(`${this.prefix}save:${slot}`));
146
- }
147
-
148
- /** Store a save and return its `updatedAt`. */
149
- putSave(slot: string, saveData: Json, label: string | null): number {
150
- const updatedAt = Date.now();
151
- this.writeStored(`${this.prefix}save:${slot}`, { slot, label, data: saveData, updatedAt });
152
- return updatedAt;
153
- }
154
-
155
- /** Forget a save. */
156
- deleteSave(slot: string): void {
157
- this.store.remove(`${this.prefix}save:${slot}`);
158
- }
159
-
160
- /** This game's cached stats. */
161
- getStats(): Stats {
162
- const stored = this.readStored(`${this.prefix}stats`);
163
- return isNumberRecord(stored) ? { ...stored } : {};
164
- }
165
-
166
- /** Apply one stat update and return the stat's new value. */
167
- updateStat(key: string, amount: number, mode: StatUpdateMode): number {
168
- const stats = this.getStats();
169
- const current = stats[key] ?? 0;
170
- const updated = mode === 'incr' ? current + amount : mode === 'max' ? Math.max(current, amount) : amount;
171
- this.writeStored(`${this.prefix}stats`, { ...stats, [key]: updated });
172
- return updated;
173
- }
174
-
175
- /** The boards this player has a local best on (offline boards only know the player's own best). */
176
- listBoards(): Board[] {
177
- return this.store.keys()
178
- .filter((key) => key.startsWith(`${this.prefix}lb:`))
179
- .flatMap((key) => {
180
- const local = readLocalBoard(this.readStored(key));
181
- return local === null ? [] : [local.board];
182
- });
183
- }
184
-
185
- /** One board's local best, or `null`. */
186
- getBoard(key: string): LocalBoard | null {
187
- return readLocalBoard(this.readStored(`${this.prefix}lb:${key}`));
188
- }
189
-
190
- /** Record a score locally, keeping the best one; returns the board and best. */
191
- submitScore(key: string, score: number, options: QueuedScoreOptions): LocalBoard {
192
- const existing = this.getBoard(key);
193
- const sortDir: SortDir = existing?.board.sortDir ?? (options.sort === 'asc' ? 'asc' : 'desc');
194
- const board: Board = existing?.board ?? { key, title: options.title !== undefined && options.title !== '' ? options.title : key, sortDir };
195
- const best = existing === null ? score : betterScore(existing.best, score, sortDir);
196
- this.writeStored(`${this.prefix}lb:${key}`, { board, best });
197
- return { board, best };
198
- }
199
-
200
- /** The queued writes, oldest first. */
201
- getQueue(): QueueOp[] {
202
- return readQueue(this.readStored(`${this.prefix}queue`));
203
- }
204
-
205
- /** Replace the queue. */
206
- setQueue(queue: readonly QueueOp[]): void {
207
- this.writeStored(`${this.prefix}queue`, queue);
208
- }
209
-
210
- /** How many writes are queued. */
211
- queueLength(): number {
212
- return this.getQueue().length;
213
- }
214
-
215
- /** Queue a write, collapsing it with a compatible queued one. */
216
- enqueue(input: QueueOpInput): void {
217
- this.sequence += 1;
218
- const now = Date.now();
219
- this.setQueue(enqueueOp(this.getQueue(), { ...input, id: `${String(now)}-${String(this.sequence)}`, ts: now }));
220
- }
221
-
222
- /** The buffered analytics events, oldest first. */
223
- getEvents(): BufferedEvent[] {
224
- return readEvents(this.readStored(`${this.prefix}events`));
225
- }
226
-
227
- /** Replace the event buffer. */
228
- setEvents(events: readonly BufferedEvent[]): void {
229
- this.writeStored(`${this.prefix}events`, events);
230
- }
231
-
232
- /** Buffer one event (capped; heartbeats are dropped first). */
233
- bufferEvent(type: string): void {
234
- this.setEvents(appendEvent(this.getEvents(), { type, ts: Date.now() }));
235
- }
236
-
237
- /** Fan a `/me/snapshot` reply out across every game's cache keys. */
238
- hydrate(snapshot: SnapshotResponse): void {
239
- if (snapshot.user !== null) {
240
- this.writeStored(USER_KEY, snapshot.user);
241
- }
242
- for (const [slug, game] of Object.entries(snapshot.games)) {
243
- const gamePrefix = `hub:${slug}:`;
244
- if (game.profile !== null) {
245
- this.writeStored(`${gamePrefix}profile`, game.profile);
246
- }
247
- for (const save of game.saves) {
248
- this.writeStored(`${gamePrefix}save:${save.slot}`, save);
249
- }
250
- this.writeStored(`${gamePrefix}stats`, game.stats);
251
- this.writeStored(`${gamePrefix}inventory`, game.inventory);
252
- }
253
- for (const gameRecords of snapshot.records) {
254
- for (const record of gameRecords.records) {
255
- this.writeStored(`hub:${gameRecords.slug}:lb:${record.key}`, { board: { key: record.key, title: record.title, sortDir: record.sortDir }, best: record.score });
256
- }
257
- }
258
- this.writeStored('hub:records', snapshot.records);
259
- this.writeStored('hub:leaderboards', snapshot.leaderboards);
260
- }
261
- }
1
+ /**
2
+ * The offline cache: what the client serves when the hub can't be reached,
3
+ * and where queued writes and buffered events wait. Keys are the v1 client's
4
+ * (`hub:user`, `hub:<slug>:save:<slot>`, `hub:<slug>:queue`, ...), so a page
5
+ * upgraded from a vendored copy keeps that copy's cache and queues.
6
+ */
7
+ import type { Board, Json, SortDir, Stats } from '@diffenderfer-games/hub-contract';
8
+ import { isJson, isNullableString, isNumberRecord, isRecord, optionalNumber, optionalString, parseJsonText } from '../storage/json-guards';
9
+ import { createPageStore, type KeyValueStore } from '../storage/key-value';
10
+ import type { Profile, Save, SaveMeta, SnapshotResponse, User } from '../types';
11
+ import { appendEvent, readEvents, type BufferedEvent } from './events';
12
+ import { betterScore, enqueueOp, readQueue, type QueueOp, type QueueOpInput, type QueuedScoreOptions } from './queue';
13
+
14
+ /** The key of the locally known user, shared by every game (like the real session). */
15
+ export const USER_KEY = 'hub:user';
16
+
17
+ /** A board this player has a local best on. */
18
+ export interface LocalBoard {
19
+ /** The board. */
20
+ readonly board: Board;
21
+ /** This player's best score. */
22
+ readonly best: number;
23
+ }
24
+
25
+ /** How a stat update combines with the stored value. */
26
+ export type StatUpdateMode = 'set' | 'incr' | 'max';
27
+
28
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: a cached user read back from localStorage
29
+ function readUser(stored: unknown): User | null {
30
+ if (!isRecord(stored)) {
31
+ return null;
32
+ }
33
+ const id = optionalNumber(stored.id);
34
+ const username = optionalString(stored.username);
35
+ if (id === undefined || username === undefined) {
36
+ return null;
37
+ }
38
+ return typeof stored.guest === 'boolean' ? { id, username, guest: stored.guest } : { id, username };
39
+ }
40
+
41
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: a cached save read back from localStorage
42
+ function readSave(stored: unknown): Save | null {
43
+ if (!isRecord(stored)) {
44
+ return null;
45
+ }
46
+ const slot = optionalString(stored.slot);
47
+ const updatedAt = optionalNumber(stored.updatedAt);
48
+ if (slot === undefined || updatedAt === undefined || !isNullableString(stored.label) || !isJson(stored.data)) {
49
+ return null;
50
+ }
51
+ return { slot, label: stored.label, data: stored.data, updatedAt };
52
+ }
53
+
54
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: a cached profile read back from localStorage
55
+ function readProfile(stored: unknown): Profile | null {
56
+ if (!isRecord(stored)) {
57
+ return null;
58
+ }
59
+ const game = optionalString(stored.game);
60
+ const updatedAt = optionalNumber(stored.updatedAt);
61
+ if (game === undefined || updatedAt === undefined || !isNullableString(stored.displayName) || !isJson(stored.data)) {
62
+ return null;
63
+ }
64
+ return { game, displayName: stored.displayName, data: stored.data, updatedAt };
65
+ }
66
+
67
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: a cached board read back from localStorage
68
+ function readLocalBoard(stored: unknown): LocalBoard | null {
69
+ if (!isRecord(stored) || !isRecord(stored.board)) {
70
+ return null;
71
+ }
72
+ const key = optionalString(stored.board.key);
73
+ const title = optionalString(stored.board.title);
74
+ const sortDir = stored.board.sortDir === 'asc' || stored.board.sortDir === 'desc' ? stored.board.sortDir : undefined;
75
+ const best = optionalNumber(stored.best);
76
+ if (key === undefined || title === undefined || sortDir === undefined || best === undefined) {
77
+ return null;
78
+ }
79
+ return { board: { key, title, sortDir }, best };
80
+ }
81
+
82
+ /** One game's offline cache, queue and event buffer. */
83
+ export class LocalStore {
84
+ private readonly prefix: string;
85
+ private readonly store: KeyValueStore;
86
+ private sequence = 0;
87
+
88
+ /** The cache for game `slug` in `store` (default: `localStorage`, or memory without it). */
89
+ constructor(slug: string, store: KeyValueStore = createPageStore()) {
90
+ this.prefix = `hub:${slug}:`;
91
+ this.store = store;
92
+ }
93
+
94
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: JSON read back from storage, checked by each caller's guard
95
+ private readStored(key: string): unknown {
96
+ const text = this.store.read(key);
97
+ return text === null ? undefined : parseJsonText(text);
98
+ }
99
+
100
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: any JSON-serializable value the cache stores
101
+ private writeStored(key: string, stored: unknown): void {
102
+ this.store.write(key, JSON.stringify(stored));
103
+ }
104
+
105
+ /** The locally known user. */
106
+ getUser(): User | null {
107
+ return readUser(this.readStored(USER_KEY));
108
+ }
109
+
110
+ /** Remember the user (offline sign-in, or a snapshot). */
111
+ setUser(user: User): void {
112
+ this.writeStored(USER_KEY, user);
113
+ }
114
+
115
+ /** Forget the user (offline sign-out). */
116
+ clearUser(): void {
117
+ this.store.remove(USER_KEY);
118
+ }
119
+
120
+ /** This game's cached profile. */
121
+ getProfile(): Profile | null {
122
+ return readProfile(this.readStored(`${this.prefix}profile`));
123
+ }
124
+
125
+ /** Store this game's profile and return it. */
126
+ setProfile(game: string, displayName: string | null, profileData: Json): Profile {
127
+ const profile: Profile = { game, displayName, data: profileData, updatedAt: Date.now() };
128
+ this.writeStored(`${this.prefix}profile`, profile);
129
+ return profile;
130
+ }
131
+
132
+ /** Every cached save's metadata, newest first. */
133
+ listSaves(): SaveMeta[] {
134
+ return this.store.keys()
135
+ .filter((key) => key.startsWith(`${this.prefix}save:`))
136
+ .flatMap((key) => {
137
+ const save = readSave(this.readStored(key));
138
+ return save === null ? [] : [{ slot: save.slot, label: save.label, updatedAt: save.updatedAt }];
139
+ })
140
+ .sort((first, second) => second.updatedAt - first.updatedAt);
141
+ }
142
+
143
+ /** One cached save, or `null`. */
144
+ getSave(slot: string): Save | null {
145
+ return readSave(this.readStored(`${this.prefix}save:${slot}`));
146
+ }
147
+
148
+ /** Store a save and return its `updatedAt`. */
149
+ putSave(slot: string, saveData: Json, label: string | null): number {
150
+ const updatedAt = Date.now();
151
+ this.writeStored(`${this.prefix}save:${slot}`, { slot, label, data: saveData, updatedAt });
152
+ return updatedAt;
153
+ }
154
+
155
+ /** Forget a save. */
156
+ deleteSave(slot: string): void {
157
+ this.store.remove(`${this.prefix}save:${slot}`);
158
+ }
159
+
160
+ /** This game's cached stats. */
161
+ getStats(): Stats {
162
+ const stored = this.readStored(`${this.prefix}stats`);
163
+ return isNumberRecord(stored) ? { ...stored } : {};
164
+ }
165
+
166
+ /** Apply one stat update and return the stat's new value. */
167
+ updateStat(key: string, amount: number, mode: StatUpdateMode): number {
168
+ const stats = this.getStats();
169
+ const current = stats[key] ?? 0;
170
+ const updated = mode === 'incr' ? current + amount : mode === 'max' ? Math.max(current, amount) : amount;
171
+ this.writeStored(`${this.prefix}stats`, { ...stats, [key]: updated });
172
+ return updated;
173
+ }
174
+
175
+ /** The boards this player has a local best on (offline boards only know the player's own best). */
176
+ listBoards(): Board[] {
177
+ return this.store.keys()
178
+ .filter((key) => key.startsWith(`${this.prefix}lb:`))
179
+ .flatMap((key) => {
180
+ const local = readLocalBoard(this.readStored(key));
181
+ return local === null ? [] : [local.board];
182
+ });
183
+ }
184
+
185
+ /** One board's local best, or `null`. */
186
+ getBoard(key: string): LocalBoard | null {
187
+ return readLocalBoard(this.readStored(`${this.prefix}lb:${key}`));
188
+ }
189
+
190
+ /** Record a score locally, keeping the best one; returns the board and best. */
191
+ submitScore(key: string, score: number, options: QueuedScoreOptions): LocalBoard {
192
+ const existing = this.getBoard(key);
193
+ const sortDir: SortDir = existing?.board.sortDir ?? (options.sort === 'asc' ? 'asc' : 'desc');
194
+ const board: Board = existing?.board ?? { key, title: options.title !== undefined && options.title !== '' ? options.title : key, sortDir };
195
+ const best = existing === null ? score : betterScore(existing.best, score, sortDir);
196
+ this.writeStored(`${this.prefix}lb:${key}`, { board, best });
197
+ return { board, best };
198
+ }
199
+
200
+ /** The queued writes, oldest first. */
201
+ getQueue(): QueueOp[] {
202
+ return readQueue(this.readStored(`${this.prefix}queue`));
203
+ }
204
+
205
+ /** Replace the queue. */
206
+ setQueue(queue: readonly QueueOp[]): void {
207
+ this.writeStored(`${this.prefix}queue`, queue);
208
+ }
209
+
210
+ /** How many writes are queued. */
211
+ queueLength(): number {
212
+ return this.getQueue().length;
213
+ }
214
+
215
+ /** Queue a write, collapsing it with a compatible queued one. */
216
+ enqueue(input: QueueOpInput): void {
217
+ this.sequence += 1;
218
+ const now = Date.now();
219
+ this.setQueue(enqueueOp(this.getQueue(), { ...input, id: `${String(now)}-${String(this.sequence)}`, ts: now }));
220
+ }
221
+
222
+ /** The buffered analytics events, oldest first. */
223
+ getEvents(): BufferedEvent[] {
224
+ return readEvents(this.readStored(`${this.prefix}events`));
225
+ }
226
+
227
+ /** Replace the event buffer. */
228
+ setEvents(events: readonly BufferedEvent[]): void {
229
+ this.writeStored(`${this.prefix}events`, events);
230
+ }
231
+
232
+ /** Buffer one event (capped; heartbeats are dropped first). */
233
+ bufferEvent(type: string): void {
234
+ this.setEvents(appendEvent(this.getEvents(), { type, ts: Date.now() }));
235
+ }
236
+
237
+ /** Fan a `/me/snapshot` reply out across every game's cache keys. */
238
+ hydrate(snapshot: SnapshotResponse): void {
239
+ if (snapshot.user !== null) {
240
+ this.writeStored(USER_KEY, snapshot.user);
241
+ }
242
+ for (const [slug, game] of Object.entries(snapshot.games)) {
243
+ const gamePrefix = `hub:${slug}:`;
244
+ if (game.profile !== null) {
245
+ this.writeStored(`${gamePrefix}profile`, game.profile);
246
+ }
247
+ for (const save of game.saves) {
248
+ this.writeStored(`${gamePrefix}save:${save.slot}`, save);
249
+ }
250
+ this.writeStored(`${gamePrefix}stats`, game.stats);
251
+ this.writeStored(`${gamePrefix}inventory`, game.inventory);
252
+ }
253
+ for (const gameRecords of snapshot.records) {
254
+ for (const record of gameRecords.records) {
255
+ this.writeStored(`hub:${gameRecords.slug}:lb:${record.key}`, { board: { key: record.key, title: record.title, sortDir: record.sortDir }, best: record.score });
256
+ }
257
+ }
258
+ this.writeStored('hub:records', snapshot.records);
259
+ this.writeStored('hub:leaderboards', snapshot.leaderboards);
260
+ }
261
+ }