@diffenderfer-games/hub 2.0.0-next.4 → 2.0.0-next.6
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 +54 -11
- 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 +1804 -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 +101 -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 +537 -17
- 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 +2217 -199
- package/dist/shared/src.js.map +1 -1
- package/dist/shared/svg-markup.js +1860 -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 +9 -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 +68 -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 +76 -0
- package/src/avatar/face.ts +187 -0
- package/src/avatar/geom.ts +168 -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 +258 -0
- package/src/avatar/render/context.ts +117 -0
- package/src/avatar/render/eyes.ts +78 -0
- package/src/avatar/render/eyewear.ts +56 -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 +9 -0
- package/src/core/errors.ts +15 -2
- package/src/core/hub-progress.ts +269 -202
- package/src/core/hub.ts +174 -6
- package/src/core/offline/cloud-saves.ts +62 -0
- package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
- package/src/core/offline/hub-kinds/index.ts +14 -3
- package/src/core/offline/hub-kinds/progress-kinds.ts +21 -4
- package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
- package/src/core/offline/local-store.ts +46 -1
- 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 +11 -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
package/src/core/hub.ts
CHANGED
|
@@ -10,16 +10,23 @@ import { getInput, getUiStack, type InputSystem } from '../input';
|
|
|
10
10
|
import { mp } from '../mp';
|
|
11
11
|
import { race } from '../race';
|
|
12
12
|
import { rooms } from '../rooms';
|
|
13
|
+
import { pageBridge } from '../realtime/bridge';
|
|
13
14
|
import { isInHubRoom } from '../realtime/rooms-forwarder';
|
|
14
15
|
import { linkRealtimeToHub, notify, presence, social } from '../social';
|
|
16
|
+
import { HubAvatar } from './avatar/hub-avatar';
|
|
15
17
|
import { HubError, OfflineError, toHubError } from './errors';
|
|
16
18
|
import { HubEconomy } from './hub-economy';
|
|
17
|
-
import { DAILY_KIND, RESUME_KIND } from './offline/hub-kinds';
|
|
19
|
+
import { AVATAR_KIND, DAILY_KIND, RESUME_KIND, SAVED_KIND } from './offline/hub-kinds';
|
|
18
20
|
import { readDailyCompletion } from './offline/hub-kinds/daily-kind';
|
|
19
21
|
import { hubMenu, type HubMenuControl } from './menu-control';
|
|
20
22
|
import { detectGameTitle, emitWindowEvent } from './page';
|
|
21
23
|
import { HubResume, type ResumeHost } from './resume/hub-resume';
|
|
22
24
|
import { isResumeRequested } from './resume/resume-request';
|
|
25
|
+
import { DailySaves } from './saved/daily-saves';
|
|
26
|
+
import { HubSaved, type SavedHost } from './saved/hub-saved';
|
|
27
|
+
import { HubTurns, type TurnsHost } from './turns/hub-turns';
|
|
28
|
+
import { savedOpenedOnPage } from './saved/saved-open';
|
|
29
|
+
import type { SavedEntry } from './saved/saved-types';
|
|
23
30
|
import { fetchGameServerTicket } from './ticket';
|
|
24
31
|
import { clientSupport, readHubVersion, type ClientSupport } from './version/hub-version';
|
|
25
32
|
import { HUB_CLIENT_VERSION } from './version/client-version.gen';
|
|
@@ -31,6 +38,12 @@ export class Hub extends HubEconomy {
|
|
|
31
38
|
readonly clientVersion: string = HUB_CLIENT_VERSION;
|
|
32
39
|
|
|
33
40
|
private resumeApi: HubResume | undefined;
|
|
41
|
+
private avatarApi: HubAvatar | undefined;
|
|
42
|
+
private savedApi: HubSaved | undefined;
|
|
43
|
+
private dailySavesApi: DailySaves | undefined;
|
|
44
|
+
private turnsApi: HubTurns | undefined;
|
|
45
|
+
/** The online games open on this page (a run isn't saved while one is). */
|
|
46
|
+
private readonly openTurnGames = new Set<object>();
|
|
34
47
|
|
|
35
48
|
/**
|
|
36
49
|
* The hub's version and the client versions it supports (`GET /_api/version`),
|
|
@@ -118,6 +131,110 @@ export class Hub extends HubEconomy {
|
|
|
118
131
|
return this.resumeApi;
|
|
119
132
|
}
|
|
120
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Players' avatars: `hub.avatar.mine()`, `of(userId)`, `from(seat)`,
|
|
136
|
+
* `bot(seed)`, `on('change')`, `openDesigner()` and `load()` for the renderer
|
|
137
|
+
* (docs: "Avatars"). No art or renderer until `load()`.
|
|
138
|
+
*/
|
|
139
|
+
get avatar(): HubAvatar {
|
|
140
|
+
this.avatarApi ??= new HubAvatar({
|
|
141
|
+
currentUserId: () => this.userId(),
|
|
142
|
+
onAccount: (listener) => this.account.on(listener),
|
|
143
|
+
fetchMine: () => this.rest.call('getMyAvatar'),
|
|
144
|
+
fetchCodes: async (ids) => (await this.rest.call('getAvatars', { ids: [...ids] })).codes,
|
|
145
|
+
queueSave: async (code, savedAt) => {
|
|
146
|
+
await this.mutateOffline(AVATAR_KIND, { code, savedAt }, () => undefined);
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
return this.avatarApi;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Every game the player has in progress, of every kind: `hub.saved.list()`
|
|
154
|
+
* for this game's "Continue" list, `opened` and `onOpen` to open the one
|
|
155
|
+
* the player picked (docs: "Saved games").
|
|
156
|
+
*/
|
|
157
|
+
get saved(): HubSaved {
|
|
158
|
+
this.savedApi ??= new HubSaved(this.savedHost());
|
|
159
|
+
return this.savedApi;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Online games played over minutes or days, run by the hub from the
|
|
164
|
+
* game's rules module (`game.turns`): `hub.turns.create`, `join`, `open`
|
|
165
|
+
* (docs/multiplayer.md "Turn games").
|
|
166
|
+
*/
|
|
167
|
+
get turns(): HubTurns {
|
|
168
|
+
this.turnsApi ??= new HubTurns(this.turnsHost());
|
|
169
|
+
return this.turnsApi;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** What `hub.turns` needs from this client: its REST calls. */
|
|
173
|
+
private turnsHost(): TurnsHost {
|
|
174
|
+
const { rest } = this;
|
|
175
|
+
return {
|
|
176
|
+
slug: this.slug,
|
|
177
|
+
create: (body) => rest.call('createTurnGame', body),
|
|
178
|
+
join: (id) => rest.call('joinTurnGame', { id }),
|
|
179
|
+
joinByLink: (key) => rest.call('joinTurnGameByLink', { key }),
|
|
180
|
+
get: (id) => rest.call('getTurnGame', { id }),
|
|
181
|
+
start: (id) => rest.call('startTurnGame', { id }),
|
|
182
|
+
move: (id, seq, move) => rest.call('moveTurnGame', { id, seq, move }),
|
|
183
|
+
history: async (id, since) => (await rest.call('getTurnGameHistory', { id, since })).moves,
|
|
184
|
+
leave: async (id) => { await rest.call('leaveTurnGame', { id }); },
|
|
185
|
+
nudge: async (id) => { await rest.call('nudgeTurnGame', { id }); },
|
|
186
|
+
setNotify: async (id, mode) => { await rest.call('setTurnGameNotify', { id, mode }); },
|
|
187
|
+
rematch: (id) => rest.call('rematchTurnGame', { id }),
|
|
188
|
+
invite: (id, userIds) => rest.call('inviteToTurnGame', { id, userIds: [...userIds] }),
|
|
189
|
+
share: (id, share) => rest.call('shareTurnGame', { id, ...share }),
|
|
190
|
+
autoPlay: (id) => rest.call('autoPlayTurn', { id }),
|
|
191
|
+
chat: (id, send) => rest.call('chatInTurnGame', { id, ...send }),
|
|
192
|
+
listOpen: async (game) => (await rest.call('listOpenTurnGames', game === null ? {} : { game })).games,
|
|
193
|
+
list: () => this.saved.list({ kinds: ['turns'] }),
|
|
194
|
+
opened: (game) => { this.openTurnGames.add(game); },
|
|
195
|
+
closed: (game) => { this.openTurnGames.delete(game); },
|
|
196
|
+
isLive: () => pageBridge.current() !== null,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** What `hub.saved` needs from this client. */
|
|
201
|
+
private savedHost(): SavedHost {
|
|
202
|
+
return {
|
|
203
|
+
slug: this.slug,
|
|
204
|
+
fetchList: (game) => this.withFallback<readonly SavedEntry[] | undefined>(
|
|
205
|
+
async () => (await this.rest.call('listMySavedGames', game === null ? {} : { game })).games,
|
|
206
|
+
() => undefined,
|
|
207
|
+
),
|
|
208
|
+
deviceCopies: () => {
|
|
209
|
+
const userId = this.store.getUser()?.id ?? null;
|
|
210
|
+
return {
|
|
211
|
+
runs: new Map([...this.resumeStore.all()].filter(([, copy]) => copy.userId === userId)),
|
|
212
|
+
dailies: this.dailyStore.all(userId),
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
discard: (entry) => this.discardSaved(entry),
|
|
216
|
+
navigate: (url) => {
|
|
217
|
+
location.assign(url);
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Discard a daily or run: this game's run through `hub.resume` (works offline), others on the hub. */
|
|
223
|
+
private async discardSaved(entry: SavedEntry): Promise<void> {
|
|
224
|
+
if (entry.kind === 'run' && entry.game === this.slug) {
|
|
225
|
+
await this.resume.clear();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (!entry.id.startsWith('local:')) {
|
|
229
|
+
await this.rest.call('discardSavedGame', { id: entry.id });
|
|
230
|
+
}
|
|
231
|
+
if (entry.kind === 'run') {
|
|
232
|
+
this.resumeStore.remove(entry.game);
|
|
233
|
+
} else if (entry.daily !== undefined) {
|
|
234
|
+
this.dailyStore.remove(entry.game, entry.daily.day);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
121
238
|
/** What `hub.resume` needs from this client. */
|
|
122
239
|
private resumeHost(): ResumeHost {
|
|
123
240
|
return {
|
|
@@ -129,7 +246,14 @@ export class Hub extends HubEconomy {
|
|
|
129
246
|
if (race.current() !== null) {
|
|
130
247
|
return 'race';
|
|
131
248
|
}
|
|
132
|
-
|
|
249
|
+
if (this.openTurnGames.size > 0) {
|
|
250
|
+
return 'turns';
|
|
251
|
+
}
|
|
252
|
+
return isInHubRoom() ? 'room' : null;
|
|
253
|
+
},
|
|
254
|
+
daily: () => {
|
|
255
|
+
const daily = this.daily;
|
|
256
|
+
return daily.active === null ? null : daily;
|
|
133
257
|
},
|
|
134
258
|
send: async (payload) => {
|
|
135
259
|
await this.mutateOffline(RESUME_KIND, payload, () => undefined);
|
|
@@ -148,17 +272,59 @@ export class Hub extends HubEconomy {
|
|
|
148
272
|
clearOther: async (slug) => {
|
|
149
273
|
await this.rest.call('clearResumeSlot', { slug });
|
|
150
274
|
},
|
|
275
|
+
onChange: () => {
|
|
276
|
+
this.savedApi?.changed();
|
|
277
|
+
},
|
|
151
278
|
};
|
|
152
279
|
}
|
|
153
280
|
|
|
154
|
-
/**
|
|
281
|
+
/** This game's daily saves (device copies and their sends). */
|
|
282
|
+
private dailySaves(): DailySaves {
|
|
283
|
+
const slug = this.slug;
|
|
284
|
+
if (slug === null) {
|
|
285
|
+
throw new HubError('hub.daily: could not determine the game slug.');
|
|
286
|
+
}
|
|
287
|
+
this.dailySavesApi ??= new DailySaves({
|
|
288
|
+
slug,
|
|
289
|
+
local: this.dailyStore,
|
|
290
|
+
currentUserId: () => this.store.getUser()?.id ?? null,
|
|
291
|
+
gameTitle: () => detectGameTitle(slug),
|
|
292
|
+
send: async (payload) => {
|
|
293
|
+
await this.mutateOffline(SAVED_KIND, payload, () => undefined);
|
|
294
|
+
this.savedApi?.changed();
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
return this.dailySavesApi;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** What the daily system needs from this client: its REST calls, the device copies and window events. */
|
|
155
301
|
private dailyHost(): DailyHost {
|
|
156
302
|
return {
|
|
157
303
|
slug: this.slug,
|
|
158
304
|
status: () => this.rest.call('getGameDailyChallenges', this.gameFields()),
|
|
159
305
|
all: () => this.rest.call('listDailyChallengesForAllGames'),
|
|
160
|
-
start: (day) => this.
|
|
161
|
-
|
|
306
|
+
start: (day) => this.withFallback(
|
|
307
|
+
async () => this.dailySaves().remember(await this.rest.call('startDailyChallenge', { ...this.gameFields(), day })),
|
|
308
|
+
() => this.dailySaves().startLocally(day),
|
|
309
|
+
),
|
|
310
|
+
resume: async (instanceId) => this.dailySaves().remember(await this.rest.call('resumeDailyChallenge', { id: instanceId })),
|
|
311
|
+
complete: async (day, token, meta) => {
|
|
312
|
+
this.dailySaves().finished(day);
|
|
313
|
+
const completed = await this.mutateOffline<DailyCompleteResponse | null>(DAILY_KIND, { day, token, meta }, () => null, (result) => readDailyCompletion(result));
|
|
314
|
+
if (completed !== null) {
|
|
315
|
+
this.dailySaves().completed(day);
|
|
316
|
+
}
|
|
317
|
+
this.savedApi?.changed();
|
|
318
|
+
return completed;
|
|
319
|
+
},
|
|
320
|
+
save: (challenge, input) => {
|
|
321
|
+
const isSaved = this.dailySaves().save(challenge, input);
|
|
322
|
+
this.savedApi?.changed();
|
|
323
|
+
return isSaved;
|
|
324
|
+
},
|
|
325
|
+
flush: () => this.dailySaves().flush(),
|
|
326
|
+
discard: (challenge) => this.dailySaves().discard(challenge),
|
|
327
|
+
inProgress: () => this.saved.list({ kinds: ['daily'] }),
|
|
162
328
|
emit: (eventName, detail) => {
|
|
163
329
|
emitWindowEvent(eventName, detail);
|
|
164
330
|
},
|
|
@@ -175,8 +341,10 @@ linkRealtimeToHub({
|
|
|
175
341
|
/** A ready-to-use client, with the slug and API base detected from the page. */
|
|
176
342
|
export const hub: Hub = new Hub();
|
|
177
343
|
|
|
178
|
-
// Take `?hub_resume` out of the address bar before the game's router sees
|
|
344
|
+
// Take `?hub_resume`, `?hub_open` and `?hub_kind` out of the address bar before the game's router sees them
|
|
345
|
+
// (the answers stay on `hub.resume.requested` and `hub.saved.opened`).
|
|
179
346
|
isResumeRequested();
|
|
347
|
+
savedOpenedOnPage();
|
|
180
348
|
|
|
181
349
|
/** Create a client for another slug or API base, or with other offline behaviour. */
|
|
182
350
|
export function createHub(options: HubOptions = {}): Hub {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this device last saw of a game's save slots on the hub, per player:
|
|
3
|
+
* each slot's `updatedAt` there, or `null` for a slot known to be empty. It
|
|
4
|
+
* decides two things offline. `getSave` answers from the cache only when
|
|
5
|
+
* the device knows the hub's copy of that slot (so a fresh device or cleared
|
|
6
|
+
* browser never reports "no save" for a slot it simply hasn't seen), and a
|
|
7
|
+
* queued save write sends the hub's `updatedAt` it was built on, so the hub
|
|
8
|
+
* refuses it when the slot has changed since.
|
|
9
|
+
*
|
|
10
|
+
* A snapshot gives the full list (`complete`: any other slot is empty); a
|
|
11
|
+
* single read or write adds that slot to a partial list.
|
|
12
|
+
*/
|
|
13
|
+
import { isRecord, optionalNumber } from '../storage/json-guards';
|
|
14
|
+
|
|
15
|
+
/** The hub's save slots for one game, as this device last saw them. */
|
|
16
|
+
export interface CloudSaveIndex {
|
|
17
|
+
/** Whose slots these are. */
|
|
18
|
+
readonly userId: number;
|
|
19
|
+
/** `true` when every slot the hub had is listed (from a snapshot). */
|
|
20
|
+
readonly complete: boolean;
|
|
21
|
+
/** Each slot's `updatedAt` on the hub, or `null` when it was empty. */
|
|
22
|
+
readonly saves: Readonly<Record<string, number | null>>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A stored index, or `null` when missing or malformed. */
|
|
26
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: an index read back from localStorage
|
|
27
|
+
export function readCloudSaveIndex(stored: unknown): CloudSaveIndex | null {
|
|
28
|
+
if (!isRecord(stored) || typeof stored.complete !== 'boolean' || !isRecord(stored.saves)) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const userId = optionalNumber(stored.userId);
|
|
32
|
+
if (userId === undefined) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const saves: Record<string, number | null> = {};
|
|
36
|
+
for (const [slot, updatedAt] of Object.entries(stored.saves)) {
|
|
37
|
+
const known = updatedAt === null ? null : optionalNumber(updatedAt);
|
|
38
|
+
if (known === undefined) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
saves[slot] = known;
|
|
42
|
+
}
|
|
43
|
+
return { userId, complete: stored.complete, saves };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The hub's `updatedAt` for `slot` as `userId` last saw it: a number, `null`
|
|
48
|
+
* when the slot was empty, or `undefined` when this device doesn't know.
|
|
49
|
+
*/
|
|
50
|
+
export function cloudSaveOf(index: CloudSaveIndex | null, userId: number, slot: string): number | null | undefined {
|
|
51
|
+
if (index?.userId !== userId) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
const updatedAt = index.saves[slot];
|
|
55
|
+
return updatedAt === undefined && index.complete ? null : updatedAt;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** `index` with `slot` seen at `updatedAt` (`null`: empty) by `userId`; another player's index starts over. */
|
|
59
|
+
export function withCloudSave(index: CloudSaveIndex | null, userId: number, slot: string, updatedAt: number | null): CloudSaveIndex {
|
|
60
|
+
const base = index?.userId === userId ? index : { userId, complete: false, saves: {} };
|
|
61
|
+
return { ...base, saves: { ...base.saves, [slot]: updatedAt } };
|
|
62
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hub's avatar kind (`hub.avatar`, avatars.md §5.6): save your look.
|
|
3
|
+
* Latest wins: a newer save replaces a waiting one, and the hub keeps the
|
|
4
|
+
* newer of the replayed save and one another device made since (`savedAt`).
|
|
5
|
+
*/
|
|
6
|
+
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
7
|
+
import { decodeLook } from '@diffenderfer-games/hub-contract/avatar';
|
|
8
|
+
import { isRecord, optionalNumber, optionalString } from '../../storage/json-guards';
|
|
9
|
+
import { checkedKind } from '../kind-adapters';
|
|
10
|
+
import type { RegisteredKind } from '../outbox-kinds';
|
|
11
|
+
|
|
12
|
+
/** The kind name. */
|
|
13
|
+
export const AVATAR_KIND = 'hub.avatar';
|
|
14
|
+
|
|
15
|
+
/** Save this look (its code) as of `savedAt`. */
|
|
16
|
+
export type AvatarPayload = { readonly code: string; readonly savedAt: number };
|
|
17
|
+
|
|
18
|
+
function readAvatar(stored: Json): AvatarPayload | undefined {
|
|
19
|
+
if (!isRecord(stored)) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const code = optionalString(stored.code);
|
|
23
|
+
const savedAt = optionalNumber(stored.savedAt);
|
|
24
|
+
return code === undefined || savedAt === undefined ? undefined : { code, savedAt };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The avatar kind, by name. */
|
|
28
|
+
export function avatarKinds(): Record<string, RegisteredKind> {
|
|
29
|
+
return {
|
|
30
|
+
[AVATAR_KIND]: checkedKind(readAvatar, {
|
|
31
|
+
mergeKey: () => 'avatar',
|
|
32
|
+
replaces: true,
|
|
33
|
+
run: async (op, context) => {
|
|
34
|
+
const look = decodeLook(op.code)?.look;
|
|
35
|
+
if (look !== undefined) {
|
|
36
|
+
await context.call('saveMyAvatar', { look: { ...look, accessories: [...look.accessories] }, savedAt: op.savedAt });
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
},
|
|
40
|
+
}),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -3,19 +3,24 @@
|
|
|
3
3
|
* client's outbox. The docs' "Offline and replay" table lists them.
|
|
4
4
|
*/
|
|
5
5
|
import type { LocalResumeStore } from '../../resume/local-resume';
|
|
6
|
+
import type { LocalDailyStore } from '../../saved/local-saved';
|
|
6
7
|
import { writeLocalFavorites } from '../favorites';
|
|
7
8
|
import type { Outbox } from '../outbox';
|
|
9
|
+
import { avatarKinds } from './avatar-kind';
|
|
8
10
|
import { dailyKinds, type DailyCompletion, type DailyPayload } from './daily-kind';
|
|
9
11
|
import { economyKinds } from './economy-kinds';
|
|
10
12
|
import type { GameFieldsSource } from './game-fields';
|
|
11
|
-
import { progressKinds } from './progress-kinds';
|
|
13
|
+
import { progressKinds, type CloudSaveTracker } from './progress-kinds';
|
|
12
14
|
import { resumeKinds } from './resume-kind';
|
|
15
|
+
import { savedKinds } from './saved-kind';
|
|
13
16
|
|
|
17
|
+
export { AVATAR_KIND, type AvatarPayload } from './avatar-kind';
|
|
14
18
|
export { DAILY_KIND, type DailyPayload } from './daily-kind';
|
|
15
19
|
export { ECONOMY_KINDS, type ExchangePayload, type FavoritePayload } from './economy-kinds';
|
|
16
20
|
export { legacyOperation } from './legacy-ops';
|
|
17
21
|
export { RESUME_KIND, type ResumePayload } from './resume-kind';
|
|
18
|
-
export {
|
|
22
|
+
export { SAVED_KIND, type SavedPayload } from './saved-kind';
|
|
23
|
+
export { PROGRESS_KINDS, type CloudSaveTracker, type ProfilePayload, type SavePayload, type ScorePayload, type StatPayload } from './progress-kinds';
|
|
19
24
|
|
|
20
25
|
/** What the hub kinds report back to their client. */
|
|
21
26
|
export interface HubKindHooks {
|
|
@@ -25,15 +30,21 @@ export interface HubKindHooks {
|
|
|
25
30
|
readonly onDailyComplete: (payload: DailyPayload, completion: DailyCompletion) => void;
|
|
26
31
|
/** The device copies of each game's resume slot (`hub.resume`). */
|
|
27
32
|
readonly resume: LocalResumeStore;
|
|
33
|
+
/** The device copies of daily challenges in progress (`hub.saved`). */
|
|
34
|
+
readonly dailies: LocalDailyStore;
|
|
35
|
+
/** What this device last saw of the game's save slots on the hub. */
|
|
36
|
+
readonly cloudSaves: CloudSaveTracker;
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
/** Register every hub kind on `outbox`. */
|
|
31
40
|
export function registerHubKinds(outbox: Outbox, hooks: HubKindHooks): void {
|
|
32
41
|
const kinds = {
|
|
33
|
-
...progressKinds(hooks.game),
|
|
42
|
+
...progressKinds(hooks.game, hooks.cloudSaves),
|
|
34
43
|
...dailyKinds(hooks.game, hooks.onDailyComplete),
|
|
35
44
|
...economyKinds(hooks.game, writeLocalFavorites),
|
|
36
45
|
...resumeKinds(hooks.game, hooks.resume),
|
|
46
|
+
...avatarKinds(),
|
|
47
|
+
...savedKinds(hooks.dailies),
|
|
37
48
|
};
|
|
38
49
|
for (const [kind, definition] of Object.entries(kinds)) {
|
|
39
50
|
outbox.define(kind, definition);
|
|
@@ -12,6 +12,14 @@ import type { RegisteredKind } from '../outbox-kinds';
|
|
|
12
12
|
import { betterScore } from '../queue';
|
|
13
13
|
import type { GameFieldsSource } from './game-fields';
|
|
14
14
|
|
|
15
|
+
/** What the save kind reads and records about the hub's copy of a slot (`LocalStore`). */
|
|
16
|
+
export interface CloudSaveTracker {
|
|
17
|
+
/** The hub's `updatedAt` for `slot` as this device last saw it (`null`: empty), or `undefined` when it doesn't know. */
|
|
18
|
+
cloudSave(slot: string): number | null | undefined;
|
|
19
|
+
/** Note the hub's copy of `slot` after a write landed: its `updatedAt`, or `null` once deleted. */
|
|
20
|
+
rememberCloudSave(slot: string, updatedAt: number | null): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
15
23
|
/** A save slot write: new contents, or a delete. */
|
|
16
24
|
export type SavePayload =
|
|
17
25
|
| { readonly slot: string; readonly data: Json; readonly label: string | null }
|
|
@@ -109,8 +117,14 @@ export function mergeStats(waiting: StatPayload, incoming: StatPayload): StatPay
|
|
|
109
117
|
}
|
|
110
118
|
}
|
|
111
119
|
|
|
112
|
-
/**
|
|
113
|
-
|
|
120
|
+
/**
|
|
121
|
+
* The progress kinds, by name. A save write replayed from the queue sends
|
|
122
|
+
* the hub's `updatedAt` this device last saw for the slot (`null` when it
|
|
123
|
+
* saw it empty or never saw it), so the hub refuses it (409 `save_conflict`,
|
|
124
|
+
* dropped and reported as a conflict) when another device saved since. A
|
|
125
|
+
* write sent at once, online, always lands, as before.
|
|
126
|
+
*/
|
|
127
|
+
export function progressKinds(game: GameFieldsSource, cloudSaves: CloudSaveTracker): Record<string, RegisteredKind> {
|
|
114
128
|
return {
|
|
115
129
|
[PROGRESS_KINDS.save]: checkedKind(readSave, {
|
|
116
130
|
mergeKey: (save) => save.slot,
|
|
@@ -118,9 +132,12 @@ export function progressKinds(game: GameFieldsSource): Record<string, Registered
|
|
|
118
132
|
run: async (save, context) => {
|
|
119
133
|
if ('deleted' in save) {
|
|
120
134
|
await context.call('deleteSave', { ...game(), slot: save.slot });
|
|
121
|
-
|
|
122
|
-
|
|
135
|
+
cloudSaves.rememberCloudSave(save.slot, null);
|
|
136
|
+
return;
|
|
123
137
|
}
|
|
138
|
+
const base = context.isReplay ? { base: cloudSaves.cloudSave(save.slot) ?? null } : {};
|
|
139
|
+
const written = await context.call('writeSave', { ...game(), slot: save.slot, data: save.data, label: save.label, ...base });
|
|
140
|
+
cloudSaves.rememberCloudSave(save.slot, written.updatedAt);
|
|
124
141
|
},
|
|
125
142
|
}),
|
|
126
143
|
[PROGRESS_KINDS.stat]: checkedKind(readStat, {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hub's saved-games kind (`hub.saved`): send a daily challenge's state,
|
|
3
|
+
* or discard the day, from the device copy (`LocalDailyStore`). One op per
|
|
4
|
+
* (game, day): a newer one replaces a waiting one, so a discard after a
|
|
5
|
+
* save drops the save. The op carries only which day and when; when it runs
|
|
6
|
+
* it sends the newest state the device holds, so the outbox stays small.
|
|
7
|
+
*
|
|
8
|
+
* A day started offline has a device-only id (`local:<slug>:<day>`): the op
|
|
9
|
+
* first starts the day on the hub, which finds or makes its saved game (one
|
|
10
|
+
* per player, game and day), then saves to that. A save or discard that
|
|
11
|
+
* finds the day already finished or discarded elsewhere (409, 404) is done:
|
|
12
|
+
* the device copy is dropped.
|
|
13
|
+
*/
|
|
14
|
+
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
15
|
+
import { HubError } from '../../errors';
|
|
16
|
+
import type { LocalDaily, LocalDailyStore } from '../../saved/local-saved';
|
|
17
|
+
import { isRecord, optionalNumber, optionalString } from '../../storage/json-guards';
|
|
18
|
+
import { checkedKind } from '../kind-adapters';
|
|
19
|
+
import type { RegisteredKind, ReplayContext } from '../outbox-kinds';
|
|
20
|
+
|
|
21
|
+
/** The kind name. */
|
|
22
|
+
export const SAVED_KIND = 'hub.saved';
|
|
23
|
+
|
|
24
|
+
/** Send (`save`) or discard a day's saved game; `at` is when the device copy changed. */
|
|
25
|
+
export type SavedPayload = { readonly action: 'save' | 'discard'; readonly game: string; readonly day: string; readonly at: number };
|
|
26
|
+
|
|
27
|
+
function readSaved(stored: Json): SavedPayload | undefined {
|
|
28
|
+
if (!isRecord(stored) || (stored.action !== 'save' && stored.action !== 'discard')) {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
const game = optionalString(stored.game);
|
|
32
|
+
const day = optionalString(stored.day);
|
|
33
|
+
const at = optionalNumber(stored.at);
|
|
34
|
+
return game === undefined || day === undefined || at === undefined ? undefined : { action: stored.action, game, day, at };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Whether a failure means the instance is gone or closed on the hub (the change is moot). */
|
|
38
|
+
function isGoneOrClosed(failure: Error): boolean {
|
|
39
|
+
return failure instanceof HubError && (failure.status === 404 || failure.status === 409);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The hub's id for the copy's day: its own, or the one the hub gives the day when it was started offline (`null`: already completed). */
|
|
43
|
+
async function resolveId(copy: LocalDaily, context: ReplayContext): Promise<string | null> {
|
|
44
|
+
if (!copy.id.startsWith('local:')) {
|
|
45
|
+
return copy.id;
|
|
46
|
+
}
|
|
47
|
+
const started = await context.call('startDailyChallenge', { slug: copy.game, day: copy.day });
|
|
48
|
+
return started.instanceId;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Run one op against the copy it names. */
|
|
52
|
+
async function runSaved(op: SavedPayload, local: LocalDailyStore, context: ReplayContext): Promise<undefined> {
|
|
53
|
+
const copy = local.read(op.game, op.day);
|
|
54
|
+
if (copy === null) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const id = await resolveId(copy, context);
|
|
59
|
+
if (id === null) {
|
|
60
|
+
local.remove(op.game, op.day);
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
if (op.action === 'discard') {
|
|
64
|
+
await context.call('discardSavedGame', { id });
|
|
65
|
+
local.remove(op.game, op.day);
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
// A null state is sent too: it is hub.resume.clear() during the day (start over from the seed).
|
|
69
|
+
if (copy.status === 'active') {
|
|
70
|
+
await context.call('saveSavedGameState', {
|
|
71
|
+
id, state: copy.state, progress: { label: copy.label, fraction: copy.fraction }, title: copy.title, version: copy.version, savedAt: copy.at,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
local.markSynced(op.game, op.day, copy.at, id);
|
|
75
|
+
} catch (failure) {
|
|
76
|
+
if (!(failure instanceof Error) || !isGoneOrClosed(failure)) {
|
|
77
|
+
throw failure;
|
|
78
|
+
}
|
|
79
|
+
local.remove(op.game, op.day);
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The saved-games kind, by name, over the device copies in `local`. */
|
|
85
|
+
export function savedKinds(local: LocalDailyStore): Record<string, RegisteredKind> {
|
|
86
|
+
return {
|
|
87
|
+
[SAVED_KIND]: checkedKind(readSaved, {
|
|
88
|
+
mergeKey: (op) => `daily:${op.game}#${op.day}`,
|
|
89
|
+
replaces: true,
|
|
90
|
+
run: (op, context) => runSaved(op, local, context),
|
|
91
|
+
}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -10,6 +10,7 @@ import type { Board, Inventory, Json, SortDir, Stats } from '@diffenderfer-games
|
|
|
10
10
|
import { isJson, isNullableString, isNumberRecord, isRecord, optionalNumber, optionalString, parseJsonText } from '../storage/json-guards';
|
|
11
11
|
import { createPageStore, type KeyValueStore } from '../storage/key-value';
|
|
12
12
|
import type { Profile, Save, SaveMeta, SnapshotResponse, User } from '../types';
|
|
13
|
+
import { cloudSaveOf, readCloudSaveIndex, withCloudSave, type CloudSaveIndex } from './cloud-saves';
|
|
13
14
|
import { appendEvent, readEvents, type BufferedEvent } from './events';
|
|
14
15
|
import { EMPTY_INVENTORY, readInventory } from './local-inventory';
|
|
15
16
|
import { betterScore, readQueue, type QueueOp, type QueuedScoreOptions } from './queue';
|
|
@@ -159,6 +160,37 @@ export class LocalStore {
|
|
|
159
160
|
this.store.remove(`${this.prefix}save:${slot}`);
|
|
160
161
|
}
|
|
161
162
|
|
|
163
|
+
/**
|
|
164
|
+
* The hub's `updatedAt` for `slot` as the current user last saw it (`null`:
|
|
165
|
+
* the slot was empty), or `undefined` when this device doesn't know.
|
|
166
|
+
*/
|
|
167
|
+
cloudSave(slot: string): number | null | undefined {
|
|
168
|
+
const user = this.getUser();
|
|
169
|
+
return user === null ? undefined : cloudSaveOf(this.readCloudSaves(this.prefix), user.id, slot);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Note the hub's copy of `slot` (just read or written) for the current user: its `updatedAt`, or `null` when empty. */
|
|
173
|
+
rememberCloudSave(slot: string, updatedAt: number | null): void {
|
|
174
|
+
const user = this.getUser();
|
|
175
|
+
if (user !== null) {
|
|
176
|
+
this.writeStored(`${this.prefix}cloud-saves`, withCloudSave(this.readCloudSaves(this.prefix), user.id, slot, updatedAt));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Cache a slot as read from the hub (forgetting it when the hub has none), and note the hub's copy. */
|
|
181
|
+
cacheCloudSave(slot: string, save: Save | null): void {
|
|
182
|
+
if (save === null) {
|
|
183
|
+
this.deleteSave(slot);
|
|
184
|
+
} else {
|
|
185
|
+
this.writeStored(`${this.prefix}save:${slot}`, save);
|
|
186
|
+
}
|
|
187
|
+
this.rememberCloudSave(slot, save?.updatedAt ?? null);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private readCloudSaves(gamePrefix: string): CloudSaveIndex | null {
|
|
191
|
+
return readCloudSaveIndex(this.readStored(`${gamePrefix}cloud-saves`));
|
|
192
|
+
}
|
|
193
|
+
|
|
162
194
|
/** This game's cached stats. */
|
|
163
195
|
getStats(): Stats {
|
|
164
196
|
const stored = this.readStored(`${this.prefix}stats`);
|
|
@@ -232,11 +264,20 @@ export class LocalStore {
|
|
|
232
264
|
this.setEvents(appendEvent(this.getEvents(), { type, ts: Date.now() }));
|
|
233
265
|
}
|
|
234
266
|
|
|
235
|
-
/**
|
|
267
|
+
/**
|
|
268
|
+
* Fan a `/me/snapshot` reply out across every game's cache keys. It lists
|
|
269
|
+
* every save slot of every game the player has played, so each game's
|
|
270
|
+
* slots on the hub become known (and this game's are all empty when it
|
|
271
|
+
* isn't listed).
|
|
272
|
+
*/
|
|
236
273
|
hydrate(snapshot: SnapshotResponse): void {
|
|
237
274
|
if (snapshot.user !== null) {
|
|
238
275
|
this.writeStored(USER_KEY, snapshot.user);
|
|
239
276
|
}
|
|
277
|
+
const userId = snapshot.user?.id;
|
|
278
|
+
if (userId !== undefined && !Object.keys(snapshot.games).some((slug) => `hub:${slug}:` === this.prefix)) {
|
|
279
|
+
this.writeStored(`${this.prefix}cloud-saves`, { userId, complete: true, saves: {} } satisfies CloudSaveIndex);
|
|
280
|
+
}
|
|
240
281
|
for (const [slug, game] of Object.entries(snapshot.games)) {
|
|
241
282
|
const gamePrefix = `hub:${slug}:`;
|
|
242
283
|
if (game.profile !== null) {
|
|
@@ -245,6 +286,10 @@ export class LocalStore {
|
|
|
245
286
|
for (const save of game.saves) {
|
|
246
287
|
this.writeStored(`${gamePrefix}save:${save.slot}`, save);
|
|
247
288
|
}
|
|
289
|
+
if (userId !== undefined) {
|
|
290
|
+
const saves = Object.fromEntries(game.saves.map((save) => [save.slot, save.updatedAt]));
|
|
291
|
+
this.writeStored(`${gamePrefix}cloud-saves`, { userId, complete: true, saves } satisfies CloudSaveIndex);
|
|
292
|
+
}
|
|
248
293
|
this.writeStored(`${gamePrefix}stats`, game.stats);
|
|
249
294
|
this.writeStored(`${gamePrefix}inventory`, game.inventory);
|
|
250
295
|
}
|