@diffenderfer-games/hub 2.0.0-next.5 → 2.0.0-next.7
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 +65 -19
- package/README.md +3 -6
- package/dist/auto.d.ts +5 -5
- package/dist/auto.js +2 -2
- package/dist/avatar-pixi.d.ts +147 -0
- package/dist/avatar-pixi.d.ts.map +1 -0
- package/dist/avatar-pixi.js +280 -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 +487 -0
- package/dist/avatar.js.map +1 -0
- package/dist/daily.d.ts +2 -2
- package/dist/doctor/cli.mjs +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/input.d.ts +2 -2
- 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 +134 -0
- package/dist/shared/atlas.d.ts.map +1 -0
- package/dist/shared/avatar.d.ts +117 -0
- package/dist/shared/avatar.d.ts.map +1 -0
- package/dist/shared/bridge.js +13 -5
- package/dist/shared/bridge.js.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.d.ts +129 -18
- package/dist/shared/daily.d.ts.map +1 -1
- package/dist/shared/daily.js +120 -26
- 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 +1 -39
- package/dist/shared/globals.js.map +1 -1
- package/dist/shared/index.d.ts +503 -14
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/input.d.ts +31 -13
- package/dist/shared/input.d.ts.map +1 -1
- package/dist/shared/input.js +40 -2
- 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/live-canvas.js +62 -0
- package/dist/shared/live-canvas.js.map +1 -0
- package/dist/shared/mp.js +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.js +1 -1
- package/dist/shared/rooms.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 +55 -4
- package/dist/shared/social.d.ts.map +1 -1
- package/dist/shared/src.js +2020 -144
- package/dist/shared/src.js.map +1 -1
- package/dist/shared/svg-markup.js +1945 -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 +166 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/social.d.ts +2 -2
- package/dist/social.js +2 -2
- package/dist/turns.d.ts +39 -0
- package/dist/turns.d.ts.map +1 -0
- package/dist/turns.js +117 -0
- package/dist/turns.js.map +1 -0
- package/package.json +33 -3
- package/src/avatar/adapters/pixi-avatar.ts +73 -0
- package/src/avatar/adapters/pixi-crowd.ts +302 -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 +236 -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 +303 -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 +60 -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 +84 -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 +62 -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 +8 -0
- package/src/core/errors.ts +7 -1
- package/src/core/hub-progress.ts +39 -8
- package/src/core/hub.ts +174 -6
- package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
- package/src/core/offline/hub-kinds/index.ts +9 -0
- package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
- package/src/core/page-account.ts +121 -0
- package/src/core/rest/routes.gen.ts +31 -1
- package/src/core/resume/hub-resume.ts +39 -5
- package/src/core/resume/resume-types.ts +2 -2
- 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/turns/hub-turns.ts +140 -0
- package/src/core/turns/turn-game.ts +388 -0
- package/src/core/version/client-version.gen.ts +2 -2
- package/src/daily/daily-system.ts +112 -13
- package/src/daily/page-daily.ts +2 -2
- package/src/daily/types.ts +46 -9
- package/src/doctor/hub-version-fetch.ts +43 -43
- package/src/index.ts +10 -1
- package/src/input/input-system.ts +10 -1
- package/src/input/navigator.ts +11 -0
- package/src/input.ts +1 -0
- package/src/overlay/overlay.ts +25 -1
- package/src/realtime/runtime-api.ts +14 -3
- package/src/social.ts +1 -0
- package/src/turns-kit/check-rules.ts +182 -0
- package/src/turns.ts +13 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hub.avatar` (avatars.md §6): players' looks without the renderer. Codes
|
|
3
|
+
* arrive in payloads (seats, `PlayerLite`, racers), so `from()` needs no
|
|
4
|
+
* request; `of()` and `many()` batch and cache the rest; your own look is
|
|
5
|
+
* cached on the device so it works offline. `load()` brings in the renderer
|
|
6
|
+
* (`@diffenderfer-games/hub/avatar`) and its art when a game wants to draw.
|
|
7
|
+
*
|
|
8
|
+
* It follows the page's account (the menu's account events, the runtime's
|
|
9
|
+
* socket hello, this client's sign-ins): when your account appears or
|
|
10
|
+
* changes, or the hello says your look is not the one cached, your look is
|
|
11
|
+
* fetched again and `change` fires. Each hello also re-checks the players
|
|
12
|
+
* this page asked about, so a change sent while the socket was down is not
|
|
13
|
+
* missed.
|
|
14
|
+
*/
|
|
15
|
+
import type { MyAvatar } from '@diffenderfer-games/hub-contract';
|
|
16
|
+
import { encodeLook, type AvatarLook } from '@diffenderfer-games/hub-contract/avatar';
|
|
17
|
+
import type * as AvatarEntry from '../../avatar';
|
|
18
|
+
import { pageBridge } from '../../realtime/bridge';
|
|
19
|
+
import { createRuntimeOpener } from '../../realtime/runtime-opener';
|
|
20
|
+
import type { AccountListener, PageUser } from '../page-account';
|
|
21
|
+
import { AvatarCache } from './avatar-cache';
|
|
22
|
+
import { botRef, refFrom, refOf, type CarriesLook } from './avatar-ref';
|
|
23
|
+
import type { AvatarChange, AvatarRef, DesignerResult, OpenDesignerOptions } from './avatar-types';
|
|
24
|
+
import { createPageStore } from '../storage/key-value';
|
|
25
|
+
|
|
26
|
+
/** What `hub.avatar` needs from its client. */
|
|
27
|
+
export interface AvatarHost {
|
|
28
|
+
/** The signed-in user's id, or `null` (`hub.userId()`). */
|
|
29
|
+
readonly currentUserId: () => number | null;
|
|
30
|
+
/** Hear every report of who is signed in (the page's account). */
|
|
31
|
+
readonly onAccount: (listener: AccountListener) => () => void;
|
|
32
|
+
/** `GET /avatar/me`; rejects when offline. */
|
|
33
|
+
readonly fetchMine: () => Promise<MyAvatar>;
|
|
34
|
+
/** `POST /avatar/batch`. */
|
|
35
|
+
readonly fetchCodes: (ids: readonly number[]) => Promise<Record<string, string>>;
|
|
36
|
+
/** Queue a save of your look (sent now when online, replayed later when not). */
|
|
37
|
+
readonly queueSave: (code: string, savedAt: number) => Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The renderer module `load()` resolves with. */
|
|
41
|
+
export type AvatarModule = typeof AvatarEntry;
|
|
42
|
+
|
|
43
|
+
type ChangeListener = (change: AvatarChange) => void;
|
|
44
|
+
|
|
45
|
+
const BATCH_MAX = 100;
|
|
46
|
+
const openWithRuntime = createRuntimeOpener(pageBridge);
|
|
47
|
+
|
|
48
|
+
/** The look `mine()` gives before the account exists (a new guest's is being made). */
|
|
49
|
+
const NO_ACCOUNT_YET = botRef('you');
|
|
50
|
+
|
|
51
|
+
/** `hub.avatar`. */
|
|
52
|
+
export class HubAvatar {
|
|
53
|
+
private readonly host: AvatarHost;
|
|
54
|
+
private readonly cache = new AvatarCache(createPageStore());
|
|
55
|
+
private readonly listeners = new Set<ChangeListener>();
|
|
56
|
+
private pending = new Map<number, ((code: string) => void)[]>();
|
|
57
|
+
private flushQueued = false;
|
|
58
|
+
private detachRuntime: (() => void) | null = null;
|
|
59
|
+
/** The account the last report named. */
|
|
60
|
+
private lastUserId: number | null;
|
|
61
|
+
/** The code of your look the page was last given (`mine`, `mineSync`, `change`). */
|
|
62
|
+
private mineShown: string | null = null;
|
|
63
|
+
/** Players this page asked about or was handed (re-checked on each socket hello). */
|
|
64
|
+
private readonly seen = new Set<number>();
|
|
65
|
+
|
|
66
|
+
constructor(host: AvatarHost) {
|
|
67
|
+
this.host = host;
|
|
68
|
+
this.lastUserId = host.currentUserId();
|
|
69
|
+
host.onAccount((user, source) => {
|
|
70
|
+
this.onAccount(user, source === 'session');
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Your look (your default if you never designed one). Works offline from
|
|
76
|
+
* the device cache. Before your account exists (a new guest's is made just
|
|
77
|
+
* after the game starts) it resolves at once with a default look, and
|
|
78
|
+
* `change` brings yours when the account is there.
|
|
79
|
+
*/
|
|
80
|
+
async mine(): Promise<AvatarRef> {
|
|
81
|
+
const userId = this.host.currentUserId();
|
|
82
|
+
if (userId === null) {
|
|
83
|
+
return this.showMine(NO_ACCOUNT_YET);
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const mine = await this.host.fetchMine();
|
|
87
|
+
return this.noteMine(userId, mine);
|
|
88
|
+
} catch (failure) {
|
|
89
|
+
const cached = this.mineSync();
|
|
90
|
+
if (cached !== null) {
|
|
91
|
+
return cached;
|
|
92
|
+
}
|
|
93
|
+
throw failure;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Your cached look, or your default; `null` only while nobody is signed in. */
|
|
98
|
+
mineSync(): AvatarRef | null {
|
|
99
|
+
const userId = this.host.currentUserId();
|
|
100
|
+
if (userId === null) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
const cached = this.cache.readMine(userId);
|
|
104
|
+
return this.showMine(cached === null ? botRef(userId) : refOf(cached.code, cached.isDefault));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Any player's look: from the cache, else batched with other requests in the same tick. */
|
|
108
|
+
of(userId: number): Promise<AvatarRef> {
|
|
109
|
+
this.seen.add(userId);
|
|
110
|
+
const cached = this.cache.codeOf(userId);
|
|
111
|
+
if (cached !== undefined) {
|
|
112
|
+
return Promise.resolve(refOf(cached));
|
|
113
|
+
}
|
|
114
|
+
return new Promise((resolve) => {
|
|
115
|
+
const waiting = this.pending.get(userId) ?? [];
|
|
116
|
+
waiting.push((code) => {
|
|
117
|
+
resolve(code === '' ? botRef(userId) : refOf(code));
|
|
118
|
+
});
|
|
119
|
+
this.pending.set(userId, waiting);
|
|
120
|
+
this.scheduleFlush();
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Many players' looks at once. */
|
|
125
|
+
async many(userIds: readonly number[]): Promise<Map<number, AvatarRef>> {
|
|
126
|
+
const refs = await Promise.all(userIds.map(async (id) => [id, await this.of(id)] as const));
|
|
127
|
+
return new Map(refs);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The look a payload already carries (a `RoomSeat`, `PlayerLite`, racer or ticket user). No request. */
|
|
131
|
+
from(carrier: CarriesLook): AvatarRef {
|
|
132
|
+
const ref = refFrom(carrier);
|
|
133
|
+
const id = carrier.userId ?? carrier.id ?? carrier.uid;
|
|
134
|
+
if (id !== undefined && carrier.look !== undefined) {
|
|
135
|
+
this.seen.add(id);
|
|
136
|
+
this.cache.remember(id, carrier.look);
|
|
137
|
+
}
|
|
138
|
+
return ref;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A seeded default for bots, previews and local pass-and-play seats: `bot('local:2')`. */
|
|
142
|
+
bot(seed: string | number): AvatarRef {
|
|
143
|
+
return botRef(seed);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Save your look (designer code paths; queued offline, latest wins). */
|
|
147
|
+
async save(look: AvatarLook): Promise<AvatarRef> {
|
|
148
|
+
const code = encodeLook(look);
|
|
149
|
+
const savedAt = Date.now();
|
|
150
|
+
const userId = this.host.currentUserId();
|
|
151
|
+
if (userId !== null) {
|
|
152
|
+
this.noteMine(userId, { code, isDefault: false, updatedAt: savedAt });
|
|
153
|
+
}
|
|
154
|
+
await this.host.queueSave(code, savedAt);
|
|
155
|
+
return refOf(code);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Hear about look changes (yours, friends', your room's, and yours when
|
|
160
|
+
* your account appears). Returns the unsubscribe function. Wait for this
|
|
161
|
+
* rather than polling `mine()`.
|
|
162
|
+
*/
|
|
163
|
+
on(_event: 'change', listener: ChangeListener): () => void {
|
|
164
|
+
this.listeners.add(listener);
|
|
165
|
+
this.attachRuntime();
|
|
166
|
+
return () => {
|
|
167
|
+
this.listeners.delete(listener);
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Open "Make your avatar" over the game; resolves when it closes. */
|
|
172
|
+
openDesigner(options: OpenDesignerOptions = {}): Promise<DesignerResult> {
|
|
173
|
+
return new Promise((resolve) => {
|
|
174
|
+
openWithRuntime((realtime) => {
|
|
175
|
+
if (realtime.avatar === undefined) {
|
|
176
|
+
resolve({ saved: false });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
void realtime.avatar.openDesigner(options).then(resolve, () => {
|
|
180
|
+
resolve({ saved: false });
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Loads the renderer and its art; resolves when rendering is synchronous. */
|
|
187
|
+
async load(): Promise<AvatarModule> {
|
|
188
|
+
const avatar = await import('../../avatar');
|
|
189
|
+
await avatar.ready();
|
|
190
|
+
return avatar;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Remember the ref the page was given for your look. */
|
|
194
|
+
private showMine(ref: AvatarRef): AvatarRef {
|
|
195
|
+
this.mineShown = ref.code;
|
|
196
|
+
return ref;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Cache your look; `change` fires when it differs from the one the page last had. */
|
|
200
|
+
private noteMine(userId: number, mine: Pick<MyAvatar, 'code' | 'isDefault' | 'updatedAt'>): AvatarRef {
|
|
201
|
+
const before = this.mineShown ?? this.cache.readMine(userId)?.code ?? null;
|
|
202
|
+
this.cache.writeMine({ userId, code: mine.code, isDefault: mine.isDefault, updatedAt: mine.updatedAt });
|
|
203
|
+
const ref = this.showMine(refOf(mine.code, mine.isDefault));
|
|
204
|
+
if (mine.code !== before) {
|
|
205
|
+
this.emit({ userId, ref });
|
|
206
|
+
}
|
|
207
|
+
return ref;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Someone said who is signed in: fetch your look when the account is new or its look isn't the cached one. */
|
|
211
|
+
private onAccount(user: PageUser | null, isHello: boolean): void {
|
|
212
|
+
const userId = user === null || user.id === 0 ? null : user.id;
|
|
213
|
+
const isNewAccount = userId !== this.lastUserId;
|
|
214
|
+
this.lastUserId = userId;
|
|
215
|
+
if (user === null || userId === null) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (isNewAccount || (user.look !== null && user.look !== this.cache.readMine(userId)?.code)) {
|
|
219
|
+
void this.refreshMine(userId);
|
|
220
|
+
}
|
|
221
|
+
if (isHello) {
|
|
222
|
+
void this.recheckSeen();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private async refreshMine(userId: number): Promise<void> {
|
|
227
|
+
const mine = await this.host.fetchMine().catch(() => null);
|
|
228
|
+
if (mine !== null && this.host.currentUserId() === userId) {
|
|
229
|
+
this.noteMine(userId, mine);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The socket (re)connected: a change sent while it was down never arrived, so ask again. */
|
|
234
|
+
private async recheckSeen(): Promise<void> {
|
|
235
|
+
if (this.listeners.size === 0 || this.seen.size === 0) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const codes = await this.fetchCodes([...this.seen]);
|
|
239
|
+
for (const [userId, code] of codes) {
|
|
240
|
+
if (code !== '' && code !== this.cache.codeOf(userId)) {
|
|
241
|
+
this.cache.remember(userId, code);
|
|
242
|
+
this.emit({ userId, ref: refOf(code) });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private emit(change: AvatarChange): void {
|
|
248
|
+
for (const listener of this.listeners) {
|
|
249
|
+
listener(change);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private attachRuntime(): void {
|
|
254
|
+
if (this.detachRuntime !== null) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
this.detachRuntime = pageBridge.subscribe((realtime) => realtime.avatar?.on('change', ({ userId, code }) => {
|
|
258
|
+
if (userId === this.host.currentUserId()) {
|
|
259
|
+
this.noteMine(userId, { code, isDefault: false, updatedAt: Date.now() });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
this.cache.remember(userId, code);
|
|
263
|
+
this.emit({ userId, ref: refOf(code) });
|
|
264
|
+
}) ?? (() => undefined));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private scheduleFlush(): void {
|
|
268
|
+
if (this.flushQueued) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
this.flushQueued = true;
|
|
272
|
+
queueMicrotask(() => {
|
|
273
|
+
this.flushQueued = false;
|
|
274
|
+
void this.flush();
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private async flush(): Promise<void> {
|
|
279
|
+
const batch = this.pending;
|
|
280
|
+
this.pending = new Map();
|
|
281
|
+
const codes = await this.fetchCodes([...batch.keys()]);
|
|
282
|
+
for (const [id, waiting] of batch) {
|
|
283
|
+
const code = codes.get(id) ?? '';
|
|
284
|
+
if (code !== '') {
|
|
285
|
+
this.cache.remember(id, code);
|
|
286
|
+
}
|
|
287
|
+
for (const resolve of waiting) {
|
|
288
|
+
resolve(code);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Players' codes, in batches; offline or failing, the missing ones are left out (they get their default). */
|
|
294
|
+
private async fetchCodes(ids: readonly number[]): Promise<Map<number, string>> {
|
|
295
|
+
const codes = new Map<number, string>();
|
|
296
|
+
for (let i = 0; i < ids.length; i += BATCH_MAX) {
|
|
297
|
+
const slice = ids.slice(i, i + BATCH_MAX);
|
|
298
|
+
const answered = await this.host.fetchCodes(slice).catch((): Record<string, string> => ({}));
|
|
299
|
+
for (const id of slice) {
|
|
300
|
+
const code = answered[String(id)];
|
|
301
|
+
if (code !== undefined) {
|
|
302
|
+
codes.set(id, code);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return codes;
|
|
307
|
+
}
|
|
308
|
+
}
|
package/src/core/connection.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { LocalStore } from './offline/local-store';
|
|
|
12
12
|
import { OfflineApi } from './offline/offline-api';
|
|
13
13
|
import { Outbox, type OfflineOp } from './offline/outbox';
|
|
14
14
|
import { LocalResumeStore } from './resume/local-resume';
|
|
15
|
+
import { LocalDailyStore } from './saved/local-saved';
|
|
15
16
|
import { absoluteApiBase, detectApiBase, detectSlug, emitWindowEvent, isBrowserPage } from './page';
|
|
16
17
|
import { createRestClient, type RestClient } from './rest/rest-client';
|
|
17
18
|
import { createPageStore } from './storage/key-value';
|
|
@@ -65,6 +66,8 @@ export class HubConnection {
|
|
|
65
66
|
protected readonly outbox: Outbox;
|
|
66
67
|
/** The device copies of every game's resume slot (`hub.resume`). */
|
|
67
68
|
protected readonly resumeStore: LocalResumeStore;
|
|
69
|
+
/** The device copies of daily challenges in progress (every game's). */
|
|
70
|
+
protected readonly dailyStore: LocalDailyStore;
|
|
68
71
|
private readonly offlineMode: NonNullable<HubOptions['offline']>;
|
|
69
72
|
private isReachable = true;
|
|
70
73
|
private runningSync: Promise<SyncResult> | null = null;
|
|
@@ -90,6 +93,7 @@ export class HubConnection {
|
|
|
90
93
|
const pageStore = createPageStore();
|
|
91
94
|
this.store = new LocalStore(this.slug ?? 'app', pageStore);
|
|
92
95
|
this.resumeStore = new LocalResumeStore(pageStore);
|
|
96
|
+
this.dailyStore = new LocalDailyStore(pageStore);
|
|
93
97
|
this.outbox = new Outbox({
|
|
94
98
|
slug: this.slug ?? 'app',
|
|
95
99
|
store: pageStore,
|
|
@@ -108,9 +112,13 @@ export class HubConnection {
|
|
|
108
112
|
registerHubKinds(this.outbox, {
|
|
109
113
|
game: () => this.gameFields(),
|
|
110
114
|
onDailyComplete: (payload, completion) => {
|
|
115
|
+
if (this.slug !== null) {
|
|
116
|
+
this.dailyStore.remove(this.slug, payload.day);
|
|
117
|
+
}
|
|
111
118
|
emitWindowEvent('hub:daily-complete', { day: payload.day, count: completion.count, rank: completion.rank, meta: payload.meta });
|
|
112
119
|
},
|
|
113
120
|
resume: this.resumeStore,
|
|
121
|
+
dailies: this.dailyStore,
|
|
114
122
|
cloudSaves: this.store,
|
|
115
123
|
});
|
|
116
124
|
this.importLegacyQueue();
|
package/src/core/errors.ts
CHANGED
|
@@ -41,6 +41,8 @@ export interface HubErrorDetails {
|
|
|
41
41
|
readonly retryAfterMs?: number | undefined;
|
|
42
42
|
/** When a suspension or mute ends (epoch ms). */
|
|
43
43
|
readonly until?: number | undefined;
|
|
44
|
+
/** An online game's rules refused a move (`illegal`): the rules' own short code. */
|
|
45
|
+
readonly rule?: string | undefined;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
/**
|
|
@@ -62,6 +64,8 @@ export class HubError extends Error {
|
|
|
62
64
|
readonly retryAfterMs: number | undefined;
|
|
63
65
|
/** When a suspension or mute ends (epoch ms). */
|
|
64
66
|
readonly until: number | undefined;
|
|
67
|
+
/** An online game's rules refused a move (`code` is `illegal`): the rules' own short code, such as `cell_taken`. */
|
|
68
|
+
readonly rule: string | undefined;
|
|
65
69
|
|
|
66
70
|
/** An error with a player-safe `message`, the HTTP `status` (if any) and the body's extras. */
|
|
67
71
|
constructor(message: string, status?: number, details: HubErrorDetails = {}) {
|
|
@@ -73,6 +77,7 @@ export class HubError extends Error {
|
|
|
73
77
|
this.hint = details.hint;
|
|
74
78
|
this.retryAfterMs = details.retryAfterMs;
|
|
75
79
|
this.until = details.until;
|
|
80
|
+
this.rule = details.rule;
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
/** `true` when the failure was a network or transport error, not an HTTP status. */
|
|
@@ -122,12 +127,13 @@ export function readErrorFields(body: unknown): ErrorBodyFields {
|
|
|
122
127
|
hint: optionalString(body.hint),
|
|
123
128
|
retryAfterMs: optionalNumber(body.retryAfterMs),
|
|
124
129
|
until: optionalNumber(body.until),
|
|
130
|
+
rule: optionalString(body.rule),
|
|
125
131
|
};
|
|
126
132
|
}
|
|
127
133
|
|
|
128
134
|
/** The right error class for a failure: a 422 or `content_rejected` is a {@link ContentRejectedError}. */
|
|
129
135
|
export function errorFor(message: string, status: number | undefined, details: HubErrorDetails): HubError {
|
|
130
|
-
return status === 422 || details.code === 'content_rejected'
|
|
136
|
+
return (status === 422 && details.code !== 'illegal') || details.code === 'content_rejected'
|
|
131
137
|
? new ContentRejectedError(message, status, details)
|
|
132
138
|
: new HubError(message, status, details);
|
|
133
139
|
}
|
package/src/core/hub-progress.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { HubConnection } from './connection';
|
|
|
9
9
|
import { HubError, SAVE_UNKNOWN_CODE } from './errors';
|
|
10
10
|
import { toGameData, toStoredJson, type GamePayload } from './game-data';
|
|
11
11
|
import { PROGRESS_KINDS, type ProfilePayload, type ScorePayload } from './offline/hub-kinds';
|
|
12
|
+
import { pageAccount, toPageUser } from './page-account';
|
|
12
13
|
import { isRecord } from './storage/json-guards';
|
|
13
14
|
import type { LeaderboardResult, MeResponse, Profile, Save, SaveMeta, SubmitScoreOptions, SubmitScoreResult, User } from './types';
|
|
14
15
|
|
|
@@ -46,9 +47,30 @@ function typedSave<TData>(save: Save | null): Save<TData> | null {
|
|
|
46
47
|
|
|
47
48
|
/** Accounts, profile, saves, stats and leaderboards. */
|
|
48
49
|
export class HubProgress extends HubConnection {
|
|
50
|
+
/** Who the page is signed in as, as the hub's events and this client's sign-ins tell it. */
|
|
51
|
+
protected readonly account = pageAccount();
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Your user id, or `null` when nobody is signed in (also before an offline
|
|
55
|
+
* first visit has an account). Synchronous, and it follows the account as
|
|
56
|
+
* the hub changes it (a new guest's account made by the menu, sign-in,
|
|
57
|
+
* sign-out) with no `hub.me()` needed. Compare it with a seat's `userId`
|
|
58
|
+
* to find yours.
|
|
59
|
+
*/
|
|
60
|
+
userId(): number | null {
|
|
61
|
+
const heard = this.account.user;
|
|
62
|
+
const id = heard === undefined ? this.store.getUser()?.id ?? null : heard?.id ?? null;
|
|
63
|
+
// 0 is an offline local guest: no account on the hub yet.
|
|
64
|
+
return id === 0 ? null : id;
|
|
65
|
+
}
|
|
66
|
+
|
|
49
67
|
/** The current user (or `null`) and the games they have a profile in. */
|
|
50
68
|
me(): Promise<MeResponse> {
|
|
51
|
-
return this.withFallback(
|
|
69
|
+
return this.withFallback(async () => {
|
|
70
|
+
const reply = await this.rest.call('getCurrentUser');
|
|
71
|
+
this.account.note(reply.user === null ? null : toPageUser(reply.user));
|
|
72
|
+
return reply;
|
|
73
|
+
}, () => {
|
|
52
74
|
const profile = this.store.getProfile();
|
|
53
75
|
return { user: this.store.getUser(), profiles: profile === null ? [] : [{ game: profile.game, displayName: profile.displayName }] };
|
|
54
76
|
});
|
|
@@ -65,9 +87,7 @@ export class HubProgress extends HubConnection {
|
|
|
65
87
|
if (known !== null) {
|
|
66
88
|
return { user: known };
|
|
67
89
|
}
|
|
68
|
-
|
|
69
|
-
this.store.setUser(guest);
|
|
70
|
-
return { user: guest };
|
|
90
|
+
return this.signInLocally({ id: 0, username: 'Guest-local', guest: true });
|
|
71
91
|
});
|
|
72
92
|
}
|
|
73
93
|
|
|
@@ -94,25 +114,36 @@ export class HubProgress extends HubConnection {
|
|
|
94
114
|
logout(): Promise<{ ok: true }> {
|
|
95
115
|
return this.withFallback(async () => {
|
|
96
116
|
const reply = await this.rest.call('logOut');
|
|
97
|
-
this.
|
|
117
|
+
this.signedOut();
|
|
98
118
|
return reply;
|
|
99
119
|
}, () => {
|
|
100
|
-
this.
|
|
120
|
+
this.signedOut();
|
|
101
121
|
return { ok: true } as const;
|
|
102
122
|
});
|
|
103
123
|
}
|
|
104
124
|
|
|
105
125
|
private signInLocally(user: User): AuthResponse {
|
|
106
|
-
this.
|
|
126
|
+
this.remember(user);
|
|
107
127
|
return { user };
|
|
108
128
|
}
|
|
109
129
|
|
|
110
130
|
/** Remember who the hub signed in, so the cache's notes about the hub's copies are read for the right player. */
|
|
111
131
|
private signedIn(reply: AuthResponse): AuthResponse {
|
|
112
|
-
this.
|
|
132
|
+
this.remember(reply.user);
|
|
113
133
|
return reply;
|
|
114
134
|
}
|
|
115
135
|
|
|
136
|
+
/** The cache and the page (`hub.userId()`, `hub.avatar`) learn who is signed in. */
|
|
137
|
+
private remember(user: User): void {
|
|
138
|
+
this.store.setUser(user);
|
|
139
|
+
this.account.note(toPageUser(user));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private signedOut(): void {
|
|
143
|
+
this.store.clearUser();
|
|
144
|
+
this.account.note(null);
|
|
145
|
+
}
|
|
146
|
+
|
|
116
147
|
/** This game's profile for the current user, or `profile: null`. `TData` is the game's own data type. */
|
|
117
148
|
getProfile<TData = Json>(): Promise<{ profile: Profile<TData> | null }> {
|
|
118
149
|
return this.withFallback(
|