@diffenderfer-games/hub 2.0.0-next.1 → 2.0.0-next.11
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 +173 -5
- package/README.md +27 -15
- 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 +134 -10
- 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 +1 -39
- 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 +2411 -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 +39 -3
- 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 +346 -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 +124 -0
- package/src/avatar/render/walker.ts +239 -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 +201 -92
- package/src/core/errors.ts +15 -2
- package/src/core/game-data.ts +11 -9
- package/src/core/hub-economy.ts +47 -10
- package/src/core/hub-progress.ts +94 -30
- package/src/core/hub.ts +228 -7
- 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/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 +66 -23
- 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 +4 -49
- 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 +14 -1
- package/src/core/rest/rest-client.ts +14 -3
- 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 +6 -0
- 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/version/client-version.gen.ts +2 -2
- package/src/daily/daily-system.ts +127 -12
- package/src/daily/page-daily.ts +2 -2
- package/src/daily/types.ts +49 -11
- package/src/doctor/checks.ts +12 -4
- package/src/doctor/cli.ts +8 -2
- package/src/doctor/embed-check.ts +61 -0
- package/src/doctor/hub-version-fetch.ts +43 -43
- package/src/doctor/world-check.ts +61 -0
- package/src/index.ts +20 -2
- package/src/input/focus-ring.ts +13 -0
- package/src/input/input-registry.ts +9 -2
- package/src/input/input-system.ts +14 -1
- package/src/input/modality.ts +122 -0
- package/src/input/nav-state.ts +13 -4
- package/src/input/navigator.ts +11 -0
- package/src/input/zero-config.ts +1 -14
- package/src/input.ts +4 -0
- package/src/mp.ts +1 -1
- package/src/overlay/overlay.ts +30 -4
- package/src/overlay/styles.ts +4 -3
- package/src/realtime/bridge.ts +5 -0
- package/src/realtime/multiplayer-forwarder.ts +13 -1
- package/src/realtime/multiplayer-types.ts +15 -1
- package/src/realtime/room-types.ts +28 -2
- package/src/realtime/rooms-forwarder.ts +27 -2
- package/src/realtime/runtime-api.ts +33 -8
- package/src/realtime/social-forwarder.ts +5 -0
- package/src/realtime/social-types.ts +8 -0
- package/src/realtime/types.ts +1 -0
- package/src/realtime/wire-types.ts +7 -0
- package/src/realtime/world-types.ts +20 -0
- package/src/rooms.ts +1 -1
- package/src/social.ts +1 -0
- package/src/turns-kit/check-rules.ts +281 -0
- package/src/turns-rules.ts +12 -0
- package/src/turns.ts +14 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hub's daily kind, a composite op: complete a day with the token the
|
|
3
|
+
* game started it with; when that token has expired by the time the op
|
|
4
|
+
* replays (401), start the day again for a fresh token and complete with
|
|
5
|
+
* that. The completion is replayable, so a retry after a lost reply never
|
|
6
|
+
* records it twice. A completion sent at once (online) is reported by the
|
|
7
|
+
* daily system itself; one that replays later is reported here.
|
|
8
|
+
*/
|
|
9
|
+
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
10
|
+
import { HubError } from '../../errors';
|
|
11
|
+
import type { EndpointReply } from '../../rest/endpoint-types';
|
|
12
|
+
import { isJsonObject, isRecord, optionalNumber, optionalString } from '../../storage/json-guards';
|
|
13
|
+
import { checkedKind } from '../kind-adapters';
|
|
14
|
+
import type { RegisteredKind } from '../outbox-kinds';
|
|
15
|
+
import type { GameFieldsSource } from './game-fields';
|
|
16
|
+
|
|
17
|
+
/** One daily completion. */
|
|
18
|
+
export type DailyPayload = { readonly day: string; readonly token: string; readonly meta: { readonly [key: string]: Json } };
|
|
19
|
+
|
|
20
|
+
/** The kind name. */
|
|
21
|
+
export const DAILY_KIND = 'hub.daily';
|
|
22
|
+
|
|
23
|
+
/** What a completion answers. */
|
|
24
|
+
export type DailyCompletion = EndpointReply<'completeDailyChallenge'>;
|
|
25
|
+
|
|
26
|
+
function readDaily(stored: Json): DailyPayload | undefined {
|
|
27
|
+
if (!isRecord(stored) || !isJsonObject(stored.meta)) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const day = optionalString(stored.day);
|
|
31
|
+
const token = optionalString(stored.token);
|
|
32
|
+
return day === undefined || token === undefined ? undefined : { day, token, meta: stored.meta };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A completion reply read back, or `undefined` when it doesn't read as one. */
|
|
36
|
+
export function readDailyCompletion(result: Json | undefined): DailyCompletion | undefined {
|
|
37
|
+
if (!isRecord(result) || result.ok !== true) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
const day = optionalString(result.day);
|
|
41
|
+
const count = optionalNumber(result.count);
|
|
42
|
+
const rank = result.rank === null ? null : optionalNumber(result.rank);
|
|
43
|
+
return day === undefined || count === undefined || rank === undefined ? undefined : { ok: true, day, count, rank };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The daily kind, by name; `onCompleted` hears each completion that lands from the queue. */
|
|
47
|
+
export function dailyKinds(game: GameFieldsSource, onCompleted: (payload: DailyPayload, completion: DailyCompletion) => void): Record<string, RegisteredKind> {
|
|
48
|
+
return {
|
|
49
|
+
[DAILY_KIND]: checkedKind(readDaily, {
|
|
50
|
+
run: async ({ day, token, meta }, context) => {
|
|
51
|
+
let completion: DailyCompletion;
|
|
52
|
+
try {
|
|
53
|
+
completion = await context.call('completeDailyChallenge', { ...game(), day, token, meta });
|
|
54
|
+
} catch (failure) {
|
|
55
|
+
if (!(failure instanceof HubError) || failure.status !== 401) {
|
|
56
|
+
throw failure;
|
|
57
|
+
}
|
|
58
|
+
const started = await context.call('startDailyChallenge', { ...game(), day });
|
|
59
|
+
completion = await context.call('completeDailyChallenge', { ...game(), day, token: started.token, meta });
|
|
60
|
+
}
|
|
61
|
+
if (context.isReplay) {
|
|
62
|
+
onCompleted({ day, token, meta }, completion);
|
|
63
|
+
}
|
|
64
|
+
return completion;
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hub's economy and catalog kinds: inventory grants and house
|
|
3
|
+
* exchanges (each one replayable call, never folded: every grant counts),
|
|
4
|
+
* and starring a game (a game's last star or unstar wins).
|
|
5
|
+
*/
|
|
6
|
+
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
7
|
+
import { isRecord, optionalNumber, optionalString } from '../../storage/json-guards';
|
|
8
|
+
import { checkedKind } from '../kind-adapters';
|
|
9
|
+
import type { GoodsPayload } from '../local-inventory';
|
|
10
|
+
import type { RegisteredKind } from '../outbox-kinds';
|
|
11
|
+
import type { GameFieldsSource } from './game-fields';
|
|
12
|
+
|
|
13
|
+
/** A house exchange: what the player gives up and gets. */
|
|
14
|
+
export type ExchangePayload = { readonly take?: GoodsPayload; readonly give?: GoodsPayload };
|
|
15
|
+
|
|
16
|
+
/** A star or unstar. */
|
|
17
|
+
export type FavoritePayload = { readonly slug: string; readonly on: boolean };
|
|
18
|
+
|
|
19
|
+
/** The kind names. */
|
|
20
|
+
export const ECONOMY_KINDS = { grant: 'hub.grant', exchange: 'hub.exchange', favorite: 'hub.favorite' } as const;
|
|
21
|
+
|
|
22
|
+
/** Goods read back, or `undefined` when malformed. */
|
|
23
|
+
export function readGoods(stored: Json | undefined): GoodsPayload | undefined {
|
|
24
|
+
if (!isRecord(stored)) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
const coins = stored.coins === undefined ? undefined : optionalNumber(stored.coins);
|
|
28
|
+
if (stored.coins !== undefined && coins === undefined) {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
const items: Record<string, number> = {};
|
|
32
|
+
if (stored.items !== undefined) {
|
|
33
|
+
if (!isRecord(stored.items)) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
for (const [key, qty] of Object.entries(stored.items)) {
|
|
37
|
+
const count = optionalNumber(qty);
|
|
38
|
+
if (count === undefined) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
items[key] = count;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { ...(stored.items === undefined ? {} : { items }), ...(coins === undefined ? {} : { coins }) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readExchange(stored: Json): ExchangePayload | undefined {
|
|
48
|
+
if (!isRecord(stored)) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const take = stored.take === undefined ? undefined : readGoods(stored.take);
|
|
52
|
+
const give = stored.give === undefined ? undefined : readGoods(stored.give);
|
|
53
|
+
if ((stored.take !== undefined && take === undefined) || (stored.give !== undefined && give === undefined)) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
return { ...(take === undefined ? {} : { take }), ...(give === undefined ? {} : { give }) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readFavorite(stored: Json): FavoritePayload | undefined {
|
|
60
|
+
if (!isRecord(stored) || typeof stored.on !== 'boolean') {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
const slug = optionalString(stored.slug);
|
|
64
|
+
return slug === undefined ? undefined : { slug, on: stored.on };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The economy kinds, by name; `onFavorites` hears the starred list each star answers with. */
|
|
68
|
+
export function economyKinds(game: GameFieldsSource, onFavorites: (games: string[]) => void): Record<string, RegisteredKind> {
|
|
69
|
+
return {
|
|
70
|
+
[ECONOMY_KINDS.grant]: checkedKind(readGoods, {
|
|
71
|
+
run: (goods, context) => context.call('grantInventoryItems', { ...game(), ...goods }),
|
|
72
|
+
}),
|
|
73
|
+
[ECONOMY_KINDS.exchange]: checkedKind(readExchange, {
|
|
74
|
+
run: (exchange, context) => context.call('exchangeInventoryItems', { ...game(), ...exchange }),
|
|
75
|
+
}),
|
|
76
|
+
[ECONOMY_KINDS.favorite]: checkedKind(readFavorite, {
|
|
77
|
+
mergeKey: (favorite) => favorite.slug,
|
|
78
|
+
replaces: true,
|
|
79
|
+
run: async (favorite, context) => {
|
|
80
|
+
const reply = await context.call('setGameFavorite', { slug: favorite.slug, on: favorite.on });
|
|
81
|
+
onFavorites(reply.games);
|
|
82
|
+
return reply;
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every kind the hub itself queues offline (`hub.*`), registered on a
|
|
3
|
+
* client's outbox. The docs' "Offline and replay" table lists them.
|
|
4
|
+
*/
|
|
5
|
+
import type { LocalResumeStore } from '../../resume/local-resume';
|
|
6
|
+
import type { LocalDailyStore } from '../../saved/local-saved';
|
|
7
|
+
import { writeLocalFavorites } from '../favorites';
|
|
8
|
+
import type { Outbox } from '../outbox';
|
|
9
|
+
import { avatarKinds } from './avatar-kind';
|
|
10
|
+
import { dailyKinds, type DailyCompletion, type DailyPayload } from './daily-kind';
|
|
11
|
+
import { economyKinds } from './economy-kinds';
|
|
12
|
+
import type { GameFieldsSource } from './game-fields';
|
|
13
|
+
import { progressKinds, type CloudSaveTracker } from './progress-kinds';
|
|
14
|
+
import { resumeKinds } from './resume-kind';
|
|
15
|
+
import { savedKinds } from './saved-kind';
|
|
16
|
+
|
|
17
|
+
export { AVATAR_KIND, type AvatarPayload } from './avatar-kind';
|
|
18
|
+
export { DAILY_KIND, type DailyPayload } from './daily-kind';
|
|
19
|
+
export { ECONOMY_KINDS, type ExchangePayload, type FavoritePayload } from './economy-kinds';
|
|
20
|
+
export { legacyOperation } from './legacy-ops';
|
|
21
|
+
export { RESUME_KIND, type ResumePayload } from './resume-kind';
|
|
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';
|
|
24
|
+
|
|
25
|
+
/** What the hub kinds report back to their client. */
|
|
26
|
+
export interface HubKindHooks {
|
|
27
|
+
/** This game's `{ slug }`. */
|
|
28
|
+
readonly game: GameFieldsSource;
|
|
29
|
+
/** A daily completion landed (the menu's calendar listens for `hub:daily-complete`). */
|
|
30
|
+
readonly onDailyComplete: (payload: DailyPayload, completion: DailyCompletion) => void;
|
|
31
|
+
/** The device copies of each game's resume slot (`hub.resume`). */
|
|
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;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Register every hub kind on `outbox`. */
|
|
40
|
+
export function registerHubKinds(outbox: Outbox, hooks: HubKindHooks): void {
|
|
41
|
+
const kinds = {
|
|
42
|
+
...progressKinds(hooks.game, hooks.cloudSaves),
|
|
43
|
+
...dailyKinds(hooks.game, hooks.onDailyComplete),
|
|
44
|
+
...economyKinds(hooks.game, writeLocalFavorites),
|
|
45
|
+
...resumeKinds(hooks.game, hooks.resume),
|
|
46
|
+
...avatarKinds(),
|
|
47
|
+
...savedKinds(hooks.dailies),
|
|
48
|
+
};
|
|
49
|
+
for (const [kind, definition] of Object.entries(kinds)) {
|
|
50
|
+
outbox.define(kind, definition);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The v1 client's queued writes (`hub:<slug>:queue`) as outbox operations,
|
|
3
|
+
* so a page upgraded from a vendored copy still replays what that copy queued.
|
|
4
|
+
*/
|
|
5
|
+
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
6
|
+
import type { QueueOp } from '../queue';
|
|
7
|
+
import { PROGRESS_KINDS, readProfile, readScoreOptions } from './progress-kinds';
|
|
8
|
+
|
|
9
|
+
/** One outbox operation, before it is stamped. */
|
|
10
|
+
export interface KindedPayload {
|
|
11
|
+
readonly kind: string;
|
|
12
|
+
readonly payload: Json;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The outbox operation for a v1 queued write, or `undefined` when it no longer reads as one. */
|
|
16
|
+
export function legacyOperation(op: QueueOp): KindedPayload | undefined {
|
|
17
|
+
switch (op.kind) {
|
|
18
|
+
case 'putSave':
|
|
19
|
+
return { kind: PROGRESS_KINDS.save, payload: { slot: op.slot, data: op.data, label: op.label } };
|
|
20
|
+
case 'deleteSave':
|
|
21
|
+
return { kind: PROGRESS_KINDS.save, payload: { slot: op.slot, deleted: true } };
|
|
22
|
+
case 'setStat':
|
|
23
|
+
return { kind: PROGRESS_KINDS.stat, payload: { key: op.key, mode: 'set', value: op.value } };
|
|
24
|
+
case 'maxStat':
|
|
25
|
+
return { kind: PROGRESS_KINDS.stat, payload: { key: op.key, mode: 'max', value: op.value } };
|
|
26
|
+
case 'incrStat':
|
|
27
|
+
return { kind: PROGRESS_KINDS.stat, payload: { key: op.key, mode: 'incr', value: op.amount } };
|
|
28
|
+
case 'submitScore': {
|
|
29
|
+
const opts = readScoreOptions({ ...op.opts });
|
|
30
|
+
return opts === undefined ? undefined : { kind: PROGRESS_KINDS.score, payload: { key: op.key, score: op.score, opts } };
|
|
31
|
+
}
|
|
32
|
+
case 'setProfile': {
|
|
33
|
+
const patch = readProfile({ ...op.patch });
|
|
34
|
+
return patch === undefined ? undefined : { kind: PROGRESS_KINDS.profile, payload: patch };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hub's progress kinds: saves, stats, scores and the game profile. Each
|
|
3
|
+
* is one replayable call, and each folds new writes into waiting ones so the
|
|
4
|
+
* backlog stays small: a save slot's last write wins, `incr`s add up, `max`es
|
|
5
|
+
* keep the largest, `set`s keep the last, a board keeps its best, and profile
|
|
6
|
+
* patches merge.
|
|
7
|
+
*/
|
|
8
|
+
import type { Json, SortDir } from '@diffenderfer-games/hub-contract';
|
|
9
|
+
import { isJson, isNullableString, isRecord, optionalNumber, optionalString } from '../../storage/json-guards';
|
|
10
|
+
import { checkedKind } from '../kind-adapters';
|
|
11
|
+
import type { RegisteredKind } from '../outbox-kinds';
|
|
12
|
+
import { betterScore } from '../queue';
|
|
13
|
+
import type { GameFieldsSource } from './game-fields';
|
|
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
|
+
|
|
23
|
+
/** A save slot write: new contents, or a delete. */
|
|
24
|
+
export type SavePayload =
|
|
25
|
+
| { readonly slot: string; readonly data: Json; readonly label: string | null }
|
|
26
|
+
| { readonly slot: string; readonly deleted: true };
|
|
27
|
+
|
|
28
|
+
/** How a stat update combines with the stored value. */
|
|
29
|
+
export type StatMode = 'set' | 'incr' | 'max';
|
|
30
|
+
|
|
31
|
+
/** One stat update. */
|
|
32
|
+
export type StatPayload = { readonly key: string; readonly mode: StatMode; readonly value: number };
|
|
33
|
+
|
|
34
|
+
/** A score's board options, as queued. */
|
|
35
|
+
export type ScoreOptionsPayload = { readonly title?: string; readonly sort?: SortDir; readonly meta?: Json };
|
|
36
|
+
|
|
37
|
+
/** One score submission. */
|
|
38
|
+
export type ScorePayload = { readonly key: string; readonly score: number; readonly opts: ScoreOptionsPayload };
|
|
39
|
+
|
|
40
|
+
/** A profile change; either part may be left out. */
|
|
41
|
+
export type ProfilePayload = { readonly displayName?: string | null; readonly data?: Json };
|
|
42
|
+
|
|
43
|
+
/** The kind names. */
|
|
44
|
+
export const PROGRESS_KINDS = { save: 'hub.save', stat: 'hub.stat', score: 'hub.score', profile: 'hub.profile' } as const;
|
|
45
|
+
|
|
46
|
+
function readSave(stored: Json): SavePayload | undefined {
|
|
47
|
+
if (!isRecord(stored)) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const slot = optionalString(stored.slot);
|
|
51
|
+
if (slot === undefined) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
if (stored.deleted === true) {
|
|
55
|
+
return { slot, deleted: true };
|
|
56
|
+
}
|
|
57
|
+
return isJson(stored.data) && isNullableString(stored.label) ? { slot, data: stored.data, label: stored.label } : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readStat(stored: Json): StatPayload | undefined {
|
|
61
|
+
if (!isRecord(stored)) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
const key = optionalString(stored.key);
|
|
65
|
+
const value = optionalNumber(stored.value);
|
|
66
|
+
const mode = stored.mode === 'set' || stored.mode === 'incr' || stored.mode === 'max' ? stored.mode : undefined;
|
|
67
|
+
return key === undefined || value === undefined || mode === undefined ? undefined : { key, mode, value };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Score options read back, or `undefined` when malformed. */
|
|
71
|
+
export function readScoreOptions(stored: Json): ScoreOptionsPayload | undefined {
|
|
72
|
+
if (!isRecord(stored)) {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
const title = optionalString(stored.title);
|
|
76
|
+
const sort = stored.sort === 'asc' || stored.sort === 'desc' ? stored.sort : undefined;
|
|
77
|
+
if (stored.meta !== undefined && !isJson(stored.meta)) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
return { ...(title === undefined ? {} : { title }), ...(sort === undefined ? {} : { sort }), ...(stored.meta === undefined ? {} : { meta: stored.meta }) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readScore(stored: Json): ScorePayload | undefined {
|
|
84
|
+
if (!isRecord(stored) || !isJson(stored.opts)) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
const key = optionalString(stored.key);
|
|
88
|
+
const score = optionalNumber(stored.score);
|
|
89
|
+
const opts = readScoreOptions(stored.opts);
|
|
90
|
+
return key === undefined || score === undefined || opts === undefined ? undefined : { key, score, opts };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A profile patch read back, or `undefined` when malformed. */
|
|
94
|
+
export function readProfile(stored: Json): ProfilePayload | undefined {
|
|
95
|
+
if (!isRecord(stored)) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
const { displayName, data: profileData } = stored;
|
|
99
|
+
if ((displayName !== undefined && !isNullableString(displayName)) || (profileData !== undefined && !isJson(profileData))) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
return { ...(displayName === undefined ? {} : { displayName }), ...(profileData === undefined ? {} : { data: profileData }) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Two waiting stat updates of one key folded together, or `undefined` when their modes differ. */
|
|
106
|
+
export function mergeStats(waiting: StatPayload, incoming: StatPayload): StatPayload | undefined {
|
|
107
|
+
if (waiting.mode !== incoming.mode) {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
switch (incoming.mode) {
|
|
111
|
+
case 'incr':
|
|
112
|
+
return { ...waiting, value: waiting.value + incoming.value };
|
|
113
|
+
case 'max':
|
|
114
|
+
return { ...waiting, value: Math.max(waiting.value, incoming.value) };
|
|
115
|
+
case 'set':
|
|
116
|
+
return incoming;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
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> {
|
|
128
|
+
return {
|
|
129
|
+
[PROGRESS_KINDS.save]: checkedKind(readSave, {
|
|
130
|
+
mergeKey: (save) => save.slot,
|
|
131
|
+
replaces: true,
|
|
132
|
+
run: async (save, context) => {
|
|
133
|
+
if ('deleted' in save) {
|
|
134
|
+
await context.call('deleteSave', { ...game(), slot: save.slot });
|
|
135
|
+
cloudSaves.rememberCloudSave(save.slot, null);
|
|
136
|
+
return;
|
|
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);
|
|
141
|
+
},
|
|
142
|
+
}),
|
|
143
|
+
[PROGRESS_KINDS.stat]: checkedKind(readStat, {
|
|
144
|
+
mergeKey: (stat) => stat.key,
|
|
145
|
+
merge: mergeStats,
|
|
146
|
+
run: async (stat, context) => {
|
|
147
|
+
await context.call('updateGameStat', { ...game(), key: stat.key, value: stat.value, mode: stat.mode });
|
|
148
|
+
},
|
|
149
|
+
}),
|
|
150
|
+
[PROGRESS_KINDS.score]: checkedKind(readScore, {
|
|
151
|
+
mergeKey: (submission) => submission.key,
|
|
152
|
+
merge: (waiting, incoming) => ({ ...waiting, score: betterScore(waiting.score, incoming.score, incoming.opts.sort), opts: incoming.opts }),
|
|
153
|
+
run: async (submission, context) => {
|
|
154
|
+
await context.call('submitScore', { ...game(), key: submission.key, score: submission.score, ...submission.opts });
|
|
155
|
+
},
|
|
156
|
+
}),
|
|
157
|
+
[PROGRESS_KINDS.profile]: checkedKind(readProfile, {
|
|
158
|
+
mergeKey: () => 'profile',
|
|
159
|
+
merge: (waiting, incoming) => ({ ...waiting, ...incoming }),
|
|
160
|
+
run: async (patch, context) => {
|
|
161
|
+
await context.call('setGameProfile', { ...game(), ...patch });
|
|
162
|
+
},
|
|
163
|
+
}),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hub's resume kind (`hub.resume`): send a game's saved run, or clear it.
|
|
3
|
+
* One slot per game, so a new op replaces any waiting one (a clear after a
|
|
4
|
+
* save drops the save, and the other way round).
|
|
5
|
+
*
|
|
6
|
+
* A save op carries only the run's facts and time: when it runs it sends
|
|
7
|
+
* the newest run in the device copy (`LocalResumeStore`), which keeps the
|
|
8
|
+
* outbox small. When the device copy couldn't be stored, the op carries the
|
|
9
|
+
* state itself. A session that lapsed (401) gets a guest session and tries
|
|
10
|
+
* once more, so a player's very first save can't be lost to it.
|
|
11
|
+
*/
|
|
12
|
+
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
13
|
+
import { HubError } from '../../errors';
|
|
14
|
+
import type { LocalResumeStore } from '../../resume/local-resume';
|
|
15
|
+
import { isJson, isNullableString, isRecord, optionalNumber, optionalString } from '../../storage/json-guards';
|
|
16
|
+
import { checkedKind } from '../kind-adapters';
|
|
17
|
+
import type { RegisteredKind, ReplayContext } from '../outbox-kinds';
|
|
18
|
+
import type { GameFieldsSource } from './game-fields';
|
|
19
|
+
|
|
20
|
+
/** The kind name. */
|
|
21
|
+
export const RESUME_KIND = 'hub.resume';
|
|
22
|
+
|
|
23
|
+
/** Send the run saved at `savedAt`; `state` only when the device copy couldn't hold it. */
|
|
24
|
+
export type ResumeSavePayload = {
|
|
25
|
+
readonly action: 'save';
|
|
26
|
+
readonly savedAt: number;
|
|
27
|
+
readonly label: string;
|
|
28
|
+
readonly fraction: number | null;
|
|
29
|
+
readonly title: string | null;
|
|
30
|
+
readonly version: number;
|
|
31
|
+
readonly state?: Json;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Clear the run, unless one was saved after `before`. */
|
|
35
|
+
export type ResumeClearPayload = { readonly action: 'clear'; readonly before: number };
|
|
36
|
+
|
|
37
|
+
/** One resume op. */
|
|
38
|
+
export type ResumePayload = ResumeSavePayload | ResumeClearPayload;
|
|
39
|
+
|
|
40
|
+
function readResume(stored: Json): ResumePayload | undefined {
|
|
41
|
+
if (!isRecord(stored)) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
if (stored.action === 'clear') {
|
|
45
|
+
const before = optionalNumber(stored.before);
|
|
46
|
+
return before === undefined ? undefined : { action: 'clear', before };
|
|
47
|
+
}
|
|
48
|
+
const savedAt = optionalNumber(stored.savedAt);
|
|
49
|
+
const label = optionalString(stored.label);
|
|
50
|
+
const version = optionalNumber(stored.version);
|
|
51
|
+
const fraction = stored.fraction === null ? null : optionalNumber(stored.fraction);
|
|
52
|
+
if (stored.action !== 'save' || savedAt === undefined || label === undefined || version === undefined || fraction === undefined
|
|
53
|
+
|| !isNullableString(stored.title) || (stored.state !== undefined && !isJson(stored.state))) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
const base = { action: 'save', savedAt, label, fraction, title: stored.title, version } as const;
|
|
57
|
+
return stored.state === undefined ? base : { ...base, state: stored.state };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The run to send: the device copy when it is at least as new as the op, else the op's own state; `undefined` when there is none. */
|
|
61
|
+
function runToSend(save: ResumeSavePayload, local: LocalResumeStore, slug: string): ResumeSavePayload | undefined {
|
|
62
|
+
const copy = local.read(slug);
|
|
63
|
+
if (copy?.status === 'saved' && copy.at >= save.savedAt) {
|
|
64
|
+
return { action: 'save', savedAt: copy.at, label: copy.label, fraction: copy.fraction, title: copy.title, version: copy.version, state: copy.state };
|
|
65
|
+
}
|
|
66
|
+
return save.state === undefined ? undefined : save;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Run `call`; on a lapsed session (401) start a guest session and run it once more. */
|
|
70
|
+
async function withSession<Reply>(context: ReplayContext, call: () => Promise<Reply>): Promise<Reply> {
|
|
71
|
+
try {
|
|
72
|
+
return await call();
|
|
73
|
+
} catch (failure) {
|
|
74
|
+
if (!(failure instanceof HubError) || failure.status !== 401) {
|
|
75
|
+
throw failure;
|
|
76
|
+
}
|
|
77
|
+
await context.call('ensureGuestSession');
|
|
78
|
+
return call();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The resume kind, by name, over the device copies in `local`. */
|
|
83
|
+
export function resumeKinds(game: GameFieldsSource, local: LocalResumeStore): Record<string, RegisteredKind> {
|
|
84
|
+
return {
|
|
85
|
+
[RESUME_KIND]: checkedKind(readResume, {
|
|
86
|
+
mergeKey: () => 'resume',
|
|
87
|
+
replaces: true,
|
|
88
|
+
run: async (op, context) => {
|
|
89
|
+
const { slug } = game();
|
|
90
|
+
if (op.action === 'clear') {
|
|
91
|
+
await withSession(context, () => context.call('clearResumeSlot', { slug, before: op.before }));
|
|
92
|
+
local.markSynced(slug, op.before);
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
const run = runToSend(op, local, slug);
|
|
96
|
+
if (run?.state === undefined) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
const { state } = run;
|
|
100
|
+
await withSession(context, () => context.call('saveResumeSlot', {
|
|
101
|
+
slug, state, progress: { label: run.label, fraction: run.fraction }, title: run.title, version: run.version, savedAt: run.savedAt,
|
|
102
|
+
}));
|
|
103
|
+
local.markSynced(slug, run.savedAt);
|
|
104
|
+
return undefined;
|
|
105
|
+
},
|
|
106
|
+
}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a typed kind definition into the JSON-in, JSON-out form the outbox
|
|
3
|
+
* runs: the hub's kinds check their stored payloads with a reader; a game's
|
|
4
|
+
* kinds vouch for their own payload type, as with saves.
|
|
5
|
+
*/
|
|
6
|
+
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
7
|
+
import { toGameData, toStoredJson, type GamePayload } from '../game-data';
|
|
8
|
+
import type { OfflineHandler, OfflineKindDefinition, RegisteredKind } from './outbox-kinds';
|
|
9
|
+
|
|
10
|
+
/** Reads a stored payload back, or `undefined` when it is malformed. */
|
|
11
|
+
export type PayloadReader<Payload> = (stored: Json) => Payload | undefined;
|
|
12
|
+
|
|
13
|
+
/** A kind whose payloads are checked by `read` (the hub's own kinds). */
|
|
14
|
+
export function checkedKind<Payload extends Json>(read: PayloadReader<Payload>, definition: OfflineKindDefinition<Payload>): RegisteredKind {
|
|
15
|
+
const readOrThrow = (stored: Json): Payload => {
|
|
16
|
+
const payload = read(stored);
|
|
17
|
+
if (payload === undefined) {
|
|
18
|
+
throw new Error('Malformed offline operation payload.');
|
|
19
|
+
}
|
|
20
|
+
return payload;
|
|
21
|
+
};
|
|
22
|
+
const { mergeKey, merge } = definition;
|
|
23
|
+
return {
|
|
24
|
+
run: (stored, context) => definition.run(readOrThrow(stored), context),
|
|
25
|
+
accepts: (stored) => read(stored) !== undefined,
|
|
26
|
+
...(mergeKey === undefined ? {} : { mergeKey: (stored: Json) => mergeKey(readOrThrow(stored)) }),
|
|
27
|
+
...(merge === undefined ? {} : { merge: (waiting: Json, incoming: Json) => merge(readOrThrow(waiting), readOrThrow(incoming)) }),
|
|
28
|
+
replaces: definition.replaces === true,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A game's kind: a bare handler or a full definition, its payload typed by the game. */
|
|
33
|
+
export function gameKind<Payload extends GamePayload>(definition: OfflineHandler<Payload> | OfflineKindDefinition<Payload>): RegisteredKind {
|
|
34
|
+
const full: OfflineKindDefinition<Payload> = typeof definition === 'function' ? { run: definition } : definition;
|
|
35
|
+
const { mergeKey, merge } = full;
|
|
36
|
+
return {
|
|
37
|
+
run: (stored, context) => full.run(toGameData<Payload>(stored), context),
|
|
38
|
+
accepts: () => true,
|
|
39
|
+
...(mergeKey === undefined ? {} : { mergeKey: (stored: Json) => mergeKey(toGameData<Payload>(stored)) }),
|
|
40
|
+
...(merge === undefined ? {} : {
|
|
41
|
+
merge: (waiting: Json, incoming: Json) => {
|
|
42
|
+
const merged = merge(toGameData<Payload>(waiting), toGameData<Payload>(incoming));
|
|
43
|
+
return merged === undefined ? undefined : toStoredJson(merged);
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
replaces: full.replaces === true,
|
|
47
|
+
};
|
|
48
|
+
}
|