@diffenderfer-games/hub 2.0.0-next.1 → 2.0.0-next.3
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 +40 -6
- package/README.md +25 -10
- package/dist/auto.d.ts +7 -7
- package/dist/auto.js +3 -3
- package/dist/doctor/cli.mjs +14 -7
- package/dist/doctor/cli.mjs.map +1 -1
- package/dist/index.d.ts +7 -7
- 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/shared/bridge.js +9 -3
- package/dist/shared/bridge.js.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.d.ts +10 -4
- package/dist/shared/daily.d.ts.map +1 -1
- package/dist/shared/daily.js +12 -2
- package/dist/shared/daily.js.map +1 -1
- package/dist/shared/index.d.ts +315 -36
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/input.d.ts +47 -5
- package/dist/shared/input.d.ts.map +1 -1
- package/dist/shared/input.js +139 -23
- package/dist/shared/input.js.map +1 -1
- package/dist/shared/latest-state.js +1 -1
- 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/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 +10 -2
- package/dist/shared/rooms.js.map +1 -1
- package/dist/shared/runtime-opener.js +1 -1
- package/dist/shared/social.d.ts +23 -8
- 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 +1588 -388
- package/dist/shared/src.js.map +1 -1
- 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 +1 -1
- package/package.json +2 -2
- package/src/core/connection.ts +187 -92
- package/src/core/game-data.ts +11 -9
- package/src/core/hub-economy.ts +47 -10
- package/src/core/hub-progress.ts +13 -16
- package/src/core/hub.ts +4 -2
- 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 +35 -0
- package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
- package/src/core/offline/hub-kinds/progress-kinds.ts +148 -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 +20 -22
- 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/rest/rest-client.ts +14 -3
- package/src/core/rest/routes.gen.ts +108 -87
- package/src/core/storage/json-guards.ts +6 -0
- package/src/core/version/client-version.gen.ts +1 -1
- package/src/daily/daily-system.ts +8 -1
- package/src/daily/types.ts +3 -2
- package/src/doctor/checks.ts +10 -2
- package/src/index.ts +6 -1
- package/src/input/focus-ring.ts +13 -0
- package/src/input/input-registry.ts +9 -2
- package/src/input/input-system.ts +4 -0
- package/src/input/modality.ts +122 -0
- package/src/input/nav-state.ts +13 -4
- package/src/input/zero-config.ts +1 -14
- package/src/input.ts +3 -0
- package/src/mp.ts +1 -1
- package/src/overlay/overlay.ts +5 -3
- 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 +13 -1
- package/src/realtime/runtime-api.ts +19 -8
- package/src/realtime/social-forwarder.ts +5 -0
- package/src/realtime/social-types.ts +8 -0
- package/src/realtime/wire-types.ts +7 -0
- package/src/rooms.ts +1 -1
|
@@ -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,35 @@
|
|
|
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 { writeLocalFavorites } from '../favorites';
|
|
6
|
+
import type { Outbox } from '../outbox';
|
|
7
|
+
import { dailyKinds, type DailyCompletion, type DailyPayload } from './daily-kind';
|
|
8
|
+
import { economyKinds } from './economy-kinds';
|
|
9
|
+
import type { GameFieldsSource } from './game-fields';
|
|
10
|
+
import { progressKinds } from './progress-kinds';
|
|
11
|
+
|
|
12
|
+
export { DAILY_KIND, type DailyPayload } from './daily-kind';
|
|
13
|
+
export { ECONOMY_KINDS, type ExchangePayload, type FavoritePayload } from './economy-kinds';
|
|
14
|
+
export { legacyOperation } from './legacy-ops';
|
|
15
|
+
export { PROGRESS_KINDS, type ProfilePayload, type SavePayload, type ScorePayload, type StatPayload } from './progress-kinds';
|
|
16
|
+
|
|
17
|
+
/** What the hub kinds report back to their client. */
|
|
18
|
+
export interface HubKindHooks {
|
|
19
|
+
/** This game's `{ slug }`. */
|
|
20
|
+
readonly game: GameFieldsSource;
|
|
21
|
+
/** A daily completion landed (the menu's calendar listens for `hub:daily-complete`). */
|
|
22
|
+
readonly onDailyComplete: (payload: DailyPayload, completion: DailyCompletion) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Register every hub kind on `outbox`. */
|
|
26
|
+
export function registerHubKinds(outbox: Outbox, hooks: HubKindHooks): void {
|
|
27
|
+
const kinds = {
|
|
28
|
+
...progressKinds(hooks.game),
|
|
29
|
+
...dailyKinds(hooks.game, hooks.onDailyComplete),
|
|
30
|
+
...economyKinds(hooks.game, writeLocalFavorites),
|
|
31
|
+
};
|
|
32
|
+
for (const [kind, definition] of Object.entries(kinds)) {
|
|
33
|
+
outbox.define(kind, definition);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -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,148 @@
|
|
|
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
|
+
/** A save slot write: new contents, or a delete. */
|
|
16
|
+
export type SavePayload =
|
|
17
|
+
| { readonly slot: string; readonly data: Json; readonly label: string | null }
|
|
18
|
+
| { readonly slot: string; readonly deleted: true };
|
|
19
|
+
|
|
20
|
+
/** How a stat update combines with the stored value. */
|
|
21
|
+
export type StatMode = 'set' | 'incr' | 'max';
|
|
22
|
+
|
|
23
|
+
/** One stat update. */
|
|
24
|
+
export type StatPayload = { readonly key: string; readonly mode: StatMode; readonly value: number };
|
|
25
|
+
|
|
26
|
+
/** A score's board options, as queued. */
|
|
27
|
+
export type ScoreOptionsPayload = { readonly title?: string; readonly sort?: SortDir; readonly meta?: Json };
|
|
28
|
+
|
|
29
|
+
/** One score submission. */
|
|
30
|
+
export type ScorePayload = { readonly key: string; readonly score: number; readonly opts: ScoreOptionsPayload };
|
|
31
|
+
|
|
32
|
+
/** A profile change; either part may be left out. */
|
|
33
|
+
export type ProfilePayload = { readonly displayName?: string | null; readonly data?: Json };
|
|
34
|
+
|
|
35
|
+
/** The kind names. */
|
|
36
|
+
export const PROGRESS_KINDS = { save: 'hub.save', stat: 'hub.stat', score: 'hub.score', profile: 'hub.profile' } as const;
|
|
37
|
+
|
|
38
|
+
function readSave(stored: Json): SavePayload | undefined {
|
|
39
|
+
if (!isRecord(stored)) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
const slot = optionalString(stored.slot);
|
|
43
|
+
if (slot === undefined) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
if (stored.deleted === true) {
|
|
47
|
+
return { slot, deleted: true };
|
|
48
|
+
}
|
|
49
|
+
return isJson(stored.data) && isNullableString(stored.label) ? { slot, data: stored.data, label: stored.label } : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readStat(stored: Json): StatPayload | undefined {
|
|
53
|
+
if (!isRecord(stored)) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
const key = optionalString(stored.key);
|
|
57
|
+
const value = optionalNumber(stored.value);
|
|
58
|
+
const mode = stored.mode === 'set' || stored.mode === 'incr' || stored.mode === 'max' ? stored.mode : undefined;
|
|
59
|
+
return key === undefined || value === undefined || mode === undefined ? undefined : { key, mode, value };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Score options read back, or `undefined` when malformed. */
|
|
63
|
+
export function readScoreOptions(stored: Json): ScoreOptionsPayload | undefined {
|
|
64
|
+
if (!isRecord(stored)) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
const title = optionalString(stored.title);
|
|
68
|
+
const sort = stored.sort === 'asc' || stored.sort === 'desc' ? stored.sort : undefined;
|
|
69
|
+
if (stored.meta !== undefined && !isJson(stored.meta)) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
return { ...(title === undefined ? {} : { title }), ...(sort === undefined ? {} : { sort }), ...(stored.meta === undefined ? {} : { meta: stored.meta }) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readScore(stored: Json): ScorePayload | undefined {
|
|
76
|
+
if (!isRecord(stored) || !isJson(stored.opts)) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
const key = optionalString(stored.key);
|
|
80
|
+
const score = optionalNumber(stored.score);
|
|
81
|
+
const opts = readScoreOptions(stored.opts);
|
|
82
|
+
return key === undefined || score === undefined || opts === undefined ? undefined : { key, score, opts };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A profile patch read back, or `undefined` when malformed. */
|
|
86
|
+
export function readProfile(stored: Json): ProfilePayload | undefined {
|
|
87
|
+
if (!isRecord(stored)) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
const { displayName, data: profileData } = stored;
|
|
91
|
+
if ((displayName !== undefined && !isNullableString(displayName)) || (profileData !== undefined && !isJson(profileData))) {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
return { ...(displayName === undefined ? {} : { displayName }), ...(profileData === undefined ? {} : { data: profileData }) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Two waiting stat updates of one key folded together, or `undefined` when their modes differ. */
|
|
98
|
+
export function mergeStats(waiting: StatPayload, incoming: StatPayload): StatPayload | undefined {
|
|
99
|
+
if (waiting.mode !== incoming.mode) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
switch (incoming.mode) {
|
|
103
|
+
case 'incr':
|
|
104
|
+
return { ...waiting, value: waiting.value + incoming.value };
|
|
105
|
+
case 'max':
|
|
106
|
+
return { ...waiting, value: Math.max(waiting.value, incoming.value) };
|
|
107
|
+
case 'set':
|
|
108
|
+
return incoming;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The progress kinds, by name. */
|
|
113
|
+
export function progressKinds(game: GameFieldsSource): Record<string, RegisteredKind> {
|
|
114
|
+
return {
|
|
115
|
+
[PROGRESS_KINDS.save]: checkedKind(readSave, {
|
|
116
|
+
mergeKey: (save) => save.slot,
|
|
117
|
+
replaces: true,
|
|
118
|
+
run: async (save, context) => {
|
|
119
|
+
if ('deleted' in save) {
|
|
120
|
+
await context.call('deleteSave', { ...game(), slot: save.slot });
|
|
121
|
+
} else {
|
|
122
|
+
await context.call('writeSave', { ...game(), slot: save.slot, data: save.data, label: save.label });
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
}),
|
|
126
|
+
[PROGRESS_KINDS.stat]: checkedKind(readStat, {
|
|
127
|
+
mergeKey: (stat) => stat.key,
|
|
128
|
+
merge: mergeStats,
|
|
129
|
+
run: async (stat, context) => {
|
|
130
|
+
await context.call('updateGameStat', { ...game(), key: stat.key, value: stat.value, mode: stat.mode });
|
|
131
|
+
},
|
|
132
|
+
}),
|
|
133
|
+
[PROGRESS_KINDS.score]: checkedKind(readScore, {
|
|
134
|
+
mergeKey: (submission) => submission.key,
|
|
135
|
+
merge: (waiting, incoming) => ({ ...waiting, score: betterScore(waiting.score, incoming.score, incoming.opts.sort), opts: incoming.opts }),
|
|
136
|
+
run: async (submission, context) => {
|
|
137
|
+
await context.call('submitScore', { ...game(), key: submission.key, score: submission.score, ...submission.opts });
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
[PROGRESS_KINDS.profile]: checkedKind(readProfile, {
|
|
141
|
+
mergeKey: () => 'profile',
|
|
142
|
+
merge: (waiting, incoming) => ({ ...waiting, ...incoming }),
|
|
143
|
+
run: async (patch, context) => {
|
|
144
|
+
await context.call('setGameProfile', { ...game(), ...patch });
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cached inventory, changed the way the hub would change it, so grants
|
|
3
|
+
* and house exchanges made offline show at once. The hub has the last word
|
|
4
|
+
* when they replay.
|
|
5
|
+
*/
|
|
6
|
+
import type { Inventory } from '@diffenderfer-games/hub-contract';
|
|
7
|
+
import { isRecord, optionalNumber, optionalString } from '../storage/json-guards';
|
|
8
|
+
|
|
9
|
+
/** Goods moved by a grant or an exchange: item key to quantity, and coins. */
|
|
10
|
+
export type GoodsPayload = { readonly items?: { readonly [key: string]: number }; readonly coins?: number };
|
|
11
|
+
|
|
12
|
+
/** An empty inventory. */
|
|
13
|
+
export const EMPTY_INVENTORY: Inventory = { items: [], coins: 0 };
|
|
14
|
+
|
|
15
|
+
/** A cached inventory, or `null` when there is none or it is malformed. */
|
|
16
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: an inventory read back from localStorage
|
|
17
|
+
export function readInventory(stored: unknown): Inventory | null {
|
|
18
|
+
if (!isRecord(stored) || !Array.isArray(stored.items)) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const coins = optionalNumber(stored.coins);
|
|
22
|
+
const items = stored.items.flatMap((item): Inventory['items'] => {
|
|
23
|
+
const key = isRecord(item) ? optionalString(item.key) : undefined;
|
|
24
|
+
const qty = isRecord(item) ? optionalNumber(item.qty) : undefined;
|
|
25
|
+
return key === undefined || qty === undefined ? [] : [{ key, qty }];
|
|
26
|
+
});
|
|
27
|
+
return coins === undefined ? null : { items, coins };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The holdings as a key-to-quantity map. */
|
|
31
|
+
function quantities(inventory: Inventory): Map<string, number> {
|
|
32
|
+
return new Map(inventory.items.map((item) => [item.key, item.qty]));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The holdings from a map (positive counts only, sorted by key). */
|
|
36
|
+
function fromQuantities(held: ReadonlyMap<string, number>, coins: number): Inventory {
|
|
37
|
+
const items = [...held].filter(([, qty]) => qty > 0).sort(([first], [second]) => first.localeCompare(second)).map(([key, qty]) => ({ key, qty }));
|
|
38
|
+
return { items, coins };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `inventory` with `goods` added. */
|
|
42
|
+
export function withGoods(inventory: Inventory, goods: GoodsPayload): Inventory {
|
|
43
|
+
const held = quantities(inventory);
|
|
44
|
+
for (const [key, qty] of Object.entries(goods.items ?? {})) {
|
|
45
|
+
held.set(key, (held.get(key) ?? 0) + qty);
|
|
46
|
+
}
|
|
47
|
+
return fromQuantities(held, inventory.coins + (goods.coins ?? 0));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `inventory` with `goods` removed, or the hub's refusal message when it can't cover them. */
|
|
51
|
+
export function withoutGoods(inventory: Inventory, goods: GoodsPayload): Inventory | string {
|
|
52
|
+
if ((goods.coins ?? 0) > inventory.coins) {
|
|
53
|
+
return "You don't have enough coins.";
|
|
54
|
+
}
|
|
55
|
+
const held = quantities(inventory);
|
|
56
|
+
for (const [key, qty] of Object.entries(goods.items ?? {})) {
|
|
57
|
+
const have = held.get(key) ?? 0;
|
|
58
|
+
if (qty > have) {
|
|
59
|
+
return `You don't have ${String(qty)}× "${key}".`;
|
|
60
|
+
}
|
|
61
|
+
held.set(key, have - qty);
|
|
62
|
+
}
|
|
63
|
+
return fromQuantities(held, inventory.coins - (goods.coins ?? 0));
|
|
64
|
+
}
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The offline cache: what the client serves when the hub can't be reached,
|
|
3
|
-
* and where
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* and where buffered analytics events wait (queued writes live in the
|
|
4
|
+
* outbox, `outbox.ts`). Keys are the v1 client's (`hub:user`,
|
|
5
|
+
* `hub:<slug>:save:<slot>`, `hub:<slug>:events`, ...), so a page upgraded
|
|
6
|
+
* from a vendored copy keeps that copy's cache, and its `hub:<slug>:queue`
|
|
7
|
+
* moves into the outbox.
|
|
6
8
|
*/
|
|
7
|
-
import type { Board, Json, SortDir, Stats } from '@diffenderfer-games/hub-contract';
|
|
9
|
+
import type { Board, Inventory, Json, SortDir, Stats } from '@diffenderfer-games/hub-contract';
|
|
8
10
|
import { isJson, isNullableString, isNumberRecord, isRecord, optionalNumber, optionalString, parseJsonText } from '../storage/json-guards';
|
|
9
11
|
import { createPageStore, type KeyValueStore } from '../storage/key-value';
|
|
10
12
|
import type { Profile, Save, SaveMeta, SnapshotResponse, User } from '../types';
|
|
11
13
|
import { appendEvent, readEvents, type BufferedEvent } from './events';
|
|
12
|
-
import {
|
|
14
|
+
import { EMPTY_INVENTORY, readInventory } from './local-inventory';
|
|
15
|
+
import { betterScore, readQueue, type QueueOp, type QueuedScoreOptions } from './queue';
|
|
13
16
|
|
|
14
17
|
/** The key of the locally known user, shared by every game (like the real session). */
|
|
15
18
|
export const USER_KEY = 'hub:user';
|
|
@@ -83,7 +86,6 @@ function readLocalBoard(stored: unknown): LocalBoard | null {
|
|
|
83
86
|
export class LocalStore {
|
|
84
87
|
private readonly prefix: string;
|
|
85
88
|
private readonly store: KeyValueStore;
|
|
86
|
-
private sequence = 0;
|
|
87
89
|
|
|
88
90
|
/** The cache for game `slug` in `store` (default: `localStorage`, or memory without it). */
|
|
89
91
|
constructor(slug: string, store: KeyValueStore = createPageStore()) {
|
|
@@ -197,26 +199,22 @@ export class LocalStore {
|
|
|
197
199
|
return { board, best };
|
|
198
200
|
}
|
|
199
201
|
|
|
200
|
-
/** The queued writes,
|
|
201
|
-
|
|
202
|
-
|
|
202
|
+
/** The v1 client's queued writes, removed from storage (the caller moves them into the outbox). */
|
|
203
|
+
takeLegacyQueue(): QueueOp[] {
|
|
204
|
+
const queue = readQueue(this.readStored(`${this.prefix}queue`));
|
|
205
|
+
this.store.remove(`${this.prefix}queue`);
|
|
206
|
+
return queue;
|
|
203
207
|
}
|
|
204
208
|
|
|
205
|
-
/**
|
|
206
|
-
|
|
207
|
-
this.
|
|
209
|
+
/** This game's cached inventory (empty when none is cached). */
|
|
210
|
+
getInventory(): Inventory {
|
|
211
|
+
return readInventory(this.readStored(`${this.prefix}inventory`)) ?? EMPTY_INVENTORY;
|
|
208
212
|
}
|
|
209
213
|
|
|
210
|
-
/**
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
/** Queue a write, collapsing it with a compatible queued one. */
|
|
216
|
-
enqueue(input: QueueOpInput): void {
|
|
217
|
-
this.sequence += 1;
|
|
218
|
-
const now = Date.now();
|
|
219
|
-
this.setQueue(enqueueOp(this.getQueue(), { ...input, id: `${String(now)}-${String(this.sequence)}`, ts: now }));
|
|
214
|
+
/** Store this game's inventory and return it. */
|
|
215
|
+
setInventory(inventory: Inventory): Inventory {
|
|
216
|
+
this.writeStored(`${this.prefix}inventory`, inventory);
|
|
217
|
+
return inventory;
|
|
220
218
|
}
|
|
221
219
|
|
|
222
220
|
/** The buffered analytics events, oldest first. */
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hub.offline`: the outbox, opened to games. A game defines its own kinds
|
|
3
|
+
* (a handler per kind, which may make several dependent calls) and enqueues
|
|
4
|
+
* operations of them; the hub queues them in order, replays them when it is
|
|
5
|
+
* reachable, and reports what happened.
|
|
6
|
+
*/
|
|
7
|
+
import { toStoredJson, type GamePayload } from '../game-data';
|
|
8
|
+
import { HubError } from '../errors';
|
|
9
|
+
import { gameKind } from './kind-adapters';
|
|
10
|
+
import type { OfflineEventName, OfflineListener, OfflineOp, Outbox } from './outbox';
|
|
11
|
+
import { HUB_KIND_PREFIX, type OfflineHandler, type OfflineKindDefinition } from './outbox-kinds';
|
|
12
|
+
|
|
13
|
+
/** What `hub.offline` drives. */
|
|
14
|
+
export interface OfflineHost {
|
|
15
|
+
/** The game's outbox. */
|
|
16
|
+
readonly outbox: Outbox;
|
|
17
|
+
/** Replay now when the hub is reachable (and nothing else is replaying). */
|
|
18
|
+
requestSync(): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The game-facing outbox API. */
|
|
22
|
+
export class OfflineApi {
|
|
23
|
+
private readonly host: OfflineHost;
|
|
24
|
+
|
|
25
|
+
/** The API over `host`. */
|
|
26
|
+
constructor(host: OfflineHost) {
|
|
27
|
+
this.host = host;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Define what an operation of `kind` does when it replays. Define every
|
|
32
|
+
* kind at load, before or after enqueueing: waiting ops of an undefined
|
|
33
|
+
* kind hold the queue until it is defined. `hub.*` kinds are the hub's own.
|
|
34
|
+
*/
|
|
35
|
+
define<Payload extends GamePayload>(kind: string, definition: OfflineHandler<Payload> | OfflineKindDefinition<Payload>): void {
|
|
36
|
+
if (kind.startsWith(HUB_KIND_PREFIX) || kind === '') {
|
|
37
|
+
throw new HubError(`Offline kinds starting with "${HUB_KIND_PREFIX}" are the hub's own; name yours after your game.`);
|
|
38
|
+
}
|
|
39
|
+
this.host.outbox.define(kind, gameKind(definition));
|
|
40
|
+
this.host.requestSync();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Queue an operation of `kind` and replay it as soon as the hub is
|
|
45
|
+
* reachable (at once when online). Resolves the queued op; its outcome
|
|
46
|
+
* arrives as a `replayed` or `failed` event. Throws a `HubError` (code
|
|
47
|
+
* `offline_queue_full`) when the outbox is full.
|
|
48
|
+
*/
|
|
49
|
+
enqueue(kind: string, payload: GamePayload): OfflineOp {
|
|
50
|
+
const op = this.host.outbox.stamp(kind, toStoredJson(payload));
|
|
51
|
+
this.host.outbox.add(op);
|
|
52
|
+
this.host.requestSync();
|
|
53
|
+
return op;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The waiting operations (the hub's and the game's), oldest first. */
|
|
57
|
+
pending(): OfflineOp[] {
|
|
58
|
+
return this.host.outbox.pending();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Listen for `queued`, `replayed` or `failed`; returns the unsubscribe function. */
|
|
62
|
+
on<Name extends OfflineEventName>(name: Name, listener: OfflineListener<Name>): () => void {
|
|
63
|
+
return this.host.outbox.on(name, listener);
|
|
64
|
+
}
|
|
65
|
+
}
|