@diffenderfer-games/hub 2.0.0-next.0 → 2.0.0-next.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +163 -0
- package/LICENSE +21 -21
- package/README.md +203 -139
- package/dist/auto.d.ts +8 -8
- package/dist/auto.js +3 -3
- package/dist/avatar-pixi.d.ts +165 -0
- package/dist/avatar-pixi.d.ts.map +1 -0
- package/dist/avatar-pixi.js +299 -0
- package/dist/avatar-pixi.js.map +1 -0
- package/dist/avatar-svg.d.ts +4 -0
- package/dist/avatar-svg.js +5 -0
- package/dist/avatar-three.d.ts +69 -0
- package/dist/avatar-three.d.ts.map +1 -0
- package/dist/avatar-three.js +49 -0
- package/dist/avatar-three.js.map +1 -0
- package/dist/avatar.d.ts +7 -0
- package/dist/avatar.js +500 -0
- package/dist/avatar.js.map +1 -0
- package/dist/daily.d.ts +2 -2
- package/dist/doctor/cli.mjs +119 -28
- package/dist/doctor/cli.mjs.map +1 -1
- package/dist/index.d.ts +8 -8
- package/dist/index.js +3 -3
- package/dist/input.d.ts +2 -2
- package/dist/input.js +2 -2
- package/dist/mp.d.ts +3 -3
- package/dist/race.d.ts +1 -1
- package/dist/rooms.d.ts +3 -3
- package/dist/rooms.js +1 -1
- package/dist/shared/animator.d.ts +104 -0
- package/dist/shared/animator.d.ts.map +1 -0
- package/dist/shared/art.js +1946 -0
- package/dist/shared/art.js.map +1 -0
- package/dist/shared/atlas.d.ts +138 -0
- package/dist/shared/atlas.d.ts.map +1 -0
- package/dist/shared/avatar.d.ts +123 -0
- package/dist/shared/avatar.d.ts.map +1 -0
- package/dist/shared/bridge.js +18 -4
- package/dist/shared/bridge.js.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.d.ts +145 -19
- package/dist/shared/daily.d.ts.map +1 -1
- package/dist/shared/daily.js +138 -25
- package/dist/shared/daily.js.map +1 -1
- package/dist/shared/defineProperty.js +39 -0
- package/dist/shared/describe.d.ts +32 -0
- package/dist/shared/describe.d.ts.map +1 -0
- package/dist/shared/describe.js +109 -0
- package/dist/shared/describe.js.map +1 -0
- package/dist/shared/globals.js +9 -42
- package/dist/shared/globals.js.map +1 -1
- package/dist/shared/index.d.ts +1122 -46
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/input.d.ts +74 -14
- package/dist/shared/input.d.ts.map +1 -1
- package/dist/shared/input.js +179 -25
- package/dist/shared/input.js.map +1 -1
- package/dist/shared/json-guards.js +48 -0
- package/dist/shared/json-guards.js.map +1 -0
- package/dist/shared/latest-state.js +1 -1
- package/dist/shared/live-canvas.js +62 -0
- package/dist/shared/live-canvas.js.map +1 -0
- package/dist/shared/mp.js +10 -2
- package/dist/shared/mp.js.map +1 -1
- package/dist/shared/multiplayer-types.d.ts +15 -2
- package/dist/shared/multiplayer-types.d.ts.map +1 -1
- package/dist/shared/overlays.js +800 -0
- package/dist/shared/overlays.js.map +1 -0
- package/dist/shared/page-stack.js +2 -1
- package/dist/shared/page-stack.js.map +1 -1
- package/dist/shared/pose-key.js +249 -0
- package/dist/shared/pose-key.js.map +1 -0
- package/dist/shared/race-types.d.ts +1 -1
- package/dist/shared/race.js +1 -1
- package/dist/shared/room-types.d.ts +28 -3
- package/dist/shared/room-types.d.ts.map +1 -1
- package/dist/shared/rooms.d.ts.map +1 -1
- package/dist/shared/rooms.js +23 -4
- package/dist/shared/rooms.js.map +1 -1
- package/dist/shared/runtime-opener.js +1 -1
- package/dist/shared/scene.d.ts +90 -0
- package/dist/shared/scene.d.ts.map +1 -0
- package/dist/shared/social.d.ts +90 -9
- package/dist/shared/social.d.ts.map +1 -1
- package/dist/shared/social.js +6 -1
- package/dist/shared/social.js.map +1 -1
- package/dist/shared/src.js +4563 -667
- package/dist/shared/src.js.map +1 -1
- package/dist/shared/svg-markup.js +2354 -0
- package/dist/shared/svg-markup.js.map +1 -0
- package/dist/shared/svg.js +27 -0
- package/dist/shared/svg.js.map +1 -0
- package/dist/shared/types.d.ts +174 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/wire-types.d.ts +6 -2
- package/dist/shared/wire-types.d.ts.map +1 -1
- package/dist/social.d.ts +5 -5
- package/dist/social.js +2 -2
- package/dist/turns-rules.d.ts +2 -0
- package/dist/turns-rules.js +1 -0
- package/dist/turns.d.ts +61 -0
- package/dist/turns.d.ts.map +1 -0
- package/dist/turns.js +183 -0
- package/dist/turns.js.map +1 -0
- package/package.json +125 -89
- package/src/auto.ts +39 -39
- package/src/avatar/adapters/pixi-avatar.ts +73 -0
- package/src/avatar/adapters/pixi-crowd.ts +329 -0
- package/src/avatar/adapters/pixi-types.ts +84 -0
- package/src/avatar/adapters/three.ts +126 -0
- package/src/avatar/animator/animator.ts +285 -0
- package/src/avatar/animator/reactions.ts +44 -0
- package/src/avatar/animator/schedule.ts +73 -0
- package/src/avatar/art/beard.ts +49 -0
- package/src/avatar/art/glasses.ts +67 -0
- package/src/avatar/art/hair.ts +254 -0
- package/src/avatar/art/headwear.ts +75 -0
- package/src/avatar/art/index.ts +21 -0
- package/src/avatar/art/shape.ts +87 -0
- package/src/avatar/art-store.ts +36 -0
- package/src/avatar/atlas.ts +246 -0
- package/src/avatar/catalog-view.ts +14 -0
- package/src/avatar/colour.ts +109 -0
- package/src/avatar/describe.ts +79 -0
- package/src/avatar/face.ts +187 -0
- package/src/avatar/geom.ts +173 -0
- package/src/avatar/layout.ts +47 -0
- package/src/avatar/look-like.ts +52 -0
- package/src/avatar/outputs/bitmap.ts +75 -0
- package/src/avatar/outputs/canvas-paint.ts +177 -0
- package/src/avatar/outputs/canvas.ts +62 -0
- package/src/avatar/outputs/custom-element.ts +140 -0
- package/src/avatar/outputs/element.ts +58 -0
- package/src/avatar/outputs/live-canvas.ts +57 -0
- package/src/avatar/outputs/overlay-svg.ts +22 -0
- package/src/avatar/outputs/pose-key.ts +30 -0
- package/src/avatar/outputs/svg-markup.ts +113 -0
- package/src/avatar/outputs/svg.ts +41 -0
- package/src/avatar/overlays.ts +128 -0
- package/src/avatar/render/body.ts +116 -0
- package/src/avatar/render/colours.ts +81 -0
- package/src/avatar/render/compose.ts +320 -0
- package/src/avatar/render/context.ts +117 -0
- package/src/avatar/render/eyes.ts +78 -0
- package/src/avatar/render/eyewear.ts +118 -0
- package/src/avatar/render/frame.ts +73 -0
- package/src/avatar/render/head.ts +81 -0
- package/src/avatar/render/mouth.ts +86 -0
- package/src/avatar/render/props.ts +91 -0
- package/src/avatar/render/types.ts +93 -0
- package/src/avatar/render/walker-parts.ts +121 -0
- package/src/avatar/render/walker.ts +199 -0
- package/src/avatar/scene.ts +119 -0
- package/src/avatar-pixi.ts +20 -0
- package/src/avatar-svg.ts +21 -0
- package/src/avatar-three.ts +18 -0
- package/src/avatar.ts +63 -0
- package/src/core/avatar/avatar-cache.ts +95 -0
- package/src/core/avatar/avatar-ref.ts +34 -0
- package/src/core/avatar/avatar-types.ts +43 -0
- package/src/core/avatar/hub-avatar.ts +308 -0
- package/src/core/connection.ts +408 -299
- package/src/core/errors.ts +160 -147
- package/src/core/game-data.ts +40 -38
- package/src/core/hub-economy.ts +216 -179
- package/src/core/hub-progress.ts +269 -205
- package/src/core/hub.ts +352 -131
- package/src/core/menu-control.ts +61 -61
- package/src/core/offline/cloud-saves.ts +62 -0
- package/src/core/offline/events.ts +50 -50
- package/src/core/offline/favorites.ts +21 -21
- package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
- package/src/core/offline/hub-kinds/daily-kind.ts +68 -0
- package/src/core/offline/hub-kinds/economy-kinds.ts +86 -0
- package/src/core/offline/hub-kinds/game-fields.ts +2 -0
- package/src/core/offline/hub-kinds/index.ts +52 -0
- package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
- package/src/core/offline/hub-kinds/progress-kinds.ts +165 -0
- package/src/core/offline/hub-kinds/resume-kind.ts +108 -0
- package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
- package/src/core/offline/kind-adapters.ts +48 -0
- package/src/core/offline/local-inventory.ts +64 -0
- package/src/core/offline/local-store.ts +304 -261
- package/src/core/offline/offline-api.ts +65 -0
- package/src/core/offline/outbox-entry.ts +59 -0
- package/src/core/offline/outbox-kinds.ts +78 -0
- package/src/core/offline/outbox-merge.ts +39 -0
- package/src/core/offline/outbox-replayer.ts +141 -0
- package/src/core/offline/outbox-store.ts +85 -0
- package/src/core/offline/outbox.ts +232 -0
- package/src/core/offline/queue.ts +126 -171
- package/src/core/offline/replay-lease.ts +87 -0
- package/src/core/offline/replay-policy.ts +52 -0
- package/src/core/page-account.ts +121 -0
- package/src/core/page.ts +87 -74
- package/src/core/rest/endpoint-types.ts +22 -22
- package/src/core/rest/rest-client.ts +163 -152
- package/src/core/rest/routes.gen.ts +144 -88
- package/src/core/resume/hub-resume.ts +286 -0
- package/src/core/resume/local-resume.ts +130 -0
- package/src/core/resume/resume-list.ts +65 -0
- package/src/core/resume/resume-request.ts +52 -0
- package/src/core/resume/resume-types.ts +64 -0
- package/src/core/saved/daily-saves.ts +196 -0
- package/src/core/saved/daily-seed.ts +16 -0
- package/src/core/saved/hub-saved.ts +221 -0
- package/src/core/saved/local-saved.ts +122 -0
- package/src/core/saved/saved-list.ts +129 -0
- package/src/core/saved/saved-open.ts +104 -0
- package/src/core/saved/saved-order.ts +49 -0
- package/src/core/saved/saved-types.ts +24 -0
- package/src/core/storage/json-guards.ts +80 -74
- package/src/core/storage/key-value.ts +66 -66
- package/src/core/ticket.ts +43 -43
- package/src/core/turns/hub-turns.ts +161 -0
- package/src/core/turns/turn-game.ts +510 -0
- package/src/core/turns/your-turn-buzz.ts +69 -0
- package/src/core/types.ts +134 -134
- package/src/core/version/client-version.gen.ts +2 -2
- package/src/core/version/hub-version.ts +62 -62
- package/src/core/version/semver.ts +97 -97
- package/src/daily/daily-system.ts +276 -161
- package/src/daily/page-daily.ts +36 -36
- package/src/daily/types.ts +105 -67
- package/src/daily.ts +10 -10
- package/src/doctor/checks.ts +86 -76
- package/src/doctor/cli.ts +101 -106
- package/src/doctor/hub-version-fetch.ts +43 -0
- package/src/doctor/tsconfig.json +12 -12
- package/src/doctor/world-check.ts +61 -0
- package/src/globals.ts +115 -110
- package/src/index.ts +68 -50
- package/src/input/device-state.ts +146 -146
- package/src/input/edges.ts +55 -55
- package/src/input/focus-ring.ts +13 -0
- package/src/input/gamepad.ts +61 -61
- package/src/input/input-events.ts +32 -32
- package/src/input/input-registry.ts +171 -164
- package/src/input/input-system.ts +406 -393
- package/src/input/legacy-controls.ts +64 -64
- package/src/input/modality.ts +122 -0
- package/src/input/nav-state.ts +200 -191
- package/src/input/navigator.ts +182 -171
- package/src/input/on-screen-controls.ts +101 -101
- package/src/input/page-control.ts +43 -43
- package/src/input/page-input.ts +193 -193
- package/src/input/player-remaps.ts +77 -77
- package/src/input/source-values.ts +116 -116
- package/src/input/spatial.ts +47 -47
- package/src/input/types.ts +331 -331
- package/src/input/zero-config.ts +164 -177
- package/src/input.ts +26 -22
- package/src/mp.ts +10 -10
- package/src/overlay/controls-panel.ts +163 -163
- package/src/overlay/dom.ts +46 -46
- package/src/overlay/layout-clusters.ts +162 -162
- package/src/overlay/layout-geometry.ts +136 -136
- package/src/overlay/layout.ts +203 -203
- package/src/overlay/listener-scope.ts +23 -23
- package/src/overlay/on-screen-keyboard.ts +137 -137
- package/src/overlay/overlay.ts +234 -208
- package/src/overlay/styles.ts +13 -12
- package/src/overlay/virtual-controls.ts +262 -262
- package/src/race.ts +10 -10
- package/src/realtime/bridge.ts +126 -121
- package/src/realtime/cross-bundle.ts +29 -29
- package/src/realtime/fallback-notice.ts +48 -48
- package/src/realtime/hub-link.ts +65 -65
- package/src/realtime/latest-state.ts +33 -33
- package/src/realtime/multiplayer-forwarder.ts +40 -28
- package/src/realtime/multiplayer-types.ts +66 -52
- package/src/realtime/page-runtime.ts +60 -60
- package/src/realtime/race-forwarder.ts +59 -59
- package/src/realtime/race-types.ts +132 -132
- package/src/realtime/room-types.ts +151 -125
- package/src/realtime/rooms-forwarder.ts +104 -79
- package/src/realtime/runtime-api.ts +74 -49
- package/src/realtime/runtime-opener.ts +61 -61
- package/src/realtime/social-forwarder.ts +77 -72
- package/src/realtime/social-types.ts +86 -78
- package/src/realtime/types.ts +54 -53
- package/src/realtime/wire-types.ts +79 -72
- package/src/realtime/world-types.ts +20 -0
- package/src/rooms.ts +10 -10
- package/src/social.ts +19 -18
- package/src/turns-kit/check-rules.ts +281 -0
- package/src/turns-rules.ts +12 -0
- package/src/turns.ts +14 -0
- package/src/types.ts +13 -13
- package/src/uistack/page-stack.ts +45 -45
- package/src/uistack/play-suspension.ts +82 -82
- package/src/uistack/types.ts +76 -76
- package/src/uistack/ui-stack.ts +144 -144
|
@@ -1,261 +1,304 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The offline cache: what the client serves when the hub can't be reached,
|
|
3
|
-
* and where
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
this.
|
|
197
|
-
return {
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
this.writeStored(
|
|
260
|
-
}
|
|
261
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The offline cache: what the client serves when the hub can't be reached,
|
|
3
|
+
* and where buffered analytics events wait (queued writes live in the
|
|
4
|
+
* outbox, `outbox.ts`). Keys are the v1 client's (`hub:user`,
|
|
5
|
+
* `hub:<slug>:save:<slot>`, `hub:<slug>:events`, ...), so a page upgraded
|
|
6
|
+
* from a vendored copy keeps that copy's cache, and its `hub:<slug>:queue`
|
|
7
|
+
* moves into the outbox.
|
|
8
|
+
*/
|
|
9
|
+
import type { Board, Inventory, Json, SortDir, Stats } from '@diffenderfer-games/hub-contract';
|
|
10
|
+
import { isJson, isNullableString, isNumberRecord, isRecord, optionalNumber, optionalString, parseJsonText } from '../storage/json-guards';
|
|
11
|
+
import { createPageStore, type KeyValueStore } from '../storage/key-value';
|
|
12
|
+
import type { Profile, Save, SaveMeta, SnapshotResponse, User } from '../types';
|
|
13
|
+
import { cloudSaveOf, readCloudSaveIndex, withCloudSave, type CloudSaveIndex } from './cloud-saves';
|
|
14
|
+
import { appendEvent, readEvents, type BufferedEvent } from './events';
|
|
15
|
+
import { EMPTY_INVENTORY, readInventory } from './local-inventory';
|
|
16
|
+
import { betterScore, readQueue, type QueueOp, type QueuedScoreOptions } from './queue';
|
|
17
|
+
|
|
18
|
+
/** The key of the locally known user, shared by every game (like the real session). */
|
|
19
|
+
export const USER_KEY = 'hub:user';
|
|
20
|
+
|
|
21
|
+
/** A board this player has a local best on. */
|
|
22
|
+
export interface LocalBoard {
|
|
23
|
+
/** The board. */
|
|
24
|
+
readonly board: Board;
|
|
25
|
+
/** This player's best score. */
|
|
26
|
+
readonly best: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** How a stat update combines with the stored value. */
|
|
30
|
+
export type StatUpdateMode = 'set' | 'incr' | 'max';
|
|
31
|
+
|
|
32
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: a cached user read back from localStorage
|
|
33
|
+
function readUser(stored: unknown): User | null {
|
|
34
|
+
if (!isRecord(stored)) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const id = optionalNumber(stored.id);
|
|
38
|
+
const username = optionalString(stored.username);
|
|
39
|
+
if (id === undefined || username === undefined) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return typeof stored.guest === 'boolean' ? { id, username, guest: stored.guest } : { id, username };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: a cached save read back from localStorage
|
|
46
|
+
function readSave(stored: unknown): Save | null {
|
|
47
|
+
if (!isRecord(stored)) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const slot = optionalString(stored.slot);
|
|
51
|
+
const updatedAt = optionalNumber(stored.updatedAt);
|
|
52
|
+
if (slot === undefined || updatedAt === undefined || !isNullableString(stored.label) || !isJson(stored.data)) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return { slot, label: stored.label, data: stored.data, updatedAt };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: a cached profile read back from localStorage
|
|
59
|
+
function readProfile(stored: unknown): Profile | null {
|
|
60
|
+
if (!isRecord(stored)) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const game = optionalString(stored.game);
|
|
64
|
+
const updatedAt = optionalNumber(stored.updatedAt);
|
|
65
|
+
if (game === undefined || updatedAt === undefined || !isNullableString(stored.displayName) || !isJson(stored.data)) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return { game, displayName: stored.displayName, data: stored.data, updatedAt };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: a cached board read back from localStorage
|
|
72
|
+
function readLocalBoard(stored: unknown): LocalBoard | null {
|
|
73
|
+
if (!isRecord(stored) || !isRecord(stored.board)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const key = optionalString(stored.board.key);
|
|
77
|
+
const title = optionalString(stored.board.title);
|
|
78
|
+
const sortDir = stored.board.sortDir === 'asc' || stored.board.sortDir === 'desc' ? stored.board.sortDir : undefined;
|
|
79
|
+
const best = optionalNumber(stored.best);
|
|
80
|
+
if (key === undefined || title === undefined || sortDir === undefined || best === undefined) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return { board: { key, title, sortDir }, best };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One game's offline cache, queue and event buffer. */
|
|
87
|
+
export class LocalStore {
|
|
88
|
+
private readonly prefix: string;
|
|
89
|
+
private readonly store: KeyValueStore;
|
|
90
|
+
|
|
91
|
+
/** The cache for game `slug` in `store` (default: `localStorage`, or memory without it). */
|
|
92
|
+
constructor(slug: string, store: KeyValueStore = createPageStore()) {
|
|
93
|
+
this.prefix = `hub:${slug}:`;
|
|
94
|
+
this.store = store;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: JSON read back from storage, checked by each caller's guard
|
|
98
|
+
private readStored(key: string): unknown {
|
|
99
|
+
const text = this.store.read(key);
|
|
100
|
+
return text === null ? undefined : parseJsonText(text);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: any JSON-serializable value the cache stores
|
|
104
|
+
private writeStored(key: string, stored: unknown): void {
|
|
105
|
+
this.store.write(key, JSON.stringify(stored));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The locally known user. */
|
|
109
|
+
getUser(): User | null {
|
|
110
|
+
return readUser(this.readStored(USER_KEY));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Remember the user (offline sign-in, or a snapshot). */
|
|
114
|
+
setUser(user: User): void {
|
|
115
|
+
this.writeStored(USER_KEY, user);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Forget the user (offline sign-out). */
|
|
119
|
+
clearUser(): void {
|
|
120
|
+
this.store.remove(USER_KEY);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** This game's cached profile. */
|
|
124
|
+
getProfile(): Profile | null {
|
|
125
|
+
return readProfile(this.readStored(`${this.prefix}profile`));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Store this game's profile and return it. */
|
|
129
|
+
setProfile(game: string, displayName: string | null, profileData: Json): Profile {
|
|
130
|
+
const profile: Profile = { game, displayName, data: profileData, updatedAt: Date.now() };
|
|
131
|
+
this.writeStored(`${this.prefix}profile`, profile);
|
|
132
|
+
return profile;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Every cached save's metadata, newest first. */
|
|
136
|
+
listSaves(): SaveMeta[] {
|
|
137
|
+
return this.store.keys()
|
|
138
|
+
.filter((key) => key.startsWith(`${this.prefix}save:`))
|
|
139
|
+
.flatMap((key) => {
|
|
140
|
+
const save = readSave(this.readStored(key));
|
|
141
|
+
return save === null ? [] : [{ slot: save.slot, label: save.label, updatedAt: save.updatedAt }];
|
|
142
|
+
})
|
|
143
|
+
.sort((first, second) => second.updatedAt - first.updatedAt);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** One cached save, or `null`. */
|
|
147
|
+
getSave(slot: string): Save | null {
|
|
148
|
+
return readSave(this.readStored(`${this.prefix}save:${slot}`));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Store a save and return its `updatedAt`. */
|
|
152
|
+
putSave(slot: string, saveData: Json, label: string | null): number {
|
|
153
|
+
const updatedAt = Date.now();
|
|
154
|
+
this.writeStored(`${this.prefix}save:${slot}`, { slot, label, data: saveData, updatedAt });
|
|
155
|
+
return updatedAt;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Forget a save. */
|
|
159
|
+
deleteSave(slot: string): void {
|
|
160
|
+
this.store.remove(`${this.prefix}save:${slot}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The hub's `updatedAt` for `slot` as the current user last saw it (`null`:
|
|
165
|
+
* the slot was empty), or `undefined` when this device doesn't know.
|
|
166
|
+
*/
|
|
167
|
+
cloudSave(slot: string): number | null | undefined {
|
|
168
|
+
const user = this.getUser();
|
|
169
|
+
return user === null ? undefined : cloudSaveOf(this.readCloudSaves(this.prefix), user.id, slot);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Note the hub's copy of `slot` (just read or written) for the current user: its `updatedAt`, or `null` when empty. */
|
|
173
|
+
rememberCloudSave(slot: string, updatedAt: number | null): void {
|
|
174
|
+
const user = this.getUser();
|
|
175
|
+
if (user !== null) {
|
|
176
|
+
this.writeStored(`${this.prefix}cloud-saves`, withCloudSave(this.readCloudSaves(this.prefix), user.id, slot, updatedAt));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Cache a slot as read from the hub (forgetting it when the hub has none), and note the hub's copy. */
|
|
181
|
+
cacheCloudSave(slot: string, save: Save | null): void {
|
|
182
|
+
if (save === null) {
|
|
183
|
+
this.deleteSave(slot);
|
|
184
|
+
} else {
|
|
185
|
+
this.writeStored(`${this.prefix}save:${slot}`, save);
|
|
186
|
+
}
|
|
187
|
+
this.rememberCloudSave(slot, save?.updatedAt ?? null);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private readCloudSaves(gamePrefix: string): CloudSaveIndex | null {
|
|
191
|
+
return readCloudSaveIndex(this.readStored(`${gamePrefix}cloud-saves`));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** This game's cached stats. */
|
|
195
|
+
getStats(): Stats {
|
|
196
|
+
const stored = this.readStored(`${this.prefix}stats`);
|
|
197
|
+
return isNumberRecord(stored) ? { ...stored } : {};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Apply one stat update and return the stat's new value. */
|
|
201
|
+
updateStat(key: string, amount: number, mode: StatUpdateMode): number {
|
|
202
|
+
const stats = this.getStats();
|
|
203
|
+
const current = stats[key] ?? 0;
|
|
204
|
+
const updated = mode === 'incr' ? current + amount : mode === 'max' ? Math.max(current, amount) : amount;
|
|
205
|
+
this.writeStored(`${this.prefix}stats`, { ...stats, [key]: updated });
|
|
206
|
+
return updated;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The boards this player has a local best on (offline boards only know the player's own best). */
|
|
210
|
+
listBoards(): Board[] {
|
|
211
|
+
return this.store.keys()
|
|
212
|
+
.filter((key) => key.startsWith(`${this.prefix}lb:`))
|
|
213
|
+
.flatMap((key) => {
|
|
214
|
+
const local = readLocalBoard(this.readStored(key));
|
|
215
|
+
return local === null ? [] : [local.board];
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** One board's local best, or `null`. */
|
|
220
|
+
getBoard(key: string): LocalBoard | null {
|
|
221
|
+
return readLocalBoard(this.readStored(`${this.prefix}lb:${key}`));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Record a score locally, keeping the best one; returns the board and best. */
|
|
225
|
+
submitScore(key: string, score: number, options: QueuedScoreOptions): LocalBoard {
|
|
226
|
+
const existing = this.getBoard(key);
|
|
227
|
+
const sortDir: SortDir = existing?.board.sortDir ?? (options.sort === 'asc' ? 'asc' : 'desc');
|
|
228
|
+
const board: Board = existing?.board ?? { key, title: options.title !== undefined && options.title !== '' ? options.title : key, sortDir };
|
|
229
|
+
const best = existing === null ? score : betterScore(existing.best, score, sortDir);
|
|
230
|
+
this.writeStored(`${this.prefix}lb:${key}`, { board, best });
|
|
231
|
+
return { board, best };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The v1 client's queued writes, removed from storage (the caller moves them into the outbox). */
|
|
235
|
+
takeLegacyQueue(): QueueOp[] {
|
|
236
|
+
const queue = readQueue(this.readStored(`${this.prefix}queue`));
|
|
237
|
+
this.store.remove(`${this.prefix}queue`);
|
|
238
|
+
return queue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** This game's cached inventory (empty when none is cached). */
|
|
242
|
+
getInventory(): Inventory {
|
|
243
|
+
return readInventory(this.readStored(`${this.prefix}inventory`)) ?? EMPTY_INVENTORY;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Store this game's inventory and return it. */
|
|
247
|
+
setInventory(inventory: Inventory): Inventory {
|
|
248
|
+
this.writeStored(`${this.prefix}inventory`, inventory);
|
|
249
|
+
return inventory;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The buffered analytics events, oldest first. */
|
|
253
|
+
getEvents(): BufferedEvent[] {
|
|
254
|
+
return readEvents(this.readStored(`${this.prefix}events`));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Replace the event buffer. */
|
|
258
|
+
setEvents(events: readonly BufferedEvent[]): void {
|
|
259
|
+
this.writeStored(`${this.prefix}events`, events);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Buffer one event (capped; heartbeats are dropped first). */
|
|
263
|
+
bufferEvent(type: string): void {
|
|
264
|
+
this.setEvents(appendEvent(this.getEvents(), { type, ts: Date.now() }));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Fan a `/me/snapshot` reply out across every game's cache keys. It lists
|
|
269
|
+
* every save slot of every game the player has played, so each game's
|
|
270
|
+
* slots on the hub become known (and this game's are all empty when it
|
|
271
|
+
* isn't listed).
|
|
272
|
+
*/
|
|
273
|
+
hydrate(snapshot: SnapshotResponse): void {
|
|
274
|
+
if (snapshot.user !== null) {
|
|
275
|
+
this.writeStored(USER_KEY, snapshot.user);
|
|
276
|
+
}
|
|
277
|
+
const userId = snapshot.user?.id;
|
|
278
|
+
if (userId !== undefined && !Object.keys(snapshot.games).some((slug) => `hub:${slug}:` === this.prefix)) {
|
|
279
|
+
this.writeStored(`${this.prefix}cloud-saves`, { userId, complete: true, saves: {} } satisfies CloudSaveIndex);
|
|
280
|
+
}
|
|
281
|
+
for (const [slug, game] of Object.entries(snapshot.games)) {
|
|
282
|
+
const gamePrefix = `hub:${slug}:`;
|
|
283
|
+
if (game.profile !== null) {
|
|
284
|
+
this.writeStored(`${gamePrefix}profile`, game.profile);
|
|
285
|
+
}
|
|
286
|
+
for (const save of game.saves) {
|
|
287
|
+
this.writeStored(`${gamePrefix}save:${save.slot}`, save);
|
|
288
|
+
}
|
|
289
|
+
if (userId !== undefined) {
|
|
290
|
+
const saves = Object.fromEntries(game.saves.map((save) => [save.slot, save.updatedAt]));
|
|
291
|
+
this.writeStored(`${gamePrefix}cloud-saves`, { userId, complete: true, saves } satisfies CloudSaveIndex);
|
|
292
|
+
}
|
|
293
|
+
this.writeStored(`${gamePrefix}stats`, game.stats);
|
|
294
|
+
this.writeStored(`${gamePrefix}inventory`, game.inventory);
|
|
295
|
+
}
|
|
296
|
+
for (const gameRecords of snapshot.records) {
|
|
297
|
+
for (const record of gameRecords.records) {
|
|
298
|
+
this.writeStored(`hub:${gameRecords.slug}:lb:${record.key}`, { board: { key: record.key, title: record.title, sortDir: record.sortDir }, best: record.score });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
this.writeStored('hub:records', snapshot.records);
|
|
302
|
+
this.writeStored('hub:leaderboards', snapshot.leaderboards);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hub.offline`: the outbox, opened to games. A game defines its own kinds
|
|
3
|
+
* (a handler per kind, which may make several dependent calls) and enqueues
|
|
4
|
+
* operations of them; the hub queues them in order, replays them when it is
|
|
5
|
+
* reachable, and reports what happened.
|
|
6
|
+
*/
|
|
7
|
+
import { toStoredJson, type GamePayload } from '../game-data';
|
|
8
|
+
import { HubError } from '../errors';
|
|
9
|
+
import { gameKind } from './kind-adapters';
|
|
10
|
+
import type { OfflineEventName, OfflineListener, OfflineOp, Outbox } from './outbox';
|
|
11
|
+
import { HUB_KIND_PREFIX, type OfflineHandler, type OfflineKindDefinition } from './outbox-kinds';
|
|
12
|
+
|
|
13
|
+
/** What `hub.offline` drives. */
|
|
14
|
+
export interface OfflineHost {
|
|
15
|
+
/** The game's outbox. */
|
|
16
|
+
readonly outbox: Outbox;
|
|
17
|
+
/** Replay now when the hub is reachable (and nothing else is replaying). */
|
|
18
|
+
requestSync(): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The game-facing outbox API. */
|
|
22
|
+
export class OfflineApi {
|
|
23
|
+
private readonly host: OfflineHost;
|
|
24
|
+
|
|
25
|
+
/** The API over `host`. */
|
|
26
|
+
constructor(host: OfflineHost) {
|
|
27
|
+
this.host = host;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Define what an operation of `kind` does when it replays. Define every
|
|
32
|
+
* kind at load, before or after enqueueing: waiting ops of an undefined
|
|
33
|
+
* kind hold the queue until it is defined. `hub.*` kinds are the hub's own.
|
|
34
|
+
*/
|
|
35
|
+
define<Payload extends GamePayload>(kind: string, definition: OfflineHandler<Payload> | OfflineKindDefinition<Payload>): void {
|
|
36
|
+
if (kind.startsWith(HUB_KIND_PREFIX) || kind === '') {
|
|
37
|
+
throw new HubError(`Offline kinds starting with "${HUB_KIND_PREFIX}" are the hub's own; name yours after your game.`);
|
|
38
|
+
}
|
|
39
|
+
this.host.outbox.define(kind, gameKind(definition));
|
|
40
|
+
this.host.requestSync();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Queue an operation of `kind` and replay it as soon as the hub is
|
|
45
|
+
* reachable (at once when online). Resolves the queued op; its outcome
|
|
46
|
+
* arrives as a `replayed` or `failed` event. Throws a `HubError` (code
|
|
47
|
+
* `offline_queue_full`) when the outbox is full.
|
|
48
|
+
*/
|
|
49
|
+
enqueue(kind: string, payload: GamePayload): OfflineOp {
|
|
50
|
+
const op = this.host.outbox.stamp(kind, toStoredJson(payload));
|
|
51
|
+
this.host.outbox.add(op);
|
|
52
|
+
this.host.requestSync();
|
|
53
|
+
return op;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The waiting operations (the hub's and the game's), oldest first. */
|
|
57
|
+
pending(): OfflineOp[] {
|
|
58
|
+
return this.host.outbox.pending();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Listen for `queued`, `replayed` or `failed`; returns the unsubscribe function. */
|
|
62
|
+
on<Name extends OfflineEventName>(name: Name, listener: OfflineListener<Name>): () => void {
|
|
63
|
+
return this.host.outbox.on(name, listener);
|
|
64
|
+
}
|
|
65
|
+
}
|