@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
package/src/core/connection.ts
CHANGED
|
@@ -1,299 +1,408 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The machinery under every `Hub` call: reaching the REST API, falling back
|
|
3
|
-
* to the offline cache, queueing writes made offline
|
|
4
|
-
* buffering analytics, warming the cache for every game, and
|
|
5
|
-
* online/offline/sync status (to `onStatus` listeners and as
|
|
6
|
-
* events, which the injected menu listens for).
|
|
7
|
-
*/
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
readonly
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
protected
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
this.
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
this.
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The machinery under every `Hub` call: reaching the REST API, falling back
|
|
3
|
+
* to the offline cache, queueing writes made offline in the outbox and
|
|
4
|
+
* replaying them, buffering analytics, warming the cache for every game, and
|
|
5
|
+
* reporting online/offline/sync status (to `onStatus` listeners and as
|
|
6
|
+
* `hub:*` window events, which the injected menu listens for).
|
|
7
|
+
*/
|
|
8
|
+
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
9
|
+
import { HubError } from './errors';
|
|
10
|
+
import { registerHubKinds, legacyOperation } from './offline/hub-kinds';
|
|
11
|
+
import { LocalStore } from './offline/local-store';
|
|
12
|
+
import { OfflineApi } from './offline/offline-api';
|
|
13
|
+
import { Outbox, type OfflineOp } from './offline/outbox';
|
|
14
|
+
import { LocalResumeStore } from './resume/local-resume';
|
|
15
|
+
import { LocalDailyStore } from './saved/local-saved';
|
|
16
|
+
import { absoluteApiBase, detectApiBase, detectSlug, emitWindowEvent, isBrowserPage } from './page';
|
|
17
|
+
import { createRestClient, type RestClient } from './rest/rest-client';
|
|
18
|
+
import { createPageStore } from './storage/key-value';
|
|
19
|
+
import type { HubOptions, HubStatus, SnapshotResponse } from './types';
|
|
20
|
+
|
|
21
|
+
/** How long `prefetchAll()` waits between snapshots unless forced. */
|
|
22
|
+
const PREFETCH_INTERVAL_MS = 30_000;
|
|
23
|
+
|
|
24
|
+
/** A queued op the hub refused during a sync (or that kept failing): dropped, with the reason. */
|
|
25
|
+
export interface SyncConflict {
|
|
26
|
+
/** The dropped op. */
|
|
27
|
+
readonly op: OfflineOp;
|
|
28
|
+
/** The hub's (or the handler's) message. */
|
|
29
|
+
readonly error?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What one `sync()` did. */
|
|
33
|
+
export interface SyncResult {
|
|
34
|
+
/** Queued ops that landed. */
|
|
35
|
+
readonly flushed: number;
|
|
36
|
+
/** Queued ops that were dropped. */
|
|
37
|
+
readonly conflicts: SyncConflict[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A sync that did nothing. */
|
|
41
|
+
const NOTHING_SYNCED: SyncResult = { flushed: 0, conflicts: [] };
|
|
42
|
+
|
|
43
|
+
/** Whether a failure means "the hub can't be reached" (rather than a refusal). */
|
|
44
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: whatever a call threw
|
|
45
|
+
function isTransportFailure(failure: unknown): boolean {
|
|
46
|
+
return failure instanceof HubError && failure.isOffline;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The game slug's path field, or a {@link HubError} when the slug is unknown. */
|
|
50
|
+
export interface GameFields {
|
|
51
|
+
/** The game's slug. */
|
|
52
|
+
readonly slug: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The connection, offline cache and sync machinery shared by every `Hub` method. */
|
|
56
|
+
export class HubConnection {
|
|
57
|
+
/** This client's game slug (`null` when it could not be determined). */
|
|
58
|
+
readonly slug: string | null;
|
|
59
|
+
/** The absolute API base, e.g. `https://diffenderfer.games/_api`. */
|
|
60
|
+
readonly apiBase: string;
|
|
61
|
+
/** Typed REST calls to {@link apiBase}. */
|
|
62
|
+
protected readonly rest: RestClient;
|
|
63
|
+
/** This game's offline cache and event buffer. */
|
|
64
|
+
protected readonly store: LocalStore;
|
|
65
|
+
/** This game's queued operations. */
|
|
66
|
+
protected readonly outbox: Outbox;
|
|
67
|
+
/** The device copies of every game's resume slot (`hub.resume`). */
|
|
68
|
+
protected readonly resumeStore: LocalResumeStore;
|
|
69
|
+
/** The device copies of daily challenges in progress (every game's). */
|
|
70
|
+
protected readonly dailyStore: LocalDailyStore;
|
|
71
|
+
private readonly offlineMode: NonNullable<HubOptions['offline']>;
|
|
72
|
+
private isReachable = true;
|
|
73
|
+
private runningSync: Promise<SyncResult> | null = null;
|
|
74
|
+
private followingSync: Promise<SyncResult> | null = null;
|
|
75
|
+
private isFollowingEager = false;
|
|
76
|
+
private directSend: Promise<Json | undefined> | null = null;
|
|
77
|
+
private lastPrefetchAt = 0;
|
|
78
|
+
private offlineApi: OfflineApi | undefined;
|
|
79
|
+
private readonly statusListeners = new Set<(status: HubStatus) => void>();
|
|
80
|
+
|
|
81
|
+
/** A connection for `options` (slug and API base default to the page's). */
|
|
82
|
+
constructor(options: HubOptions = {}) {
|
|
83
|
+
this.slug = options.slug ?? detectSlug();
|
|
84
|
+
this.apiBase = options.apiBase === undefined ? detectApiBase() : absoluteApiBase(options.apiBase);
|
|
85
|
+
this.offlineMode = options.offline ?? 'auto';
|
|
86
|
+
this.rest = createRestClient({
|
|
87
|
+
apiBase: this.apiBase,
|
|
88
|
+
fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
|
|
89
|
+
onReachable: () => {
|
|
90
|
+
this.setReachable(true);
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
const pageStore = createPageStore();
|
|
94
|
+
this.store = new LocalStore(this.slug ?? 'app', pageStore);
|
|
95
|
+
this.resumeStore = new LocalResumeStore(pageStore);
|
|
96
|
+
this.dailyStore = new LocalDailyStore(pageStore);
|
|
97
|
+
this.outbox = new Outbox({
|
|
98
|
+
slug: this.slug ?? 'app',
|
|
99
|
+
store: pageStore,
|
|
100
|
+
rest: this.rest,
|
|
101
|
+
emit: emitWindowEvent,
|
|
102
|
+
onUnreachable: () => {
|
|
103
|
+
this.setReachable(false);
|
|
104
|
+
},
|
|
105
|
+
onChange: () => {
|
|
106
|
+
this.notifyStatus();
|
|
107
|
+
},
|
|
108
|
+
onRetryDue: () => {
|
|
109
|
+
void this.retrySync();
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
registerHubKinds(this.outbox, {
|
|
113
|
+
game: () => this.gameFields(),
|
|
114
|
+
onDailyComplete: (payload, completion) => {
|
|
115
|
+
if (this.slug !== null) {
|
|
116
|
+
this.dailyStore.remove(this.slug, payload.day);
|
|
117
|
+
}
|
|
118
|
+
emitWindowEvent('hub:daily-complete', { day: payload.day, count: completion.count, rank: completion.rank, meta: payload.meta });
|
|
119
|
+
},
|
|
120
|
+
resume: this.resumeStore,
|
|
121
|
+
dailies: this.dailyStore,
|
|
122
|
+
cloudSaves: this.store,
|
|
123
|
+
});
|
|
124
|
+
this.importLegacyQueue();
|
|
125
|
+
if (typeof navigator !== 'undefined' && 'onLine' in navigator) {
|
|
126
|
+
this.isReachable = navigator.onLine;
|
|
127
|
+
}
|
|
128
|
+
if (isBrowserPage()) {
|
|
129
|
+
this.listenToPage();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** `true` when calls are being served from the offline cache rather than the hub. */
|
|
134
|
+
get isOffline(): boolean {
|
|
135
|
+
return this.offlineMode === 'always' || (this.offlineMode === 'auto' && !this.isReachable);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** `true` while the hub is reachable. */
|
|
139
|
+
get isOnline(): boolean {
|
|
140
|
+
return this.isReachable;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The offline outbox: define your own kinds of operation, queue them, and
|
|
145
|
+
* hear how they replay (`hub.offline.define`, `enqueue`, `pending`, `on`).
|
|
146
|
+
*/
|
|
147
|
+
get offline(): OfflineApi {
|
|
148
|
+
this.offlineApi ??= new OfflineApi({
|
|
149
|
+
outbox: this.outbox,
|
|
150
|
+
requestSync: () => {
|
|
151
|
+
if (!this.isOffline) {
|
|
152
|
+
void this.sync();
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
return this.offlineApi;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Queued operations plus buffered analytics events waiting to sync. */
|
|
160
|
+
pending(): number {
|
|
161
|
+
return this.outbox.count() + this.store.getEvents().length;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Subscribe to connection and sync status changes; returns the unsubscribe function. */
|
|
165
|
+
onStatus(listener: (status: HubStatus) => void): () => void {
|
|
166
|
+
this.statusListeners.add(listener);
|
|
167
|
+
return () => {
|
|
168
|
+
this.statusListeners.delete(listener);
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Tell `onStatus` listeners and the page (`hub:status`) the current status. */
|
|
173
|
+
protected notifyStatus(): void {
|
|
174
|
+
const status: HubStatus = { online: this.isReachable, offline: this.isOffline, pending: this.pending() };
|
|
175
|
+
for (const listener of this.statusListeners) {
|
|
176
|
+
try {
|
|
177
|
+
listener(status);
|
|
178
|
+
} catch {
|
|
179
|
+
// A listener's failure must not stop the others.
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
emitWindowEvent('hub:status', status);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Record reachability; coming back online replays the outbox and re-warms the cache. */
|
|
186
|
+
protected setReachable(isReachableNow: boolean): void {
|
|
187
|
+
if (isReachableNow === this.isReachable) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
this.isReachable = isReachableNow;
|
|
191
|
+
emitWindowEvent(isReachableNow ? 'hub:online' : 'hub:offline', { pending: this.pending() });
|
|
192
|
+
this.notifyStatus();
|
|
193
|
+
if (isReachableNow) {
|
|
194
|
+
// Replay first, so the snapshot that re-warms the cache already has the queued changes.
|
|
195
|
+
void this.replayAll(true).then(() => this.prefetchAll()).catch(() => undefined);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** `{ slug }` for this game's endpoints; throws when the slug is unknown. */
|
|
200
|
+
protected gameFields(): GameFields {
|
|
201
|
+
if (this.slug === null) {
|
|
202
|
+
throw new HubError('Hub: could not determine the game slug.');
|
|
203
|
+
}
|
|
204
|
+
return { slug: this.slug };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Run the network call, falling back to the offline answer when the hub
|
|
209
|
+
* can't be reached (per the offline mode). Real API errors (with an HTTP
|
|
210
|
+
* status) always reach the caller.
|
|
211
|
+
*/
|
|
212
|
+
protected async withFallback<Reply>(networkCall: () => Promise<Reply>, offlineAnswer: () => Reply): Promise<Reply> {
|
|
213
|
+
if (this.isOffline) {
|
|
214
|
+
return offlineAnswer();
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
return await networkCall();
|
|
218
|
+
} catch (failure) {
|
|
219
|
+
if (!isTransportFailure(failure) || this.offlineMode === 'never') {
|
|
220
|
+
throw failure;
|
|
221
|
+
}
|
|
222
|
+
this.setReachable(false);
|
|
223
|
+
return offlineAnswer();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Apply a write to the offline cache at once, then perform it as an op of
|
|
229
|
+
* `kind`: sent now when online with nothing waiting, else queued behind
|
|
230
|
+
* the others. Resolves with the local answer (or, sent now, with
|
|
231
|
+
* `replyAnswer`'s reading of the hub's reply). A real API error on the
|
|
232
|
+
* direct path reaches the caller and is not queued; a transport failure
|
|
233
|
+
* queues the op under the same id, so if the request did land, its replay
|
|
234
|
+
* is recognised by the hub and not applied twice.
|
|
235
|
+
*/
|
|
236
|
+
protected async mutateOffline<Reply>(kind: string, payload: Json, localAnswer: () => Reply, replyAnswer?: (result: Json | undefined) => Reply | undefined): Promise<Reply> {
|
|
237
|
+
const answer = localAnswer();
|
|
238
|
+
const op = this.outbox.stamp(kind, payload);
|
|
239
|
+
if (this.isOffline || this.outbox.count() > 0 || this.runningSync !== null || this.directSend !== null) {
|
|
240
|
+
this.outbox.add(op);
|
|
241
|
+
if (!this.isOffline) {
|
|
242
|
+
void this.sync();
|
|
243
|
+
}
|
|
244
|
+
return answer;
|
|
245
|
+
}
|
|
246
|
+
const sending = this.outbox.runNow(op);
|
|
247
|
+
this.directSend = sending;
|
|
248
|
+
try {
|
|
249
|
+
const result = await sending;
|
|
250
|
+
return replyAnswer?.(result) ?? answer;
|
|
251
|
+
} catch (failure) {
|
|
252
|
+
if (!isTransportFailure(failure)) {
|
|
253
|
+
throw failure;
|
|
254
|
+
}
|
|
255
|
+
this.setReachable(false);
|
|
256
|
+
this.outbox.add({ ...op, attempts: 1 });
|
|
257
|
+
return answer;
|
|
258
|
+
} finally {
|
|
259
|
+
if (this.directSend === sending) {
|
|
260
|
+
this.directSend = null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Replay the outbox, then the buffered events, in order. Stops at the
|
|
267
|
+
* first transport failure (offline again: the rest stays queued); an op
|
|
268
|
+
* the hub refuses is dropped and reported as a conflict.
|
|
269
|
+
*/
|
|
270
|
+
sync(): Promise<SyncResult> {
|
|
271
|
+
return this.isOffline ? Promise.resolve(NOTHING_SYNCED) : this.replayAll(false);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** A retry came due: try again even when the hub was last unreachable (the try is the probe), unless the browser itself is offline. */
|
|
275
|
+
private retrySync(): Promise<SyncResult> {
|
|
276
|
+
const isBrowserOffline = typeof navigator !== 'undefined' && 'onLine' in navigator && !navigator.onLine;
|
|
277
|
+
return this.offlineMode === 'always' || isBrowserOffline ? Promise.resolve(NOTHING_SYNCED) : this.replayAll(false);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* One replay at a time per client. A call during one queues a single
|
|
282
|
+
* follow-up replay (eager if any caller asked for that), so an op queued
|
|
283
|
+
* or a reconnect that happens mid-replay is never missed.
|
|
284
|
+
*/
|
|
285
|
+
private replayAll(isEager: boolean): Promise<SyncResult> {
|
|
286
|
+
if (this.slug === null || this.offlineMode === 'always') {
|
|
287
|
+
return Promise.resolve(NOTHING_SYNCED);
|
|
288
|
+
}
|
|
289
|
+
const running = this.runningSync;
|
|
290
|
+
if (running === null) {
|
|
291
|
+
return this.startReplay(isEager);
|
|
292
|
+
}
|
|
293
|
+
this.isFollowingEager ||= isEager;
|
|
294
|
+
this.followingSync ??= running.catch(() => NOTHING_SYNCED).then(() => {
|
|
295
|
+
const isFollowUpEager = this.isFollowingEager;
|
|
296
|
+
this.isFollowingEager = false;
|
|
297
|
+
this.followingSync = null;
|
|
298
|
+
return this.startReplay(isFollowUpEager);
|
|
299
|
+
});
|
|
300
|
+
return this.followingSync;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private startReplay(isEager: boolean): Promise<SyncResult> {
|
|
304
|
+
const run = this.runReplay(isEager).finally(() => {
|
|
305
|
+
if (this.runningSync === run) {
|
|
306
|
+
this.runningSync = null;
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
this.runningSync = run;
|
|
310
|
+
return run;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private async runReplay(isEager: boolean): Promise<SyncResult> {
|
|
314
|
+
if (this.directSend !== null) {
|
|
315
|
+
await this.directSend.catch(() => undefined);
|
|
316
|
+
}
|
|
317
|
+
const report = await this.outbox.replay(isEager);
|
|
318
|
+
const conflicts = report.failed.map((failure) => ({ op: failure.entry, error: failure.error }));
|
|
319
|
+
if (report.stoppedBy !== 'connection') {
|
|
320
|
+
await this.flushEvents();
|
|
321
|
+
}
|
|
322
|
+
const result: SyncResult = { flushed: report.replayed.length, conflicts };
|
|
323
|
+
emitWindowEvent('hub:sync', { pending: this.pending(), ...result });
|
|
324
|
+
this.notifyStatus();
|
|
325
|
+
return result;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Send the buffered analytics events; a refused one is dropped (telemetry never blocks). */
|
|
329
|
+
private async flushEvents(): Promise<void> {
|
|
330
|
+
let events = this.store.getEvents();
|
|
331
|
+
for (let event = events[0]; event !== undefined; event = events[0]) {
|
|
332
|
+
try {
|
|
333
|
+
await this.rest.call('recordGameEvent', { ...this.gameFields(), type: event.type });
|
|
334
|
+
} catch (failure) {
|
|
335
|
+
if (isTransportFailure(failure)) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
events = events.slice(1);
|
|
340
|
+
this.store.setEvents(events);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Move a v1 copy's queued writes into the outbox, in their order. */
|
|
345
|
+
private importLegacyQueue(): void {
|
|
346
|
+
if (this.slug === null) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
for (const legacy of this.store.takeLegacyQueue()) {
|
|
351
|
+
const operation = legacyOperation(legacy);
|
|
352
|
+
if (operation !== undefined) {
|
|
353
|
+
this.outbox.add(this.outbox.stamp(operation.kind, operation.payload));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
} catch {
|
|
357
|
+
// A full outbox keeps what fitted; the client must still start.
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Follow the browser's connection, other tabs' outbox changes, and replay what an earlier page left queued. */
|
|
362
|
+
private listenToPage(): void {
|
|
363
|
+
window.addEventListener('online', () => {
|
|
364
|
+
this.setReachable(true);
|
|
365
|
+
});
|
|
366
|
+
window.addEventListener('offline', () => {
|
|
367
|
+
this.setReachable(false);
|
|
368
|
+
});
|
|
369
|
+
window.addEventListener('storage', (event) => {
|
|
370
|
+
if (event instanceof StorageEvent && this.outbox.entries.owns(event.key)) {
|
|
371
|
+
this.notifyStatus();
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
if (this.outbox.count() > 0) {
|
|
375
|
+
setTimeout(() => {
|
|
376
|
+
void this.sync();
|
|
377
|
+
}, 0);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Warm the offline cache for every game the player has touched, in one
|
|
383
|
+
* request, so going offline later still shows complete progress.
|
|
384
|
+
* Throttled to once per 30 s unless `isForced`; `null` when offline or throttled.
|
|
385
|
+
*/
|
|
386
|
+
async prefetchAll(isForced = false): Promise<SnapshotResponse | null> {
|
|
387
|
+
if (this.isOffline || (!isForced && Date.now() - this.lastPrefetchAt < PREFETCH_INTERVAL_MS)) {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
let snapshot: SnapshotResponse;
|
|
391
|
+
try {
|
|
392
|
+
snapshot = await this.rest.call('getMyOfflineSnapshot');
|
|
393
|
+
} catch (failure) {
|
|
394
|
+
if (isTransportFailure(failure)) {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
throw failure;
|
|
398
|
+
}
|
|
399
|
+
this.lastPrefetchAt = Date.now();
|
|
400
|
+
if (this.outbox.count() > 0) {
|
|
401
|
+
// Changes are still waiting to sync: the cache already holds them, and the snapshot doesn't yet.
|
|
402
|
+
return snapshot;
|
|
403
|
+
}
|
|
404
|
+
this.store.hydrate(snapshot);
|
|
405
|
+
emitWindowEvent('hub:prefetch', { games: Object.keys(snapshot.games).length });
|
|
406
|
+
return snapshot;
|
|
407
|
+
}
|
|
408
|
+
}
|