@diffenderfer-games/hub 2.0.0-next.0 → 2.0.0-next.10
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 +163 -0
- package/LICENSE +21 -21
- package/README.md +203 -139
- 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 +119 -28
- 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 +9 -42
- 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 +2354 -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 +125 -89
- package/src/auto.ts +39 -39
- 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 +320 -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 +121 -0
- package/src/avatar/render/walker.ts +199 -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 +408 -299
- package/src/core/errors.ts +160 -147
- package/src/core/game-data.ts +40 -38
- package/src/core/hub-economy.ts +216 -179
- package/src/core/hub-progress.ts +269 -205
- package/src/core/hub.ts +352 -131
- package/src/core/menu-control.ts +61 -61
- package/src/core/offline/cloud-saves.ts +62 -0
- package/src/core/offline/events.ts +50 -50
- package/src/core/offline/favorites.ts +21 -21
- 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 +304 -261
- 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 +126 -171
- 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 +87 -74
- package/src/core/rest/endpoint-types.ts +22 -22
- package/src/core/rest/rest-client.ts +163 -152
- 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 +80 -74
- package/src/core/storage/key-value.ts +66 -66
- package/src/core/ticket.ts +43 -43
- 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/types.ts +134 -134
- package/src/core/version/client-version.gen.ts +2 -2
- package/src/core/version/hub-version.ts +62 -62
- package/src/core/version/semver.ts +97 -97
- package/src/daily/daily-system.ts +276 -161
- package/src/daily/page-daily.ts +36 -36
- package/src/daily/types.ts +105 -67
- package/src/daily.ts +10 -10
- package/src/doctor/checks.ts +86 -76
- package/src/doctor/cli.ts +101 -106
- package/src/doctor/hub-version-fetch.ts +43 -0
- package/src/doctor/tsconfig.json +12 -12
- package/src/doctor/world-check.ts +61 -0
- package/src/globals.ts +115 -110
- package/src/index.ts +68 -50
- package/src/input/device-state.ts +146 -146
- package/src/input/edges.ts +55 -55
- package/src/input/focus-ring.ts +13 -0
- package/src/input/gamepad.ts +61 -61
- package/src/input/input-events.ts +32 -32
- package/src/input/input-registry.ts +171 -164
- package/src/input/input-system.ts +406 -393
- package/src/input/legacy-controls.ts +64 -64
- package/src/input/modality.ts +122 -0
- package/src/input/nav-state.ts +200 -191
- package/src/input/navigator.ts +182 -171
- package/src/input/on-screen-controls.ts +101 -101
- package/src/input/page-control.ts +43 -43
- package/src/input/page-input.ts +193 -193
- package/src/input/player-remaps.ts +77 -77
- package/src/input/source-values.ts +116 -116
- package/src/input/spatial.ts +47 -47
- package/src/input/types.ts +331 -331
- package/src/input/zero-config.ts +164 -177
- package/src/input.ts +26 -22
- package/src/mp.ts +10 -10
- package/src/overlay/controls-panel.ts +163 -163
- package/src/overlay/dom.ts +46 -46
- package/src/overlay/layout-clusters.ts +162 -162
- package/src/overlay/layout-geometry.ts +136 -136
- package/src/overlay/layout.ts +203 -203
- package/src/overlay/listener-scope.ts +23 -23
- package/src/overlay/on-screen-keyboard.ts +137 -137
- package/src/overlay/overlay.ts +234 -208
- package/src/overlay/styles.ts +13 -12
- package/src/overlay/virtual-controls.ts +262 -262
- package/src/race.ts +10 -10
- package/src/realtime/bridge.ts +126 -121
- package/src/realtime/cross-bundle.ts +29 -29
- package/src/realtime/fallback-notice.ts +48 -48
- package/src/realtime/hub-link.ts +65 -65
- package/src/realtime/latest-state.ts +33 -33
- package/src/realtime/multiplayer-forwarder.ts +40 -28
- package/src/realtime/multiplayer-types.ts +66 -52
- package/src/realtime/page-runtime.ts +60 -60
- package/src/realtime/race-forwarder.ts +59 -59
- package/src/realtime/race-types.ts +132 -132
- package/src/realtime/room-types.ts +151 -125
- package/src/realtime/rooms-forwarder.ts +104 -79
- package/src/realtime/runtime-api.ts +74 -49
- package/src/realtime/runtime-opener.ts +61 -61
- package/src/realtime/social-forwarder.ts +77 -72
- package/src/realtime/social-types.ts +86 -78
- package/src/realtime/types.ts +54 -53
- package/src/realtime/wire-types.ts +79 -72
- package/src/realtime/world-types.ts +20 -0
- package/src/rooms.ts +10 -10
- package/src/social.ts +19 -18
- package/src/turns-kit/check-rules.ts +281 -0
- package/src/turns-rules.ts +12 -0
- package/src/turns.ts +14 -0
- package/src/types.ts +13 -13
- package/src/uistack/page-stack.ts +45 -45
- package/src/uistack/play-suspension.ts +82 -82
- package/src/uistack/types.ts +76 -76
- package/src/uistack/ui-stack.ts +144 -144
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this device last saw of a game's save slots on the hub, per player:
|
|
3
|
+
* each slot's `updatedAt` there, or `null` for a slot known to be empty. It
|
|
4
|
+
* decides two things offline. `getSave` answers from the cache only when
|
|
5
|
+
* the device knows the hub's copy of that slot (so a fresh device or cleared
|
|
6
|
+
* browser never reports "no save" for a slot it simply hasn't seen), and a
|
|
7
|
+
* queued save write sends the hub's `updatedAt` it was built on, so the hub
|
|
8
|
+
* refuses it when the slot has changed since.
|
|
9
|
+
*
|
|
10
|
+
* A snapshot gives the full list (`complete`: any other slot is empty); a
|
|
11
|
+
* single read or write adds that slot to a partial list.
|
|
12
|
+
*/
|
|
13
|
+
import { isRecord, optionalNumber } from '../storage/json-guards';
|
|
14
|
+
|
|
15
|
+
/** The hub's save slots for one game, as this device last saw them. */
|
|
16
|
+
export interface CloudSaveIndex {
|
|
17
|
+
/** Whose slots these are. */
|
|
18
|
+
readonly userId: number;
|
|
19
|
+
/** `true` when every slot the hub had is listed (from a snapshot). */
|
|
20
|
+
readonly complete: boolean;
|
|
21
|
+
/** Each slot's `updatedAt` on the hub, or `null` when it was empty. */
|
|
22
|
+
readonly saves: Readonly<Record<string, number | null>>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A stored index, or `null` when missing or malformed. */
|
|
26
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: an index read back from localStorage
|
|
27
|
+
export function readCloudSaveIndex(stored: unknown): CloudSaveIndex | null {
|
|
28
|
+
if (!isRecord(stored) || typeof stored.complete !== 'boolean' || !isRecord(stored.saves)) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const userId = optionalNumber(stored.userId);
|
|
32
|
+
if (userId === undefined) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const saves: Record<string, number | null> = {};
|
|
36
|
+
for (const [slot, updatedAt] of Object.entries(stored.saves)) {
|
|
37
|
+
const known = updatedAt === null ? null : optionalNumber(updatedAt);
|
|
38
|
+
if (known === undefined) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
saves[slot] = known;
|
|
42
|
+
}
|
|
43
|
+
return { userId, complete: stored.complete, saves };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The hub's `updatedAt` for `slot` as `userId` last saw it: a number, `null`
|
|
48
|
+
* when the slot was empty, or `undefined` when this device doesn't know.
|
|
49
|
+
*/
|
|
50
|
+
export function cloudSaveOf(index: CloudSaveIndex | null, userId: number, slot: string): number | null | undefined {
|
|
51
|
+
if (index?.userId !== userId) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
const updatedAt = index.saves[slot];
|
|
55
|
+
return updatedAt === undefined && index.complete ? null : updatedAt;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** `index` with `slot` seen at `updatedAt` (`null`: empty) by `userId`; another player's index starts over. */
|
|
59
|
+
export function withCloudSave(index: CloudSaveIndex | null, userId: number, slot: string, updatedAt: number | null): CloudSaveIndex {
|
|
60
|
+
const base = index?.userId === userId ? index : { userId, complete: false, saves: {} };
|
|
61
|
+
return { ...base, saves: { ...base.saves, [slot]: updatedAt } };
|
|
62
|
+
}
|
|
@@ -1,50 +1,50 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Analytics events recorded while offline (`play`, `heartbeat`, custom
|
|
3
|
-
* events), kept under `hub:<slug>:events` and sent when the hub is back.
|
|
4
|
-
* The buffer is capped; heartbeats are dropped first, then the oldest.
|
|
5
|
-
*/
|
|
6
|
-
import { isRecord, optionalNumber, optionalString } from '../storage/json-guards';
|
|
7
|
-
|
|
8
|
-
/** The most events kept while offline. */
|
|
9
|
-
export const EVENT_BUFFER_CAP = 100;
|
|
10
|
-
|
|
11
|
-
/** One buffered event. */
|
|
12
|
-
export interface BufferedEvent {
|
|
13
|
-
/** The event type, as `recordGameEvent` sends it. */
|
|
14
|
-
readonly type: string;
|
|
15
|
-
/** When it happened (epoch ms). */
|
|
16
|
-
readonly ts: number;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** The well-formed events of a stored buffer. */
|
|
20
|
-
// eslint-disable-next-line no-restricted-syntax -- trust boundary: a buffer read back from localStorage (possibly written by a v1 client)
|
|
21
|
-
export function readEvents(stored: unknown): BufferedEvent[] {
|
|
22
|
-
if (!Array.isArray(stored)) {
|
|
23
|
-
return [];
|
|
24
|
-
}
|
|
25
|
-
return stored.flatMap((entry): BufferedEvent[] => {
|
|
26
|
-
if (!isRecord(entry)) {
|
|
27
|
-
return [];
|
|
28
|
-
}
|
|
29
|
-
const type = optionalString(entry.type);
|
|
30
|
-
const ts = optionalNumber(entry.ts);
|
|
31
|
-
return type === undefined || ts === undefined ? [] : [{ type, ts }];
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** The buffer with one more event, trimmed to {@link EVENT_BUFFER_CAP}: oldest heartbeats go first, then the oldest events. */
|
|
36
|
-
export function appendEvent(events: readonly BufferedEvent[], incoming: BufferedEvent): BufferedEvent[] {
|
|
37
|
-
let buffered = [...events, incoming];
|
|
38
|
-
let excess = buffered.length - EVENT_BUFFER_CAP;
|
|
39
|
-
if (excess <= 0) {
|
|
40
|
-
return buffered;
|
|
41
|
-
}
|
|
42
|
-
buffered = buffered.filter((event) => {
|
|
43
|
-
if (excess > 0 && event.type === 'heartbeat') {
|
|
44
|
-
excess -= 1;
|
|
45
|
-
return false;
|
|
46
|
-
}
|
|
47
|
-
return true;
|
|
48
|
-
});
|
|
49
|
-
return buffered.length > EVENT_BUFFER_CAP ? buffered.slice(buffered.length - EVENT_BUFFER_CAP) : buffered;
|
|
50
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Analytics events recorded while offline (`play`, `heartbeat`, custom
|
|
3
|
+
* events), kept under `hub:<slug>:events` and sent when the hub is back.
|
|
4
|
+
* The buffer is capped; heartbeats are dropped first, then the oldest.
|
|
5
|
+
*/
|
|
6
|
+
import { isRecord, optionalNumber, optionalString } from '../storage/json-guards';
|
|
7
|
+
|
|
8
|
+
/** The most events kept while offline. */
|
|
9
|
+
export const EVENT_BUFFER_CAP = 100;
|
|
10
|
+
|
|
11
|
+
/** One buffered event. */
|
|
12
|
+
export interface BufferedEvent {
|
|
13
|
+
/** The event type, as `recordGameEvent` sends it. */
|
|
14
|
+
readonly type: string;
|
|
15
|
+
/** When it happened (epoch ms). */
|
|
16
|
+
readonly ts: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The well-formed events of a stored buffer. */
|
|
20
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: a buffer read back from localStorage (possibly written by a v1 client)
|
|
21
|
+
export function readEvents(stored: unknown): BufferedEvent[] {
|
|
22
|
+
if (!Array.isArray(stored)) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
return stored.flatMap((entry): BufferedEvent[] => {
|
|
26
|
+
if (!isRecord(entry)) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
const type = optionalString(entry.type);
|
|
30
|
+
const ts = optionalNumber(entry.ts);
|
|
31
|
+
return type === undefined || ts === undefined ? [] : [{ type, ts }];
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The buffer with one more event, trimmed to {@link EVENT_BUFFER_CAP}: oldest heartbeats go first, then the oldest events. */
|
|
36
|
+
export function appendEvent(events: readonly BufferedEvent[], incoming: BufferedEvent): BufferedEvent[] {
|
|
37
|
+
let buffered = [...events, incoming];
|
|
38
|
+
let excess = buffered.length - EVENT_BUFFER_CAP;
|
|
39
|
+
if (excess <= 0) {
|
|
40
|
+
return buffered;
|
|
41
|
+
}
|
|
42
|
+
buffered = buffered.filter((event) => {
|
|
43
|
+
if (excess > 0 && event.type === 'heartbeat') {
|
|
44
|
+
excess -= 1;
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
});
|
|
49
|
+
return buffered.length > EVENT_BUFFER_CAP ? buffered.slice(buffered.length - EVENT_BUFFER_CAP) : buffered;
|
|
50
|
+
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Starred games are site-wide, not per game, so they live under one global
|
|
3
|
-
* key (`hub:favorites`, the v1 client's) rather than in a game's cache. The
|
|
4
|
-
* list mirrors the hub's so the menu still shows it offline.
|
|
5
|
-
*/
|
|
6
|
-
import { parseJsonText, stringsIn } from '../storage/json-guards';
|
|
7
|
-
import { createPageStore } from '../storage/key-value';
|
|
8
|
-
|
|
9
|
-
/** The key of the mirrored favorites list. */
|
|
10
|
-
export const FAVORITES_KEY = 'hub:favorites';
|
|
11
|
-
|
|
12
|
-
/** The mirrored favorite slugs (empty when none or unreadable). */
|
|
13
|
-
export function readLocalFavorites(): string[] {
|
|
14
|
-
const text = createPageStore().read(FAVORITES_KEY);
|
|
15
|
-
return text === null ? [] : stringsIn(parseJsonText(text));
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** Mirror the hub's favorites list. */
|
|
19
|
-
export function writeLocalFavorites(games: readonly string[]): void {
|
|
20
|
-
createPageStore().write(FAVORITES_KEY, JSON.stringify(games));
|
|
21
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Starred games are site-wide, not per game, so they live under one global
|
|
3
|
+
* key (`hub:favorites`, the v1 client's) rather than in a game's cache. The
|
|
4
|
+
* list mirrors the hub's so the menu still shows it offline.
|
|
5
|
+
*/
|
|
6
|
+
import { parseJsonText, stringsIn } from '../storage/json-guards';
|
|
7
|
+
import { createPageStore } from '../storage/key-value';
|
|
8
|
+
|
|
9
|
+
/** The key of the mirrored favorites list. */
|
|
10
|
+
export const FAVORITES_KEY = 'hub:favorites';
|
|
11
|
+
|
|
12
|
+
/** The mirrored favorite slugs (empty when none or unreadable). */
|
|
13
|
+
export function readLocalFavorites(): string[] {
|
|
14
|
+
const text = createPageStore().read(FAVORITES_KEY);
|
|
15
|
+
return text === null ? [] : stringsIn(parseJsonText(text));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Mirror the hub's favorites list. */
|
|
19
|
+
export function writeLocalFavorites(games: readonly string[]): void {
|
|
20
|
+
createPageStore().write(FAVORITES_KEY, JSON.stringify(games));
|
|
21
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hub's avatar kind (`hub.avatar`, avatars.md §5.6): save your look.
|
|
3
|
+
* Latest wins: a newer save replaces a waiting one, and the hub keeps the
|
|
4
|
+
* newer of the replayed save and one another device made since (`savedAt`).
|
|
5
|
+
*/
|
|
6
|
+
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
7
|
+
import { decodeLook } from '@diffenderfer-games/hub-contract/avatar';
|
|
8
|
+
import { isRecord, optionalNumber, optionalString } from '../../storage/json-guards';
|
|
9
|
+
import { checkedKind } from '../kind-adapters';
|
|
10
|
+
import type { RegisteredKind } from '../outbox-kinds';
|
|
11
|
+
|
|
12
|
+
/** The kind name. */
|
|
13
|
+
export const AVATAR_KIND = 'hub.avatar';
|
|
14
|
+
|
|
15
|
+
/** Save this look (its code) as of `savedAt`. */
|
|
16
|
+
export type AvatarPayload = { readonly code: string; readonly savedAt: number };
|
|
17
|
+
|
|
18
|
+
function readAvatar(stored: Json): AvatarPayload | undefined {
|
|
19
|
+
if (!isRecord(stored)) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const code = optionalString(stored.code);
|
|
23
|
+
const savedAt = optionalNumber(stored.savedAt);
|
|
24
|
+
return code === undefined || savedAt === undefined ? undefined : { code, savedAt };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The avatar kind, by name. */
|
|
28
|
+
export function avatarKinds(): Record<string, RegisteredKind> {
|
|
29
|
+
return {
|
|
30
|
+
[AVATAR_KIND]: checkedKind(readAvatar, {
|
|
31
|
+
mergeKey: () => 'avatar',
|
|
32
|
+
replaces: true,
|
|
33
|
+
run: async (op, context) => {
|
|
34
|
+
const look = decodeLook(op.code)?.look;
|
|
35
|
+
if (look !== undefined) {
|
|
36
|
+
await context.call('saveMyAvatar', { look: { ...look, accessories: [...look.accessories] }, savedAt: op.savedAt });
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
},
|
|
40
|
+
}),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -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
|
+
}
|