@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
package/src/core/connection.ts
CHANGED
|
@@ -1,36 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The machinery under every `Hub` call: reaching the REST API, falling back
|
|
3
|
-
* to the offline cache, queueing writes made offline
|
|
4
|
-
* buffering analytics, warming the cache for every game, and
|
|
5
|
-
* online/offline/sync status (to `onStatus` listeners and as
|
|
6
|
-
* events, which the injected menu listens for).
|
|
3
|
+
* to the offline cache, queueing writes made offline in the outbox and
|
|
4
|
+
* replaying them, buffering analytics, warming the cache for every game, and
|
|
5
|
+
* reporting online/offline/sync status (to `onStatus` listeners and as
|
|
6
|
+
* `hub:*` window events, which the injected menu listens for).
|
|
7
7
|
*/
|
|
8
|
+
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
8
9
|
import { HubError } from './errors';
|
|
10
|
+
import { registerHubKinds, legacyOperation } from './offline/hub-kinds';
|
|
9
11
|
import { LocalStore } from './offline/local-store';
|
|
10
|
-
import
|
|
12
|
+
import { OfflineApi } from './offline/offline-api';
|
|
13
|
+
import { Outbox, type OfflineOp } from './offline/outbox';
|
|
14
|
+
import { LocalResumeStore } from './resume/local-resume';
|
|
15
|
+
import { LocalDailyStore } from './saved/local-saved';
|
|
11
16
|
import { absoluteApiBase, detectApiBase, detectSlug, emitWindowEvent, isBrowserPage } from './page';
|
|
12
17
|
import { createRestClient, type RestClient } from './rest/rest-client';
|
|
18
|
+
import { createPageStore } from './storage/key-value';
|
|
13
19
|
import type { HubOptions, HubStatus, SnapshotResponse } from './types';
|
|
14
20
|
|
|
15
21
|
/** How long `prefetchAll()` waits between snapshots unless forced. */
|
|
16
22
|
const PREFETCH_INTERVAL_MS = 30_000;
|
|
17
23
|
|
|
18
|
-
/** A queued
|
|
24
|
+
/** A queued op the hub refused during a sync (or that kept failing): dropped, with the reason. */
|
|
19
25
|
export interface SyncConflict {
|
|
20
|
-
/** The
|
|
21
|
-
readonly op:
|
|
22
|
-
/** The hub's message. */
|
|
26
|
+
/** The dropped op. */
|
|
27
|
+
readonly op: OfflineOp;
|
|
28
|
+
/** The hub's (or the handler's) message. */
|
|
23
29
|
readonly error?: string;
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
/** What one `sync()` did. */
|
|
27
33
|
export interface SyncResult {
|
|
28
|
-
/** Queued
|
|
34
|
+
/** Queued ops that landed. */
|
|
29
35
|
readonly flushed: number;
|
|
30
|
-
/** Queued
|
|
36
|
+
/** Queued ops that were dropped. */
|
|
31
37
|
readonly conflicts: SyncConflict[];
|
|
32
38
|
}
|
|
33
39
|
|
|
40
|
+
/** A sync that did nothing. */
|
|
41
|
+
const NOTHING_SYNCED: SyncResult = { flushed: 0, conflicts: [] };
|
|
42
|
+
|
|
34
43
|
/** Whether a failure means "the hub can't be reached" (rather than a refusal). */
|
|
35
44
|
// eslint-disable-next-line no-restricted-syntax -- trust boundary: whatever a call threw
|
|
36
45
|
function isTransportFailure(failure: unknown): boolean {
|
|
@@ -51,12 +60,22 @@ export class HubConnection {
|
|
|
51
60
|
readonly apiBase: string;
|
|
52
61
|
/** Typed REST calls to {@link apiBase}. */
|
|
53
62
|
protected readonly rest: RestClient;
|
|
54
|
-
/** This game's offline cache
|
|
63
|
+
/** This game's offline cache and event buffer. */
|
|
55
64
|
protected readonly store: LocalStore;
|
|
65
|
+
/** This game's queued operations. */
|
|
66
|
+
protected readonly outbox: Outbox;
|
|
67
|
+
/** The device copies of every game's resume slot (`hub.resume`). */
|
|
68
|
+
protected readonly resumeStore: LocalResumeStore;
|
|
69
|
+
/** The device copies of daily challenges in progress (every game's). */
|
|
70
|
+
protected readonly dailyStore: LocalDailyStore;
|
|
56
71
|
private readonly offlineMode: NonNullable<HubOptions['offline']>;
|
|
57
72
|
private isReachable = true;
|
|
58
|
-
private
|
|
73
|
+
private runningSync: Promise<SyncResult> | null = null;
|
|
74
|
+
private followingSync: Promise<SyncResult> | null = null;
|
|
75
|
+
private isFollowingEager = false;
|
|
76
|
+
private directSend: Promise<Json | undefined> | null = null;
|
|
59
77
|
private lastPrefetchAt = 0;
|
|
78
|
+
private offlineApi: OfflineApi | undefined;
|
|
60
79
|
private readonly statusListeners = new Set<(status: HubStatus) => void>();
|
|
61
80
|
|
|
62
81
|
/** A connection for `options` (slug and API base default to the page's). */
|
|
@@ -71,17 +90,43 @@ export class HubConnection {
|
|
|
71
90
|
this.setReachable(true);
|
|
72
91
|
},
|
|
73
92
|
});
|
|
74
|
-
|
|
93
|
+
const pageStore = createPageStore();
|
|
94
|
+
this.store = new LocalStore(this.slug ?? 'app', pageStore);
|
|
95
|
+
this.resumeStore = new LocalResumeStore(pageStore);
|
|
96
|
+
this.dailyStore = new LocalDailyStore(pageStore);
|
|
97
|
+
this.outbox = new Outbox({
|
|
98
|
+
slug: this.slug ?? 'app',
|
|
99
|
+
store: pageStore,
|
|
100
|
+
rest: this.rest,
|
|
101
|
+
emit: emitWindowEvent,
|
|
102
|
+
onUnreachable: () => {
|
|
103
|
+
this.setReachable(false);
|
|
104
|
+
},
|
|
105
|
+
onChange: () => {
|
|
106
|
+
this.notifyStatus();
|
|
107
|
+
},
|
|
108
|
+
onRetryDue: () => {
|
|
109
|
+
void this.retrySync();
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
registerHubKinds(this.outbox, {
|
|
113
|
+
game: () => this.gameFields(),
|
|
114
|
+
onDailyComplete: (payload, completion) => {
|
|
115
|
+
if (this.slug !== null) {
|
|
116
|
+
this.dailyStore.remove(this.slug, payload.day);
|
|
117
|
+
}
|
|
118
|
+
emitWindowEvent('hub:daily-complete', { day: payload.day, count: completion.count, rank: completion.rank, meta: payload.meta });
|
|
119
|
+
},
|
|
120
|
+
resume: this.resumeStore,
|
|
121
|
+
dailies: this.dailyStore,
|
|
122
|
+
cloudSaves: this.store,
|
|
123
|
+
});
|
|
124
|
+
this.importLegacyQueue();
|
|
75
125
|
if (typeof navigator !== 'undefined' && 'onLine' in navigator) {
|
|
76
126
|
this.isReachable = navigator.onLine;
|
|
77
127
|
}
|
|
78
128
|
if (isBrowserPage()) {
|
|
79
|
-
|
|
80
|
-
this.setReachable(true);
|
|
81
|
-
});
|
|
82
|
-
window.addEventListener('offline', () => {
|
|
83
|
-
this.setReachable(false);
|
|
84
|
-
});
|
|
129
|
+
this.listenToPage();
|
|
85
130
|
}
|
|
86
131
|
}
|
|
87
132
|
|
|
@@ -95,9 +140,25 @@ export class HubConnection {
|
|
|
95
140
|
return this.isReachable;
|
|
96
141
|
}
|
|
97
142
|
|
|
98
|
-
/**
|
|
143
|
+
/**
|
|
144
|
+
* The offline outbox: define your own kinds of operation, queue them, and
|
|
145
|
+
* hear how they replay (`hub.offline.define`, `enqueue`, `pending`, `on`).
|
|
146
|
+
*/
|
|
147
|
+
get offline(): OfflineApi {
|
|
148
|
+
this.offlineApi ??= new OfflineApi({
|
|
149
|
+
outbox: this.outbox,
|
|
150
|
+
requestSync: () => {
|
|
151
|
+
if (!this.isOffline) {
|
|
152
|
+
void this.sync();
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
return this.offlineApi;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Queued operations plus buffered analytics events waiting to sync. */
|
|
99
160
|
pending(): number {
|
|
100
|
-
return this.
|
|
161
|
+
return this.outbox.count() + this.store.getEvents().length;
|
|
101
162
|
}
|
|
102
163
|
|
|
103
164
|
/** Subscribe to connection and sync status changes; returns the unsubscribe function. */
|
|
@@ -121,7 +182,7 @@ export class HubConnection {
|
|
|
121
182
|
emitWindowEvent('hub:status', status);
|
|
122
183
|
}
|
|
123
184
|
|
|
124
|
-
/** Record reachability; coming back online
|
|
185
|
+
/** Record reachability; coming back online replays the outbox and re-warms the cache. */
|
|
125
186
|
protected setReachable(isReachableNow: boolean): void {
|
|
126
187
|
if (isReachableNow === this.isReachable) {
|
|
127
188
|
return;
|
|
@@ -130,8 +191,8 @@ export class HubConnection {
|
|
|
130
191
|
emitWindowEvent(isReachableNow ? 'hub:online' : 'hub:offline', { pending: this.pending() });
|
|
131
192
|
this.notifyStatus();
|
|
132
193
|
if (isReachableNow) {
|
|
133
|
-
|
|
134
|
-
void this.prefetchAll().catch(() => undefined);
|
|
194
|
+
// Replay first, so the snapshot that re-warms the cache already has the queued changes.
|
|
195
|
+
void this.replayAll(true).then(() => this.prefetchAll()).catch(() => undefined);
|
|
135
196
|
}
|
|
136
197
|
}
|
|
137
198
|
|
|
@@ -163,98 +224,105 @@ export class HubConnection {
|
|
|
163
224
|
}
|
|
164
225
|
}
|
|
165
226
|
|
|
166
|
-
/** Send one queued write. */
|
|
167
|
-
private async sendOp(op: QueueOpInput): Promise<void> {
|
|
168
|
-
const game = this.gameFields();
|
|
169
|
-
switch (op.kind) {
|
|
170
|
-
case 'putSave':
|
|
171
|
-
await this.rest.call('writeSave', { ...game, slot: op.slot, data: op.data, label: op.label });
|
|
172
|
-
return;
|
|
173
|
-
case 'deleteSave':
|
|
174
|
-
await this.rest.call('deleteSave', { ...game, slot: op.slot });
|
|
175
|
-
return;
|
|
176
|
-
case 'setStat':
|
|
177
|
-
await this.rest.call('updateGameStat', { ...game, key: op.key, value: op.value, mode: 'set' });
|
|
178
|
-
return;
|
|
179
|
-
case 'maxStat':
|
|
180
|
-
await this.rest.call('updateGameStat', { ...game, key: op.key, value: op.value, mode: 'max' });
|
|
181
|
-
return;
|
|
182
|
-
case 'incrStat':
|
|
183
|
-
await this.rest.call('updateGameStat', { ...game, key: op.key, value: op.amount, mode: 'incr' });
|
|
184
|
-
return;
|
|
185
|
-
case 'submitScore':
|
|
186
|
-
await this.rest.call('submitScore', { ...game, key: op.key, score: op.score, ...op.opts });
|
|
187
|
-
return;
|
|
188
|
-
case 'setProfile':
|
|
189
|
-
await this.rest.call('setGameProfile', { ...game, ...op.patch });
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
227
|
/**
|
|
195
|
-
* Apply a write to the offline cache at once, then
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
228
|
+
* Apply a write to the offline cache at once, then perform it as an op of
|
|
229
|
+
* `kind`: sent now when online with nothing waiting, else queued behind
|
|
230
|
+
* the others. Resolves with the local answer (or, sent now, with
|
|
231
|
+
* `replyAnswer`'s reading of the hub's reply). A real API error on the
|
|
232
|
+
* direct path reaches the caller and is not queued; a transport failure
|
|
233
|
+
* queues the op under the same id, so if the request did land, its replay
|
|
234
|
+
* is recognised by the hub and not applied twice.
|
|
199
235
|
*/
|
|
200
|
-
protected async
|
|
236
|
+
protected async mutateOffline<Reply>(kind: string, payload: Json, localAnswer: () => Reply, replyAnswer?: (result: Json | undefined) => Reply | undefined): Promise<Reply> {
|
|
201
237
|
const answer = localAnswer();
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
this.
|
|
238
|
+
const op = this.outbox.stamp(kind, payload);
|
|
239
|
+
if (this.isOffline || this.outbox.count() > 0 || this.runningSync !== null || this.directSend !== null) {
|
|
240
|
+
this.outbox.add(op);
|
|
205
241
|
if (!this.isOffline) {
|
|
206
242
|
void this.sync();
|
|
207
243
|
}
|
|
208
244
|
return answer;
|
|
209
245
|
}
|
|
246
|
+
const sending = this.outbox.runNow(op);
|
|
247
|
+
this.directSend = sending;
|
|
210
248
|
try {
|
|
211
|
-
await
|
|
212
|
-
return answer;
|
|
249
|
+
const result = await sending;
|
|
250
|
+
return replyAnswer?.(result) ?? answer;
|
|
213
251
|
} catch (failure) {
|
|
214
252
|
if (!isTransportFailure(failure)) {
|
|
215
253
|
throw failure;
|
|
216
254
|
}
|
|
217
255
|
this.setReachable(false);
|
|
218
|
-
this.
|
|
219
|
-
this.notifyStatus();
|
|
256
|
+
this.outbox.add({ ...op, attempts: 1 });
|
|
220
257
|
return answer;
|
|
258
|
+
} finally {
|
|
259
|
+
if (this.directSend === sending) {
|
|
260
|
+
this.directSend = null;
|
|
261
|
+
}
|
|
221
262
|
}
|
|
222
263
|
}
|
|
223
264
|
|
|
224
265
|
/**
|
|
225
|
-
* Replay the
|
|
226
|
-
* transport failure (offline again: the rest stays queued);
|
|
227
|
-
* refuses is dropped and reported as a conflict.
|
|
266
|
+
* Replay the outbox, then the buffered events, in order. Stops at the
|
|
267
|
+
* first transport failure (offline again: the rest stays queued); an op
|
|
268
|
+
* the hub refuses is dropped and reported as a conflict.
|
|
269
|
+
*/
|
|
270
|
+
sync(): Promise<SyncResult> {
|
|
271
|
+
return this.isOffline ? Promise.resolve(NOTHING_SYNCED) : this.replayAll(false);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** A retry came due: try again even when the hub was last unreachable (the try is the probe), unless the browser itself is offline. */
|
|
275
|
+
private retrySync(): Promise<SyncResult> {
|
|
276
|
+
const isBrowserOffline = typeof navigator !== 'undefined' && 'onLine' in navigator && !navigator.onLine;
|
|
277
|
+
return this.offlineMode === 'always' || isBrowserOffline ? Promise.resolve(NOTHING_SYNCED) : this.replayAll(false);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* One replay at a time per client. A call during one queues a single
|
|
282
|
+
* follow-up replay (eager if any caller asked for that), so an op queued
|
|
283
|
+
* or a reconnect that happens mid-replay is never missed.
|
|
228
284
|
*/
|
|
229
|
-
|
|
230
|
-
if (this.
|
|
231
|
-
return
|
|
285
|
+
private replayAll(isEager: boolean): Promise<SyncResult> {
|
|
286
|
+
if (this.slug === null || this.offlineMode === 'always') {
|
|
287
|
+
return Promise.resolve(NOTHING_SYNCED);
|
|
232
288
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
289
|
+
const running = this.runningSync;
|
|
290
|
+
if (running === null) {
|
|
291
|
+
return this.startReplay(isEager);
|
|
292
|
+
}
|
|
293
|
+
this.isFollowingEager ||= isEager;
|
|
294
|
+
this.followingSync ??= running.catch(() => NOTHING_SYNCED).then(() => {
|
|
295
|
+
const isFollowUpEager = this.isFollowingEager;
|
|
296
|
+
this.isFollowingEager = false;
|
|
297
|
+
this.followingSync = null;
|
|
298
|
+
return this.startReplay(isFollowUpEager);
|
|
299
|
+
});
|
|
300
|
+
return this.followingSync;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private startReplay(isEager: boolean): Promise<SyncResult> {
|
|
304
|
+
const run = this.runReplay(isEager).finally(() => {
|
|
305
|
+
if (this.runningSync === run) {
|
|
306
|
+
this.runningSync = null;
|
|
250
307
|
}
|
|
308
|
+
});
|
|
309
|
+
this.runningSync = run;
|
|
310
|
+
return run;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private async runReplay(isEager: boolean): Promise<SyncResult> {
|
|
314
|
+
if (this.directSend !== null) {
|
|
315
|
+
await this.directSend.catch(() => undefined);
|
|
316
|
+
}
|
|
317
|
+
const report = await this.outbox.replay(isEager);
|
|
318
|
+
const conflicts = report.failed.map((failure) => ({ op: failure.entry, error: failure.error }));
|
|
319
|
+
if (report.stoppedBy !== 'connection') {
|
|
251
320
|
await this.flushEvents();
|
|
252
|
-
} finally {
|
|
253
|
-
this.isSyncing = false;
|
|
254
321
|
}
|
|
255
|
-
|
|
322
|
+
const result: SyncResult = { flushed: report.replayed.length, conflicts };
|
|
323
|
+
emitWindowEvent('hub:sync', { pending: this.pending(), ...result });
|
|
256
324
|
this.notifyStatus();
|
|
257
|
-
return
|
|
325
|
+
return result;
|
|
258
326
|
}
|
|
259
327
|
|
|
260
328
|
/** Send the buffered analytics events; a refused one is dropped (telemetry never blocks). */
|
|
@@ -273,6 +341,43 @@ export class HubConnection {
|
|
|
273
341
|
}
|
|
274
342
|
}
|
|
275
343
|
|
|
344
|
+
/** Move a v1 copy's queued writes into the outbox, in their order. */
|
|
345
|
+
private importLegacyQueue(): void {
|
|
346
|
+
if (this.slug === null) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
for (const legacy of this.store.takeLegacyQueue()) {
|
|
351
|
+
const operation = legacyOperation(legacy);
|
|
352
|
+
if (operation !== undefined) {
|
|
353
|
+
this.outbox.add(this.outbox.stamp(operation.kind, operation.payload));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
} catch {
|
|
357
|
+
// A full outbox keeps what fitted; the client must still start.
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Follow the browser's connection, other tabs' outbox changes, and replay what an earlier page left queued. */
|
|
362
|
+
private listenToPage(): void {
|
|
363
|
+
window.addEventListener('online', () => {
|
|
364
|
+
this.setReachable(true);
|
|
365
|
+
});
|
|
366
|
+
window.addEventListener('offline', () => {
|
|
367
|
+
this.setReachable(false);
|
|
368
|
+
});
|
|
369
|
+
window.addEventListener('storage', (event) => {
|
|
370
|
+
if (event instanceof StorageEvent && this.outbox.entries.owns(event.key)) {
|
|
371
|
+
this.notifyStatus();
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
if (this.outbox.count() > 0) {
|
|
375
|
+
setTimeout(() => {
|
|
376
|
+
void this.sync();
|
|
377
|
+
}, 0);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
276
381
|
/**
|
|
277
382
|
* Warm the offline cache for every game the player has touched, in one
|
|
278
383
|
* request, so going offline later still shows complete progress.
|
|
@@ -292,6 +397,10 @@ export class HubConnection {
|
|
|
292
397
|
throw failure;
|
|
293
398
|
}
|
|
294
399
|
this.lastPrefetchAt = Date.now();
|
|
400
|
+
if (this.outbox.count() > 0) {
|
|
401
|
+
// Changes are still waiting to sync: the cache already holds them, and the snapshot doesn't yet.
|
|
402
|
+
return snapshot;
|
|
403
|
+
}
|
|
295
404
|
this.store.hydrate(snapshot);
|
|
296
405
|
emitWindowEvent('hub:prefetch', { games: Object.keys(snapshot.games).length });
|
|
297
406
|
return snapshot;
|
package/src/core/errors.ts
CHANGED
|
@@ -15,12 +15,19 @@ export const UNSUPPORTED_BY_HUB = 'unsupported_by_hub';
|
|
|
15
15
|
/** The code the social runtime gives a call it could not send (no connection). */
|
|
16
16
|
export const OFFLINE_CODE = 'offline';
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* The code `getSave` rejects with when the hub can't be reached and this
|
|
20
|
+
* device has no copy of the slot it can trust (a fresh device or cleared
|
|
21
|
+
* browser): the slot may well hold a save, so it is not reported empty.
|
|
22
|
+
*/
|
|
23
|
+
export const SAVE_UNKNOWN_CODE = 'save_unknown';
|
|
24
|
+
|
|
18
25
|
/**
|
|
19
26
|
* A machine-readable error code: one of the contract's public codes
|
|
20
27
|
* (`content_rejected`, `rate_limited`, `suspended`, `claim_required`, ...),
|
|
21
28
|
* {@link UNSUPPORTED_BY_HUB}, or a code a newer hub added.
|
|
22
29
|
*/
|
|
23
|
-
export type HubErrorCode = ErrorCode | typeof UNSUPPORTED_BY_HUB | typeof OFFLINE_CODE | (string & Record<never, never>);
|
|
30
|
+
export type HubErrorCode = ErrorCode | typeof UNSUPPORTED_BY_HUB | typeof OFFLINE_CODE | typeof SAVE_UNKNOWN_CODE | (string & Record<never, never>);
|
|
24
31
|
|
|
25
32
|
/** The machine-readable extras the hub attaches to an error body. */
|
|
26
33
|
export interface HubErrorDetails {
|
|
@@ -34,6 +41,8 @@ export interface HubErrorDetails {
|
|
|
34
41
|
readonly retryAfterMs?: number | undefined;
|
|
35
42
|
/** When a suspension or mute ends (epoch ms). */
|
|
36
43
|
readonly until?: number | undefined;
|
|
44
|
+
/** An online game's rules refused a move (`illegal`): the rules' own short code. */
|
|
45
|
+
readonly rule?: string | undefined;
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
/**
|
|
@@ -55,6 +64,8 @@ export class HubError extends Error {
|
|
|
55
64
|
readonly retryAfterMs: number | undefined;
|
|
56
65
|
/** When a suspension or mute ends (epoch ms). */
|
|
57
66
|
readonly until: number | undefined;
|
|
67
|
+
/** An online game's rules refused a move (`code` is `illegal`): the rules' own short code, such as `cell_taken`. */
|
|
68
|
+
readonly rule: string | undefined;
|
|
58
69
|
|
|
59
70
|
/** An error with a player-safe `message`, the HTTP `status` (if any) and the body's extras. */
|
|
60
71
|
constructor(message: string, status?: number, details: HubErrorDetails = {}) {
|
|
@@ -66,6 +77,7 @@ export class HubError extends Error {
|
|
|
66
77
|
this.hint = details.hint;
|
|
67
78
|
this.retryAfterMs = details.retryAfterMs;
|
|
68
79
|
this.until = details.until;
|
|
80
|
+
this.rule = details.rule;
|
|
69
81
|
}
|
|
70
82
|
|
|
71
83
|
/** `true` when the failure was a network or transport error, not an HTTP status. */
|
|
@@ -115,12 +127,13 @@ export function readErrorFields(body: unknown): ErrorBodyFields {
|
|
|
115
127
|
hint: optionalString(body.hint),
|
|
116
128
|
retryAfterMs: optionalNumber(body.retryAfterMs),
|
|
117
129
|
until: optionalNumber(body.until),
|
|
130
|
+
rule: optionalString(body.rule),
|
|
118
131
|
};
|
|
119
132
|
}
|
|
120
133
|
|
|
121
134
|
/** The right error class for a failure: a 422 or `content_rejected` is a {@link ContentRejectedError}. */
|
|
122
135
|
export function errorFor(message: string, status: number | undefined, details: HubErrorDetails): HubError {
|
|
123
|
-
return status === 422 || details.code === 'content_rejected'
|
|
136
|
+
return (status === 422 && details.code !== 'illegal') || details.code === 'content_rejected'
|
|
124
137
|
? new ContentRejectedError(message, status, details)
|
|
125
138
|
: new HubError(message, status, details);
|
|
126
139
|
}
|
package/src/core/game-data.ts
CHANGED
|
@@ -6,26 +6,28 @@
|
|
|
6
6
|
* v1 client. These two functions are the only places that hand-over happens.
|
|
7
7
|
*/
|
|
8
8
|
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
9
|
+
import { isJson, parseJsonText } from './storage/json-guards';
|
|
9
10
|
|
|
10
11
|
/** Anything a game may store as JSON: an object (interfaces included), an array, text, a number, a boolean or `null`. */
|
|
11
12
|
export type GamePayload = object | string | number | boolean | null;
|
|
12
13
|
|
|
13
|
-
/** Always true: a game's payload is sent as JSON (`JSON.stringify` drops what JSON can't hold). */
|
|
14
|
-
function isSentAsJson(_payload: GamePayload): _payload is GamePayload & Json {
|
|
15
|
-
return true;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
14
|
/** Always true: JSON the game stored is read back as the game's type. */
|
|
19
15
|
function isGameOwnData<TData>(_stored: Json): _stored is Json & TData {
|
|
20
16
|
return true;
|
|
21
17
|
}
|
|
22
18
|
|
|
23
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* A game's payload as the JSON the hub stores: a copy of exactly what
|
|
21
|
+
* `JSON.stringify` sends, so an optional field left `undefined` is simply
|
|
22
|
+
* absent (as on the wire) and the outbox's payload checks accept it, and a
|
|
23
|
+
* later change to the game's object can't alter a queued write.
|
|
24
|
+
*/
|
|
24
25
|
export function toStoredJson(payload: GamePayload): Json {
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
const stored = parseJsonText(JSON.stringify(payload));
|
|
27
|
+
if (isJson(stored)) {
|
|
28
|
+
return stored;
|
|
27
29
|
}
|
|
28
|
-
throw new TypeError('
|
|
30
|
+
throw new TypeError('This value cannot be stored as JSON.');
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/** JSON the game stored, typed as the game's own type. */
|
package/src/core/hub-economy.ts
CHANGED
|
@@ -4,9 +4,13 @@
|
|
|
4
4
|
* offline they are buffered and sent when the hub is back. Method names are
|
|
5
5
|
* the v1 client's, unchanged.
|
|
6
6
|
*/
|
|
7
|
+
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
7
8
|
import { HubProgress } from './hub-progress';
|
|
8
9
|
import { readLocalFavorites, writeLocalFavorites } from './offline/favorites';
|
|
10
|
+
import { ECONOMY_KINDS, type ExchangePayload } from './offline/hub-kinds';
|
|
11
|
+
import { readInventory, withGoods, withoutGoods, type GoodsPayload } from './offline/local-inventory';
|
|
9
12
|
import { HubError } from './errors';
|
|
13
|
+
import { isRecord, stringsIn } from './storage/json-guards';
|
|
10
14
|
import type { EndpointFields, EndpointReply } from './rest/endpoint-types';
|
|
11
15
|
import type { GameAnalytics, GameInfo, GlobalAnalytics, InventorySnapshot } from './types';
|
|
12
16
|
|
|
@@ -25,6 +29,18 @@ export interface TradeNoteOptions {
|
|
|
25
29
|
readonly note?: string;
|
|
26
30
|
}
|
|
27
31
|
|
|
32
|
+
/** Goods as queued: `null` parts left out. */
|
|
33
|
+
function goodsOf(bundle: TradeBundle | GrantBundle | undefined): GoodsPayload {
|
|
34
|
+
const items = bundle?.items ?? undefined;
|
|
35
|
+
const coins = bundle?.coins ?? undefined;
|
|
36
|
+
return { ...(items === undefined ? {} : { items }), ...(coins === undefined ? {} : { coins }) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The holdings in a grant or exchange reply, or `undefined` when it doesn't read as one. */
|
|
40
|
+
function inventoryReply(result: Json | undefined): InventorySnapshot | undefined {
|
|
41
|
+
return readInventory(result) ?? undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
28
44
|
/** Which trades `listTrades` lists. */
|
|
29
45
|
export type TradeListOptions = Omit<EndpointFields<'listMyTrades'>, 'slug'>;
|
|
30
46
|
|
|
@@ -92,11 +108,14 @@ export class HubEconomy extends HubProgress {
|
|
|
92
108
|
}, () => ({ games: readLocalFavorites() }));
|
|
93
109
|
}
|
|
94
110
|
|
|
95
|
-
/** Star (`isOn = true`) or unstar a game.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
111
|
+
/** Star (`isOn = true`) or unstar a game. Offline, the local list changes at once and the hub's when it is back. */
|
|
112
|
+
setFavorite(slug: string, isOn: boolean): Promise<{ games: string[] }> {
|
|
113
|
+
return this.mutateOffline(ECONOMY_KINDS.favorite, { slug, on: isOn }, () => {
|
|
114
|
+
const others = readLocalFavorites().filter((starred) => starred !== slug);
|
|
115
|
+
const games = isOn ? [...others, slug] : others;
|
|
116
|
+
writeLocalFavorites(games);
|
|
117
|
+
return { games };
|
|
118
|
+
}, (result) => (isRecord(result) ? { games: stringsIn(result.games) } : undefined));
|
|
100
119
|
}
|
|
101
120
|
|
|
102
121
|
/** The player's leaderboard standings, grouped by game (every game, or one via `forSlug`). Empty offline or signed out. */
|
|
@@ -109,7 +128,7 @@ export class HubEconomy extends HubProgress {
|
|
|
109
128
|
|
|
110
129
|
/** This game's inventory and coin balance for the current user. */
|
|
111
130
|
getInventory(): Promise<InventorySnapshot> {
|
|
112
|
-
return this.withFallback(() => this.rest.call('getMyInventory', this.gameFields()), () => (
|
|
131
|
+
return this.withFallback(async () => this.store.setInventory(await this.rest.call('getMyInventory', this.gameFields())), () => this.store.getInventory());
|
|
113
132
|
}
|
|
114
133
|
|
|
115
134
|
/** Another player's available holdings (to compose a trade). */
|
|
@@ -117,14 +136,32 @@ export class HubEconomy extends HubProgress {
|
|
|
117
136
|
return this.withFallback(() => this.rest.call('getPlayerInventory', { ...this.gameFields(), userId }), () => ({ items: [], coins: 0 }));
|
|
118
137
|
}
|
|
119
138
|
|
|
120
|
-
/**
|
|
139
|
+
/**
|
|
140
|
+
* Add goods to the player (gameplay rewards); answers the new holdings.
|
|
141
|
+
* Offline, the cached holdings change at once and the grant replays later.
|
|
142
|
+
*/
|
|
121
143
|
grant(bundle: GrantBundle): Promise<EndpointReply<'grantInventoryItems'>> {
|
|
122
|
-
|
|
144
|
+
const goods = goodsOf(bundle);
|
|
145
|
+
return this.mutateOffline(ECONOMY_KINDS.grant, goods, () => this.store.setInventory(withGoods(this.store.getInventory(), goods)), inventoryReply);
|
|
123
146
|
}
|
|
124
147
|
|
|
125
|
-
/**
|
|
148
|
+
/**
|
|
149
|
+
* An atomic swap with the house: remove `take`, add `give`. Offline it is
|
|
150
|
+
* checked against the cached holdings (409 when they can't cover `take`),
|
|
151
|
+
* applied to them at once, and replayed later; the hub has the last word.
|
|
152
|
+
*/
|
|
126
153
|
exchange(take?: TradeBundle, give?: TradeBundle): Promise<EndpointReply<'exchangeInventoryItems'>> {
|
|
127
|
-
|
|
154
|
+
const payload: ExchangePayload = { ...(take === undefined ? {} : { take: goodsOf(take) }), ...(give === undefined ? {} : { give: goodsOf(give) }) };
|
|
155
|
+
return this.mutateOffline(ECONOMY_KINDS.exchange, payload, () => {
|
|
156
|
+
const remaining = withoutGoods(this.store.getInventory(), payload.take ?? {});
|
|
157
|
+
if (typeof remaining === 'string') {
|
|
158
|
+
if (this.isOffline) {
|
|
159
|
+
throw new HubError(remaining, 409);
|
|
160
|
+
}
|
|
161
|
+
return this.store.getInventory();
|
|
162
|
+
}
|
|
163
|
+
return this.store.setInventory(withGoods(remaining, payload.give ?? {}));
|
|
164
|
+
}, inventoryReply);
|
|
128
165
|
}
|
|
129
166
|
|
|
130
167
|
/** Sell items to the house for coins. */
|