@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,171 +1,126 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The offline
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
readonly
|
|
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
|
-
| { readonly kind: '
|
|
44
|
-
| { readonly kind: '
|
|
45
|
-
| { readonly kind: '
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
case '
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
case '
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
return
|
|
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
|
-
/** The better of two scores on a board sorted `sort` (`desc` by default). */
|
|
128
|
-
export function betterScore(first: number, second: number, sort: SortDir | undefined): number {
|
|
129
|
-
return sort === 'asc' ? Math.min(first, second) : Math.max(first, second);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** The op that `queued` becomes when `incoming` collapses into it, or `undefined` when they don't collapse. */
|
|
133
|
-
function collapsedOp(queued: QueueOp, incoming: QueueOp): QueueOp | undefined {
|
|
134
|
-
if (queued.kind === 'incrStat' && incoming.kind === 'incrStat' && queued.key === incoming.key) {
|
|
135
|
-
return { ...queued, amount: queued.amount + incoming.amount };
|
|
136
|
-
}
|
|
137
|
-
if (queued.kind === 'maxStat' && incoming.kind === 'maxStat' && queued.key === incoming.key) {
|
|
138
|
-
return { ...queued, value: Math.max(queued.value, incoming.value) };
|
|
139
|
-
}
|
|
140
|
-
if (queued.kind === 'setStat' && incoming.kind === 'setStat' && queued.key === incoming.key) {
|
|
141
|
-
return { ...queued, value: incoming.value };
|
|
142
|
-
}
|
|
143
|
-
if (queued.kind === 'submitScore' && incoming.kind === 'submitScore' && queued.key === incoming.key) {
|
|
144
|
-
return { ...queued, score: betterScore(queued.score, incoming.score, incoming.opts.sort), opts: incoming.opts };
|
|
145
|
-
}
|
|
146
|
-
if (queued.kind === 'setProfile' && incoming.kind === 'setProfile') {
|
|
147
|
-
return { ...queued, patch: { ...queued.patch, ...incoming.patch } };
|
|
148
|
-
}
|
|
149
|
-
return undefined;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Whether an op writes save slot `slot`. */
|
|
153
|
-
function writesSlot(op: QueueOp, slot: string): boolean {
|
|
154
|
-
return (op.kind === 'putSave' || op.kind === 'deleteSave') && op.slot === slot;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* The queue with `incoming` added: a save or delete replaces any queued
|
|
159
|
-
* write to the same slot (and goes last); a compatible op merges into the
|
|
160
|
-
* first queued one in place; anything else is appended.
|
|
161
|
-
*/
|
|
162
|
-
export function enqueueOp(queue: readonly QueueOp[], incoming: QueueOp): QueueOp[] {
|
|
163
|
-
if (incoming.kind === 'putSave' || incoming.kind === 'deleteSave') {
|
|
164
|
-
return [...queue.filter((op) => !writesSlot(op, incoming.slot)), incoming];
|
|
165
|
-
}
|
|
166
|
-
const mergeAt = queue.findIndex((op) => collapsedOp(op, incoming) !== undefined);
|
|
167
|
-
if (mergeAt < 0) {
|
|
168
|
-
return [...queue, incoming];
|
|
169
|
-
}
|
|
170
|
-
return queue.map((op, i) => (i === mergeAt ? (collapsedOp(op, incoming) ?? op) : op));
|
|
171
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The v1 client's offline queue (`hub:<slug>:queue`): its op shapes and the
|
|
3
|
+
* reader for what a vendored v1 copy left in `localStorage`. This client
|
|
4
|
+
* queues into the outbox instead (`outbox.ts`) and moves any v1 ops there on
|
|
5
|
+
* start (`hub-kinds/legacy-ops.ts`), so nothing a v1 copy queued is lost.
|
|
6
|
+
*/
|
|
7
|
+
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
8
|
+
import type { SortDir } from '@diffenderfer-games/hub-contract';
|
|
9
|
+
import { isJson, isNullableString, isRecord, optionalNumber, optionalString, type UncheckedRecord } from '../storage/json-guards';
|
|
10
|
+
|
|
11
|
+
/** A score submission's options as queued (the metadata already JSON). */
|
|
12
|
+
export interface QueuedScoreOptions {
|
|
13
|
+
/** The board's title; only used when the board key is first created. */
|
|
14
|
+
readonly title?: string;
|
|
15
|
+
/** The sort direction; only used on board creation. Default `desc`. */
|
|
16
|
+
readonly sort?: SortDir;
|
|
17
|
+
/** Any JSON stored with the entry (a character, a run seed). */
|
|
18
|
+
readonly meta?: Json;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A profile change: either part may be left out. */
|
|
22
|
+
export interface ProfilePatch {
|
|
23
|
+
/** Shown to other players; `null` clears it. */
|
|
24
|
+
displayName?: string | null;
|
|
25
|
+
/** The game's own profile data. */
|
|
26
|
+
data?: Json;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A queued op's id and when it was queued. */
|
|
30
|
+
interface QueueStamp {
|
|
31
|
+
/** Unique within this page: `<epoch ms>-<sequence>`. */
|
|
32
|
+
readonly id: string;
|
|
33
|
+
/** When it was queued (epoch ms). */
|
|
34
|
+
readonly ts: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A queued write, before it is stamped. */
|
|
38
|
+
export type QueueOpInput =
|
|
39
|
+
| { readonly kind: 'putSave'; readonly slot: string; readonly data: Json; readonly label: string | null }
|
|
40
|
+
| { readonly kind: 'deleteSave'; readonly slot: string }
|
|
41
|
+
| { readonly kind: 'setStat'; readonly key: string; readonly value: number }
|
|
42
|
+
| { readonly kind: 'maxStat'; readonly key: string; readonly value: number }
|
|
43
|
+
| { readonly kind: 'incrStat'; readonly key: string; readonly amount: number }
|
|
44
|
+
| { readonly kind: 'submitScore'; readonly key: string; readonly score: number; readonly opts: QueuedScoreOptions }
|
|
45
|
+
| { readonly kind: 'setProfile'; readonly patch: ProfilePatch };
|
|
46
|
+
|
|
47
|
+
/** A queued write, as stored under `hub:<slug>:queue`. */
|
|
48
|
+
export type QueueOp = QueueOpInput & QueueStamp;
|
|
49
|
+
|
|
50
|
+
/** The score options in a stored op, or `undefined` when they are malformed. */
|
|
51
|
+
function readScoreOptions(stored: UncheckedRecord): QueuedScoreOptions | undefined {
|
|
52
|
+
const title = optionalString(stored.title);
|
|
53
|
+
const sort = stored.sort === 'asc' || stored.sort === 'desc' ? stored.sort : undefined;
|
|
54
|
+
if (stored.meta !== undefined && !isJson(stored.meta)) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
return { ...(title === undefined ? {} : { title }), ...(sort === undefined ? {} : { sort }), ...(stored.meta === undefined ? {} : { meta: stored.meta }) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The profile patch in a stored op, or `undefined` when it is malformed. */
|
|
61
|
+
function readProfilePatch(stored: UncheckedRecord): ProfilePatch | undefined {
|
|
62
|
+
if ((stored.displayName !== undefined && !isNullableString(stored.displayName)) || (stored.data !== undefined && !isJson(stored.data))) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
const patch: ProfilePatch = {};
|
|
66
|
+
if (stored.displayName !== undefined) {
|
|
67
|
+
patch.displayName = stored.displayName;
|
|
68
|
+
}
|
|
69
|
+
if (stored.data !== undefined) {
|
|
70
|
+
patch.data = stored.data;
|
|
71
|
+
}
|
|
72
|
+
return patch;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One stored op's write, or `undefined` when it is not a known, well-formed op. */
|
|
76
|
+
function readOpInput(stored: UncheckedRecord): QueueOpInput | undefined {
|
|
77
|
+
const slot = optionalString(stored.slot);
|
|
78
|
+
const key = optionalString(stored.key);
|
|
79
|
+
const statValue = optionalNumber(stored.value);
|
|
80
|
+
switch (stored.kind) {
|
|
81
|
+
case 'putSave':
|
|
82
|
+
return slot !== undefined && isJson(stored.data) && isNullableString(stored.label) ? { kind: 'putSave', slot, data: stored.data, label: stored.label } : undefined;
|
|
83
|
+
case 'deleteSave':
|
|
84
|
+
return slot === undefined ? undefined : { kind: 'deleteSave', slot };
|
|
85
|
+
case 'setStat':
|
|
86
|
+
case 'maxStat':
|
|
87
|
+
return key !== undefined && statValue !== undefined ? { kind: stored.kind, key, value: statValue } : undefined;
|
|
88
|
+
case 'incrStat': {
|
|
89
|
+
const amount = optionalNumber(stored.amount);
|
|
90
|
+
return key !== undefined && amount !== undefined ? { kind: 'incrStat', key, amount } : undefined;
|
|
91
|
+
}
|
|
92
|
+
case 'submitScore': {
|
|
93
|
+
const score = optionalNumber(stored.score);
|
|
94
|
+
const opts = isRecord(stored.opts) ? readScoreOptions(stored.opts) : undefined;
|
|
95
|
+
return key !== undefined && score !== undefined && opts !== undefined ? { kind: 'submitScore', key, score, opts } : undefined;
|
|
96
|
+
}
|
|
97
|
+
case 'setProfile': {
|
|
98
|
+
const patch = isRecord(stored.patch) ? readProfilePatch(stored.patch) : undefined;
|
|
99
|
+
return patch === undefined ? undefined : { kind: 'setProfile', patch };
|
|
100
|
+
}
|
|
101
|
+
default:
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The well-formed ops of a stored queue, in order; anything malformed is dropped. */
|
|
107
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: a queue read back from localStorage (possibly written by a v1 client)
|
|
108
|
+
export function readQueue(stored: unknown): QueueOp[] {
|
|
109
|
+
if (!Array.isArray(stored)) {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
return stored.flatMap((entry): QueueOp[] => {
|
|
113
|
+
if (!isRecord(entry)) {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
const input = readOpInput(entry);
|
|
117
|
+
const id = optionalString(entry.id);
|
|
118
|
+
const ts = optionalNumber(entry.ts);
|
|
119
|
+
return input === undefined || id === undefined || ts === undefined ? [] : [{ ...input, id, ts }];
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The better of two scores on a board sorted `sort` (`desc` by default). */
|
|
124
|
+
export function betterScore(first: number, second: number, sort: SortDir | undefined): number {
|
|
125
|
+
return sort === 'asc' ? Math.min(first, second) : Math.max(first, second);
|
|
126
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One replayer at a time per game, across tabs and across the client copies
|
|
3
|
+
* on one page: a Web Lock where the browser has them, else a lease in
|
|
4
|
+
* `localStorage` that its holder renews while it works and that lapses if
|
|
5
|
+
* the tab dies. A replayer that can't get it simply doesn't run; the holder
|
|
6
|
+
* replays for everyone.
|
|
7
|
+
*/
|
|
8
|
+
import { isRecord, optionalNumber, optionalString, parseJsonText } from '../storage/json-guards';
|
|
9
|
+
import type { KeyValueStore } from '../storage/key-value';
|
|
10
|
+
|
|
11
|
+
/** Runs work while holding the lease. */
|
|
12
|
+
export interface ReplayLease {
|
|
13
|
+
/** Run `work` holding the lease; `false` (without running it) when another replayer holds it. */
|
|
14
|
+
hold(work: () => Promise<void>): Promise<boolean>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** How long a `localStorage` lease lasts without renewal. */
|
|
18
|
+
export const STORAGE_LEASE_MS = 15_000;
|
|
19
|
+
|
|
20
|
+
/** How often the holder renews it. */
|
|
21
|
+
const RENEW_EVERY_MS = 5_000;
|
|
22
|
+
|
|
23
|
+
/** A lease on the Web Locks API. */
|
|
24
|
+
function webLockLease(locks: LockManager, name: string): ReplayLease {
|
|
25
|
+
return {
|
|
26
|
+
hold: (work) => locks.request(name, { ifAvailable: true }, async (lock) => {
|
|
27
|
+
if (lock === null) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
await work();
|
|
31
|
+
return true;
|
|
32
|
+
}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The lease's holder and expiry, or `undefined` when there is none. */
|
|
37
|
+
function readLease(text: string | null): { readonly owner: string; readonly until: number } | undefined {
|
|
38
|
+
const stored = text === null ? undefined : parseJsonText(text);
|
|
39
|
+
if (!isRecord(stored)) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
const owner = optionalString(stored.owner);
|
|
43
|
+
const until = optionalNumber(stored.until);
|
|
44
|
+
return owner === undefined || until === undefined ? undefined : { owner, until };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A lease kept in `store` under `key`, for pages without Web Locks. */
|
|
48
|
+
export function storageLease(store: KeyValueStore, key: string, now: () => number = Date.now): ReplayLease {
|
|
49
|
+
const owner = `${String(now())}-${Math.random().toString(36).slice(2)}`;
|
|
50
|
+
const claim = (): boolean => {
|
|
51
|
+
const current = readLease(store.read(key));
|
|
52
|
+
if (current !== undefined && current.owner !== owner && current.until > now()) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
store.write(key, JSON.stringify({ owner, until: now() + STORAGE_LEASE_MS }));
|
|
56
|
+
return readLease(store.read(key))?.owner === owner;
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
async hold(work) {
|
|
60
|
+
if (!claim()) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const renewal = setInterval(claim, RENEW_EVERY_MS);
|
|
64
|
+
try {
|
|
65
|
+
await work();
|
|
66
|
+
} finally {
|
|
67
|
+
clearInterval(renewal);
|
|
68
|
+
if (readLease(store.read(key))?.owner === owner) {
|
|
69
|
+
store.remove(key);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The page's Web Locks, when it has them. */
|
|
78
|
+
function pageLocks(): LockManager | undefined {
|
|
79
|
+
return typeof navigator !== 'undefined' && 'locks' in navigator ? navigator.locks : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The lease for game `slug`'s outbox: a Web Lock when available, else one in `store`. */
|
|
83
|
+
export function createReplayLease(slug: string, store: KeyValueStore): ReplayLease {
|
|
84
|
+
const name = `hub:${slug}:outbox-lease`;
|
|
85
|
+
const locks = pageLocks();
|
|
86
|
+
return locks === undefined ? storageLease(store, name) : webLockLease(locks, name);
|
|
87
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a failed replay does next, and how long it waits:
|
|
3
|
+
*
|
|
4
|
+
* - `offline`: the hub (or the network) can't be reached. The op stays at
|
|
5
|
+
* the head of the queue and replay waits for the connection.
|
|
6
|
+
* - `retry`: the hub answered with a passing problem (408, 429, 5xx) or a
|
|
7
|
+
* game handler's fetch failed. The op stays at the head and is retried with
|
|
8
|
+
* backoff, up to {@link MAX_RETRIES} times, then dropped.
|
|
9
|
+
* - `drop`: the hub refused it (any other status) or the handler threw
|
|
10
|
+
* something else. The op is dropped and reported, and the queue moves on.
|
|
11
|
+
*/
|
|
12
|
+
import { HubError } from '../errors';
|
|
13
|
+
|
|
14
|
+
/** What to do after a failed replay. */
|
|
15
|
+
export type FailureVerdict = 'offline' | 'retry' | 'drop';
|
|
16
|
+
|
|
17
|
+
/** The most tries for an op that keeps getting a passing failure. */
|
|
18
|
+
export const MAX_RETRIES = 8;
|
|
19
|
+
|
|
20
|
+
/** The first retry's delay. */
|
|
21
|
+
const BASE_DELAY_MS = 1000;
|
|
22
|
+
|
|
23
|
+
/** The longest delay between tries. */
|
|
24
|
+
const MAX_DELAY_MS = 60_000;
|
|
25
|
+
|
|
26
|
+
/** Statuses that mean "try again later". */
|
|
27
|
+
const PASSING_STATUSES: ReadonlySet<number> = new Set([408, 425, 429]);
|
|
28
|
+
|
|
29
|
+
/** The verdict for a failure. */
|
|
30
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: whatever a replay handler threw
|
|
31
|
+
export function verdictFor(failure: unknown): FailureVerdict {
|
|
32
|
+
if (failure instanceof HubError) {
|
|
33
|
+
if (failure.status === undefined) {
|
|
34
|
+
return 'offline';
|
|
35
|
+
}
|
|
36
|
+
return failure.status >= 500 || PASSING_STATUSES.has(failure.status) ? 'retry' : 'drop';
|
|
37
|
+
}
|
|
38
|
+
// A game handler's own fetch rejects with a TypeError when the network is down.
|
|
39
|
+
return failure instanceof TypeError ? 'retry' : 'drop';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The delay before try `attempts + 1`: doubling from 1 s, capped at 60 s, with jitter so tabs and players spread out. */
|
|
43
|
+
export function backoffMs(attempts: number, random: () => number = Math.random): number {
|
|
44
|
+
const ceiling = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** Math.max(0, attempts - 1));
|
|
45
|
+
return Math.round(ceiling / 2 + (ceiling / 2) * random());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A failure's message, for reports. */
|
|
49
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: whatever a replay handler threw
|
|
50
|
+
export function failureMessage(failure: unknown): string {
|
|
51
|
+
return failure instanceof Error ? failure.message : String(failure);
|
|
52
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who the page is signed in as, as the hub tells it, so `hub.userId()` and
|
|
3
|
+
* `hub.avatar` follow the account without the game calling `hub.me()`:
|
|
4
|
+
*
|
|
5
|
+
* - the menu's `hub:account` (every refresh: after it makes a new guest's
|
|
6
|
+
* account, after sign-in) and `hub:auth` (the account changed);
|
|
7
|
+
* - the social runtime's {@link SESSION_EVENT} each time its socket says
|
|
8
|
+
* hello (connected, or connected again after a drop);
|
|
9
|
+
* - this client's own sign-in, sign-up, claim and sign-out.
|
|
10
|
+
*
|
|
11
|
+
* One per page: every bundle's copy hears the same window events.
|
|
12
|
+
*/
|
|
13
|
+
import { readField } from '../realtime/cross-bundle';
|
|
14
|
+
import { SESSION_EVENT } from '../realtime/runtime-api';
|
|
15
|
+
import { isBrowserPage } from './page';
|
|
16
|
+
|
|
17
|
+
/** The window events that say who is signed in (`{ user }`). */
|
|
18
|
+
const ACCOUNT_EVENTS = ['hub:account', 'hub:auth'] as const;
|
|
19
|
+
|
|
20
|
+
/** The signed-in player as the page heard it. */
|
|
21
|
+
export interface PageUser {
|
|
22
|
+
readonly id: number;
|
|
23
|
+
/** Their look code when the source carried it, else `null`. */
|
|
24
|
+
readonly look: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Where the news came from: an account event, or a socket hello (a time to catch up on what was missed). */
|
|
28
|
+
export type AccountSource = 'account' | 'session';
|
|
29
|
+
|
|
30
|
+
/** Hears every report of who is signed in. */
|
|
31
|
+
export type AccountListener = (user: PageUser | null, source: AccountSource) => void;
|
|
32
|
+
|
|
33
|
+
/** The page's account, as last heard. */
|
|
34
|
+
export class PageAccount {
|
|
35
|
+
/** `undefined` until something said; `null` when signed out. */
|
|
36
|
+
private heard: PageUser | null | undefined = undefined;
|
|
37
|
+
private readonly listeners = new Set<AccountListener>();
|
|
38
|
+
private isListening = false;
|
|
39
|
+
|
|
40
|
+
constructor() {
|
|
41
|
+
this.listen();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The signed-in player, `null` when signed out, `undefined` before anything said. */
|
|
45
|
+
get user(): PageUser | null | undefined {
|
|
46
|
+
this.listen();
|
|
47
|
+
return this.heard;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Record who is signed in (this client's own sign-in or sign-out) and tell the listeners. */
|
|
51
|
+
note(user: PageUser | null, source: AccountSource = 'account'): void {
|
|
52
|
+
this.heard = user;
|
|
53
|
+
for (const listener of [...this.listeners]) {
|
|
54
|
+
listener(user, source);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Hear every report; returns the unsubscribe function. */
|
|
59
|
+
on(listener: AccountListener): () => void {
|
|
60
|
+
this.listen();
|
|
61
|
+
this.listeners.add(listener);
|
|
62
|
+
return () => {
|
|
63
|
+
this.listeners.delete(listener);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Listen to the window events (at once in a page; made before a window existed, on first use). */
|
|
68
|
+
private listen(): void {
|
|
69
|
+
if (this.isListening || !isBrowserPage()) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
this.isListening = true;
|
|
73
|
+
for (const eventName of ACCOUNT_EVENTS) {
|
|
74
|
+
window.addEventListener(eventName, (event) => { this.onEvent(event, 'account'); });
|
|
75
|
+
}
|
|
76
|
+
window.addEventListener(SESSION_EVENT, (event) => { this.onEvent(event, 'session'); });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private onEvent(event: Event, source: AccountSource): void {
|
|
80
|
+
if (!(event instanceof CustomEvent)) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const user = readUser(event.detail);
|
|
84
|
+
if (user !== undefined) {
|
|
85
|
+
this.note(user, source);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A page user from an account the hub sent (`look` when it has one). */
|
|
91
|
+
export function toPageUser(user: { readonly id: number; readonly look?: string | undefined }): PageUser {
|
|
92
|
+
return { id: user.id, look: lookOrNull(user.look) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A non-empty look code, else `null`. */
|
|
96
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: a field of another bundle's event detail
|
|
97
|
+
function lookOrNull(look: unknown): string | null {
|
|
98
|
+
return typeof look === 'string' && look !== '' ? look : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The user in a `{ user }` detail: the user, `null` (signed out), or `undefined` (it doesn't say). */
|
|
102
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: another bundle's event detail, read field by field
|
|
103
|
+
function readUser(detail: unknown): PageUser | null | undefined {
|
|
104
|
+
const user = readField(detail, 'user');
|
|
105
|
+
if (user === null) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const id = readField(user, 'id');
|
|
109
|
+
if (typeof id !== 'number' || !Number.isInteger(id)) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
return { id, look: lookOrNull(readField(user, 'look')) };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let pageAccountSingleton: PageAccount | null = null;
|
|
116
|
+
|
|
117
|
+
/** The page's account tracker (made on first use; the hub client makes it at load). */
|
|
118
|
+
export function pageAccount(): PageAccount {
|
|
119
|
+
pageAccountSingleton ??= new PageAccount();
|
|
120
|
+
return pageAccountSingleton;
|
|
121
|
+
}
|