@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,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,19 @@
|
|
|
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';
|
|
13
|
+
import { cloudSaveOf, readCloudSaveIndex, withCloudSave, type CloudSaveIndex } from './cloud-saves';
|
|
11
14
|
import { appendEvent, readEvents, type BufferedEvent } from './events';
|
|
12
|
-
import {
|
|
15
|
+
import { EMPTY_INVENTORY, readInventory } from './local-inventory';
|
|
16
|
+
import { betterScore, readQueue, type QueueOp, type QueuedScoreOptions } from './queue';
|
|
13
17
|
|
|
14
18
|
/** The key of the locally known user, shared by every game (like the real session). */
|
|
15
19
|
export const USER_KEY = 'hub:user';
|
|
@@ -83,7 +87,6 @@ function readLocalBoard(stored: unknown): LocalBoard | null {
|
|
|
83
87
|
export class LocalStore {
|
|
84
88
|
private readonly prefix: string;
|
|
85
89
|
private readonly store: KeyValueStore;
|
|
86
|
-
private sequence = 0;
|
|
87
90
|
|
|
88
91
|
/** The cache for game `slug` in `store` (default: `localStorage`, or memory without it). */
|
|
89
92
|
constructor(slug: string, store: KeyValueStore = createPageStore()) {
|
|
@@ -157,6 +160,37 @@ export class LocalStore {
|
|
|
157
160
|
this.store.remove(`${this.prefix}save:${slot}`);
|
|
158
161
|
}
|
|
159
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
|
+
|
|
160
194
|
/** This game's cached stats. */
|
|
161
195
|
getStats(): Stats {
|
|
162
196
|
const stored = this.readStored(`${this.prefix}stats`);
|
|
@@ -197,26 +231,22 @@ export class LocalStore {
|
|
|
197
231
|
return { board, best };
|
|
198
232
|
}
|
|
199
233
|
|
|
200
|
-
/** The queued writes,
|
|
201
|
-
|
|
202
|
-
|
|
234
|
+
/** The v1 client's queued writes, removed from storage (the caller moves them into the outbox). */
|
|
235
|
+
takeLegacyQueue(): QueueOp[] {
|
|
236
|
+
const queue = readQueue(this.readStored(`${this.prefix}queue`));
|
|
237
|
+
this.store.remove(`${this.prefix}queue`);
|
|
238
|
+
return queue;
|
|
203
239
|
}
|
|
204
240
|
|
|
205
|
-
/**
|
|
206
|
-
|
|
207
|
-
this.
|
|
241
|
+
/** This game's cached inventory (empty when none is cached). */
|
|
242
|
+
getInventory(): Inventory {
|
|
243
|
+
return readInventory(this.readStored(`${this.prefix}inventory`)) ?? EMPTY_INVENTORY;
|
|
208
244
|
}
|
|
209
245
|
|
|
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 }));
|
|
246
|
+
/** Store this game's inventory and return it. */
|
|
247
|
+
setInventory(inventory: Inventory): Inventory {
|
|
248
|
+
this.writeStored(`${this.prefix}inventory`, inventory);
|
|
249
|
+
return inventory;
|
|
220
250
|
}
|
|
221
251
|
|
|
222
252
|
/** The buffered analytics events, oldest first. */
|
|
@@ -234,11 +264,20 @@ export class LocalStore {
|
|
|
234
264
|
this.setEvents(appendEvent(this.getEvents(), { type, ts: Date.now() }));
|
|
235
265
|
}
|
|
236
266
|
|
|
237
|
-
/**
|
|
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
|
+
*/
|
|
238
273
|
hydrate(snapshot: SnapshotResponse): void {
|
|
239
274
|
if (snapshot.user !== null) {
|
|
240
275
|
this.writeStored(USER_KEY, snapshot.user);
|
|
241
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
|
+
}
|
|
242
281
|
for (const [slug, game] of Object.entries(snapshot.games)) {
|
|
243
282
|
const gamePrefix = `hub:${slug}:`;
|
|
244
283
|
if (game.profile !== null) {
|
|
@@ -247,6 +286,10 @@ export class LocalStore {
|
|
|
247
286
|
for (const save of game.saves) {
|
|
248
287
|
this.writeStored(`${gamePrefix}save:${save.slot}`, save);
|
|
249
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
|
+
}
|
|
250
293
|
this.writeStored(`${gamePrefix}stats`, game.stats);
|
|
251
294
|
this.writeStored(`${gamePrefix}inventory`, game.inventory);
|
|
252
295
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One operation in the offline outbox: a `kind` (whose registered handler
|
|
3
|
+
* performs it), its JSON payload, and its replay bookkeeping. Each entry is
|
|
4
|
+
* stored under its own key, so two tabs adding and removing entries never
|
|
5
|
+
* overwrite each other's lists.
|
|
6
|
+
*/
|
|
7
|
+
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
8
|
+
import { isJson, isRecord, optionalNumber, optionalString } from '../storage/json-guards';
|
|
9
|
+
|
|
10
|
+
/** A queued operation. */
|
|
11
|
+
export interface OutboxEntry {
|
|
12
|
+
/** Unique and sortable by queue time: replay runs in `id` order. Also the base of its operation keys. */
|
|
13
|
+
readonly id: string;
|
|
14
|
+
/** Which handler performs it (`hub.save`, `hub.stat`, or a game's own kind). */
|
|
15
|
+
readonly kind: string;
|
|
16
|
+
/** The handler's input. */
|
|
17
|
+
readonly payload: Json;
|
|
18
|
+
/** When it was queued (epoch ms). */
|
|
19
|
+
readonly createdAt: number;
|
|
20
|
+
/** How many times replay has started it. A started entry never absorbs a later one. */
|
|
21
|
+
readonly attempts: number;
|
|
22
|
+
/** Not before this time (epoch ms); 0 means as soon as possible. */
|
|
23
|
+
readonly retryAt: number;
|
|
24
|
+
/** The last failure's message, when it has failed before. */
|
|
25
|
+
readonly lastError?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** How wide the time part of an id is (enough for epoch ms until the year 5138). */
|
|
29
|
+
const TIME_DIGITS = 14;
|
|
30
|
+
|
|
31
|
+
/** How wide the sequence part of an id is. */
|
|
32
|
+
const SEQUENCE_DIGITS = 6;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A new entry id: the queue time, a per-page sequence and a random tag, so
|
|
36
|
+
* ids sort by time and two tabs queueing in the same millisecond never clash.
|
|
37
|
+
*/
|
|
38
|
+
export function newEntryId(now: number, sequence: number): string {
|
|
39
|
+
const tag = Math.floor(Math.random() * 36 ** 6).toString(36).padStart(6, '0');
|
|
40
|
+
return `${String(now).padStart(TIME_DIGITS, '0')}-${String(sequence % 10 ** SEQUENCE_DIGITS).padStart(SEQUENCE_DIGITS, '0')}-${tag}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A stored entry, or `undefined` when it is malformed. */
|
|
44
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: an entry read back from localStorage
|
|
45
|
+
export function readEntry(stored: unknown): OutboxEntry | undefined {
|
|
46
|
+
if (!isRecord(stored) || !isJson(stored.payload)) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
const id = optionalString(stored.id);
|
|
50
|
+
const kind = optionalString(stored.kind);
|
|
51
|
+
const createdAt = optionalNumber(stored.createdAt);
|
|
52
|
+
const attempts = optionalNumber(stored.attempts);
|
|
53
|
+
const retryAt = optionalNumber(stored.retryAt);
|
|
54
|
+
const lastError = optionalString(stored.lastError);
|
|
55
|
+
if (id === undefined || kind === undefined || createdAt === undefined || attempts === undefined || retryAt === undefined) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
return { id, kind, payload: stored.payload, createdAt, attempts, retryAt, ...(lastError === undefined ? {} : { lastError }) };
|
|
59
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbox kinds: what each sort of queued operation does when it replays,
|
|
3
|
+
* and how a new one folds into those already waiting. The hub registers its
|
|
4
|
+
* own (`hub.*`); a game adds its own with `hub.offline.define`.
|
|
5
|
+
*/
|
|
6
|
+
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
7
|
+
import type { EndpointName, EndpointReply } from '../rest/endpoint-types';
|
|
8
|
+
import type { CallArguments } from '../rest/rest-client';
|
|
9
|
+
|
|
10
|
+
/** What a kind's handler gets besides its payload. */
|
|
11
|
+
export interface ReplayContext {
|
|
12
|
+
/** The operation's id (stable across retries). */
|
|
13
|
+
readonly opId: string;
|
|
14
|
+
/** Which try this is, from 1. */
|
|
15
|
+
readonly attempt: number;
|
|
16
|
+
/** `false` when the op runs at once (online, nothing waiting); `true` when it runs from the queue. */
|
|
17
|
+
readonly isReplay: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Call a hub endpoint. Calls to endpoints marked for offline replay carry
|
|
20
|
+
* an operation key made from the op id and the call's position in the
|
|
21
|
+
* handler, so a retried op whose earlier calls already landed gets their
|
|
22
|
+
* remembered replies instead of running them twice. Make the same calls in
|
|
23
|
+
* the same order on every try.
|
|
24
|
+
*/
|
|
25
|
+
call<K extends EndpointName>(name: K, ...fieldsArgument: CallArguments<K>): Promise<EndpointReply<K>>;
|
|
26
|
+
/** A key for `step` of this op that is the same on every try: send it to a game server to deduplicate there. */
|
|
27
|
+
key(step: string): string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Performs one operation of a kind. Throw to fail it (see the docs for which failures retry). */
|
|
31
|
+
export type OfflineHandler<Payload> = (payload: Payload, context: ReplayContext) => Promise<Json | undefined> | Json | undefined;
|
|
32
|
+
|
|
33
|
+
/** A kind, with optional rules for folding new operations into waiting ones. */
|
|
34
|
+
export interface OfflineKindDefinition<Payload> {
|
|
35
|
+
/** Perform one operation. */
|
|
36
|
+
readonly run: OfflineHandler<Payload>;
|
|
37
|
+
/**
|
|
38
|
+
* Operations with the same merge key may fold together. Without one, every
|
|
39
|
+
* operation replays on its own.
|
|
40
|
+
*/
|
|
41
|
+
readonly mergeKey?: (payload: Payload) => string;
|
|
42
|
+
/**
|
|
43
|
+
* Fold `incoming` into the newest waiting operation with the same merge key
|
|
44
|
+
* (in its place in the queue); `undefined` to queue it separately.
|
|
45
|
+
*/
|
|
46
|
+
readonly merge?: (waiting: Payload, incoming: Payload) => Payload | undefined;
|
|
47
|
+
/** `true`: a new operation replaces every waiting one with the same merge key and goes last (a save slot's last write wins). */
|
|
48
|
+
readonly replaces?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A kind as the outbox runs it: payloads are the stored JSON. */
|
|
52
|
+
export interface RegisteredKind {
|
|
53
|
+
/** Perform one operation (malformed payloads are refused before this). */
|
|
54
|
+
readonly run: (payload: Json, context: ReplayContext) => Promise<Json | undefined> | Json | undefined;
|
|
55
|
+
/** Whether a stored payload is well formed for this kind. */
|
|
56
|
+
readonly accepts: (payload: Json) => boolean;
|
|
57
|
+
readonly mergeKey?: (payload: Json) => string;
|
|
58
|
+
readonly merge?: (waiting: Json, incoming: Json) => Json | undefined;
|
|
59
|
+
readonly replaces: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The prefix reserved for the hub's own kinds. */
|
|
63
|
+
export const HUB_KIND_PREFIX = 'hub.';
|
|
64
|
+
|
|
65
|
+
/** The registered kinds of one client. */
|
|
66
|
+
export class KindRegistry {
|
|
67
|
+
private readonly kinds = new Map<string, RegisteredKind>();
|
|
68
|
+
|
|
69
|
+
/** Register (or replace) a kind. */
|
|
70
|
+
register(kind: string, definition: RegisteredKind): void {
|
|
71
|
+
this.kinds.set(kind, definition);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A registered kind, or `undefined`. */
|
|
75
|
+
get(kind: string): RegisteredKind | undefined {
|
|
76
|
+
return this.kinds.get(kind);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a new operation joins the outbox (pure, so it is easy to test): queued
|
|
3
|
+
* on its own, folded into the newest waiting one with the same merge key, or
|
|
4
|
+
* replacing every waiting one with that key. An entry replay has started is
|
|
5
|
+
* never changed or replaced: its request may already have reached the hub
|
|
6
|
+
* under its operation key, so a changed payload would be lost.
|
|
7
|
+
*/
|
|
8
|
+
import type { OutboxEntry } from './outbox-entry';
|
|
9
|
+
import type { RegisteredKind } from './outbox-kinds';
|
|
10
|
+
|
|
11
|
+
/** The store changes one enqueue makes. */
|
|
12
|
+
export interface EnqueuePlan {
|
|
13
|
+
/** Entries to write (the new one, or a waiting one with a merged payload). */
|
|
14
|
+
readonly put: OutboxEntry;
|
|
15
|
+
/** Entries to remove (waiting ones the new one replaces). */
|
|
16
|
+
readonly remove: readonly string[];
|
|
17
|
+
/** `true` when `put` is a waiting entry that absorbed the new one. */
|
|
18
|
+
readonly isMerged: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** How `incoming` (of kind `kind`) joins `entries` (oldest first). */
|
|
22
|
+
export function planEnqueue(entries: readonly OutboxEntry[], incoming: OutboxEntry, kind: RegisteredKind): EnqueuePlan {
|
|
23
|
+
const separately: EnqueuePlan = { put: incoming, remove: [], isMerged: false };
|
|
24
|
+
const { mergeKey } = kind;
|
|
25
|
+
if (mergeKey === undefined) {
|
|
26
|
+
return separately;
|
|
27
|
+
}
|
|
28
|
+
const key = mergeKey(incoming.payload);
|
|
29
|
+
const sameKey = entries.filter((entry) => entry.kind === incoming.kind && kind.accepts(entry.payload) && mergeKey(entry.payload) === key);
|
|
30
|
+
if (kind.replaces) {
|
|
31
|
+
return { put: incoming, remove: sameKey.filter((entry) => entry.attempts === 0).map((entry) => entry.id), isMerged: false };
|
|
32
|
+
}
|
|
33
|
+
const newest = sameKey.at(-1);
|
|
34
|
+
if (kind.merge === undefined || newest === undefined || newest.attempts > 0) {
|
|
35
|
+
return separately;
|
|
36
|
+
}
|
|
37
|
+
const merged = kind.merge(newest.payload, incoming.payload);
|
|
38
|
+
return merged === undefined ? separately : { put: { ...newest, payload: merged }, remove: [], isMerged: true };
|
|
39
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replays the outbox in order, holding the game's replay lease so only one
|
|
3
|
+
* tab (or client copy) sends at a time. Each entry is marked started before
|
|
4
|
+
* its handler runs, then removed on success or on a permanent failure; a
|
|
5
|
+
* passing failure leaves it at the head with a backoff, so nothing behind it
|
|
6
|
+
* jumps the queue. An entry whose kind isn't defined here stops this
|
|
7
|
+
* replayer (and lets go of the lease) so a client that knows the kind, such
|
|
8
|
+
* as the game's own copy on the page, can carry on.
|
|
9
|
+
*/
|
|
10
|
+
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
11
|
+
import type { OutboxEntry } from './outbox-entry';
|
|
12
|
+
import type { KindRegistry, ReplayContext } from './outbox-kinds';
|
|
13
|
+
import type { OutboxStore } from './outbox-store';
|
|
14
|
+
import type { ReplayLease } from './replay-lease';
|
|
15
|
+
import { backoffMs, failureMessage, MAX_RETRIES, verdictFor } from './replay-policy';
|
|
16
|
+
|
|
17
|
+
/** An op dropped during replay, with why. */
|
|
18
|
+
export interface ReplayFailure {
|
|
19
|
+
/** The dropped op. */
|
|
20
|
+
readonly entry: OutboxEntry;
|
|
21
|
+
/** Why it was dropped (the hub's message, or the handler's). */
|
|
22
|
+
readonly error: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Why a replay stopped before the outbox was empty. */
|
|
26
|
+
export type ReplayStop = 'connection' | 'backoff' | 'unknown-kind' | 'lease';
|
|
27
|
+
|
|
28
|
+
/** What one replay did. */
|
|
29
|
+
export interface ReplayReport {
|
|
30
|
+
/** The ops that landed, in order. */
|
|
31
|
+
readonly replayed: OutboxEntry[];
|
|
32
|
+
/** The ops dropped. */
|
|
33
|
+
readonly failed: ReplayFailure[];
|
|
34
|
+
/** Why it stopped early, or `undefined` when the outbox is empty. */
|
|
35
|
+
readonly stoppedBy?: ReplayStop;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What the replayer works with. */
|
|
39
|
+
export interface ReplayerParts {
|
|
40
|
+
readonly store: OutboxStore;
|
|
41
|
+
readonly kinds: KindRegistry;
|
|
42
|
+
readonly lease: ReplayLease;
|
|
43
|
+
/** The context an entry's handler gets on try `attempt`. */
|
|
44
|
+
readonly contextFor: (entry: OutboxEntry, attempt: number) => ReplayContext;
|
|
45
|
+
/** An op landed (`result` is what its handler returned). */
|
|
46
|
+
readonly onReplayed: (entry: OutboxEntry, result: Json | undefined) => void;
|
|
47
|
+
/** An op was dropped. */
|
|
48
|
+
readonly onFailed: (failure: ReplayFailure) => void;
|
|
49
|
+
/** The hub couldn't be reached. */
|
|
50
|
+
readonly onUnreachable: () => void;
|
|
51
|
+
/** Ask for another replay in `delayMs`. */
|
|
52
|
+
readonly scheduleRetry: (delayMs: number) => void;
|
|
53
|
+
readonly now: () => number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A report for a replay that didn't get the lease. */
|
|
57
|
+
const LEASE_HELD: ReplayReport = { replayed: [], failed: [], stoppedBy: 'lease' };
|
|
58
|
+
|
|
59
|
+
/** Replays one game's outbox. */
|
|
60
|
+
export class OutboxReplayer {
|
|
61
|
+
private readonly parts: ReplayerParts;
|
|
62
|
+
|
|
63
|
+
/** A replayer over `parts`. */
|
|
64
|
+
constructor(parts: ReplayerParts) {
|
|
65
|
+
this.parts = parts;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Replay everything due, in order. `isEager` skips the backoff of ops waiting for the connection (it just came back). */
|
|
69
|
+
async replay(isEager = false): Promise<ReplayReport> {
|
|
70
|
+
let report: ReplayReport = LEASE_HELD;
|
|
71
|
+
const isHeld = await this.parts.lease.hold(async () => {
|
|
72
|
+
report = await this.drain(isEager);
|
|
73
|
+
});
|
|
74
|
+
return isHeld ? report : LEASE_HELD;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private async drain(isEager: boolean): Promise<ReplayReport> {
|
|
78
|
+
const { store, kinds, now } = this.parts;
|
|
79
|
+
const replayed: OutboxEntry[] = [];
|
|
80
|
+
const failed: ReplayFailure[] = [];
|
|
81
|
+
for (let head = store.list()[0]; head !== undefined; head = store.list()[0]) {
|
|
82
|
+
if (head.retryAt > now() && !(isEager && head.lastError === undefined)) {
|
|
83
|
+
this.parts.scheduleRetry(head.retryAt - now());
|
|
84
|
+
return { replayed, failed, stoppedBy: 'backoff' };
|
|
85
|
+
}
|
|
86
|
+
const kind = kinds.get(head.kind);
|
|
87
|
+
if (kind === undefined) {
|
|
88
|
+
return { replayed, failed, stoppedBy: 'unknown-kind' };
|
|
89
|
+
}
|
|
90
|
+
if (!kind.accepts(head.payload)) {
|
|
91
|
+
store.remove(head.id);
|
|
92
|
+
this.drop(failed, head, 'Malformed offline operation payload.');
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const started: OutboxEntry = { ...head, attempts: head.attempts + 1 };
|
|
96
|
+
store.put(started);
|
|
97
|
+
try {
|
|
98
|
+
const result = await kind.run(head.payload, this.parts.contextFor(head, started.attempts));
|
|
99
|
+
store.remove(head.id);
|
|
100
|
+
replayed.push(started);
|
|
101
|
+
this.parts.onReplayed(started, result ?? undefined);
|
|
102
|
+
} catch (failure) {
|
|
103
|
+
const stop = this.settleFailure(started, failure, failed);
|
|
104
|
+
if (stop !== undefined) {
|
|
105
|
+
return { replayed, failed, stoppedBy: stop };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { replayed, failed };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Record a failed try; answers why replay stops, or `undefined` to carry on with the next op. */
|
|
113
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: whatever a replay handler threw
|
|
114
|
+
private settleFailure(started: OutboxEntry, failure: unknown, failed: ReplayFailure[]): ReplayStop | undefined {
|
|
115
|
+
const { store, now } = this.parts;
|
|
116
|
+
const verdict = verdictFor(failure);
|
|
117
|
+
const message = failureMessage(failure);
|
|
118
|
+
if (verdict === 'drop' || (verdict === 'retry' && started.attempts >= MAX_RETRIES)) {
|
|
119
|
+
store.remove(started.id);
|
|
120
|
+
this.drop(failed, started, message);
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
const delay = backoffMs(started.attempts);
|
|
124
|
+
// Waiting for the connection is not an error of the op's own: an eager replay (back online) retries it at once.
|
|
125
|
+
const { id, kind, payload, createdAt, attempts } = started;
|
|
126
|
+
const retrying: OutboxEntry = { id, kind, payload, createdAt, attempts, retryAt: now() + delay };
|
|
127
|
+
const waiting: OutboxEntry = verdict === 'offline' ? retrying : { ...retrying, lastError: message };
|
|
128
|
+
store.put(waiting);
|
|
129
|
+
if (verdict === 'offline') {
|
|
130
|
+
this.parts.onUnreachable();
|
|
131
|
+
}
|
|
132
|
+
this.parts.scheduleRetry(delay);
|
|
133
|
+
return verdict === 'offline' ? 'connection' : 'backoff';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private drop(failed: ReplayFailure[], entry: OutboxEntry, error: string): void {
|
|
137
|
+
const failure = { entry, error };
|
|
138
|
+
failed.push(failure);
|
|
139
|
+
this.parts.onFailed(failure);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the outbox lives: one `localStorage` key per entry,
|
|
3
|
+
* `hub:<slug>:outbox:<id>`, read back in id (queue) order. A tab adding an
|
|
4
|
+
* entry and another removing one touch different keys, so neither loses the
|
|
5
|
+
* other's change. The outbox is capped by entry count and stored size.
|
|
6
|
+
*/
|
|
7
|
+
import { parseJsonText } from '../storage/json-guards';
|
|
8
|
+
import type { KeyValueStore } from '../storage/key-value';
|
|
9
|
+
import { readEntry, type OutboxEntry } from './outbox-entry';
|
|
10
|
+
|
|
11
|
+
/** The most entries one game's outbox holds. */
|
|
12
|
+
export const OUTBOX_MAX_ENTRIES = 500;
|
|
13
|
+
|
|
14
|
+
/** The most stored characters one game's outbox holds (about 2 MB of `localStorage`). */
|
|
15
|
+
export const OUTBOX_MAX_CHARS = 1_000_000;
|
|
16
|
+
|
|
17
|
+
/** One game's outbox. */
|
|
18
|
+
export class OutboxStore {
|
|
19
|
+
/** The key prefix of this game's entries. */
|
|
20
|
+
readonly prefix: string;
|
|
21
|
+
private readonly store: KeyValueStore;
|
|
22
|
+
|
|
23
|
+
/** The outbox for game `slug` in `store`. */
|
|
24
|
+
constructor(slug: string, store: KeyValueStore) {
|
|
25
|
+
this.prefix = `hub:${slug}:outbox:`;
|
|
26
|
+
this.store = store;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Every well-formed entry, oldest first. Malformed ones are removed. */
|
|
30
|
+
list(): OutboxEntry[] {
|
|
31
|
+
const entries: OutboxEntry[] = [];
|
|
32
|
+
for (const key of this.entryKeys()) {
|
|
33
|
+
const text = this.store.read(key);
|
|
34
|
+
const entry = text === null ? undefined : readEntry(parseJsonText(text));
|
|
35
|
+
if (entry === undefined) {
|
|
36
|
+
this.store.remove(key);
|
|
37
|
+
} else {
|
|
38
|
+
entries.push(entry);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return entries;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** How many entries are queued. */
|
|
45
|
+
count(): number {
|
|
46
|
+
return this.entryKeys().length;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One entry, or `undefined` once it is gone (replayed, or removed by another tab). */
|
|
50
|
+
get(id: string): OutboxEntry | undefined {
|
|
51
|
+
const text = this.store.read(this.prefix + id);
|
|
52
|
+
return text === null ? undefined : readEntry(parseJsonText(text));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Whether `extra` more stored characters in one more entry still fit under the caps. */
|
|
56
|
+
fits(extraChars: number): boolean {
|
|
57
|
+
const keys = this.entryKeys();
|
|
58
|
+
if (keys.length >= OUTBOX_MAX_ENTRIES) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
const used = keys.reduce((total, key) => total + (this.store.read(key)?.length ?? 0), 0);
|
|
62
|
+
return used + extraChars <= OUTBOX_MAX_CHARS;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Store an entry (new or updated); `false` when the storage refused it (full or blocked). */
|
|
66
|
+
put(entry: OutboxEntry): boolean {
|
|
67
|
+
const text = JSON.stringify(entry);
|
|
68
|
+
this.store.write(this.prefix + entry.id, text);
|
|
69
|
+
return this.store.read(this.prefix + entry.id) === text;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Forget an entry. */
|
|
73
|
+
remove(id: string): void {
|
|
74
|
+
this.store.remove(this.prefix + id);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Whether a storage key belongs to this outbox (for cross-tab `storage` events). */
|
|
78
|
+
owns(key: string | null): boolean {
|
|
79
|
+
return key === null || key.startsWith(this.prefix);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private entryKeys(): string[] {
|
|
83
|
+
return this.store.keys().filter((key) => key.startsWith(this.prefix)).sort();
|
|
84
|
+
}
|
|
85
|
+
}
|