@diffenderfer-games/hub 2.0.0-next.1 → 2.0.0-next.2
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 +36 -9
- package/README.md +25 -10
- package/dist/auto.d.ts +6 -6
- package/dist/auto.js +2 -2
- package/dist/doctor/cli.mjs +14 -7
- package/dist/doctor/cli.mjs.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.js +2 -2
- package/dist/mp.d.ts +3 -3
- package/dist/race.d.ts +1 -1
- package/dist/rooms.d.ts +3 -3
- package/dist/shared/bridge.js +9 -3
- package/dist/shared/bridge.js.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.d.ts +10 -4
- package/dist/shared/daily.d.ts.map +1 -1
- package/dist/shared/daily.js +12 -2
- package/dist/shared/daily.js.map +1 -1
- package/dist/shared/index.d.ts +314 -35
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/input.js +7 -2
- package/dist/shared/input.js.map +1 -1
- package/dist/shared/latest-state.js +1 -1
- package/dist/shared/mp.js +10 -2
- package/dist/shared/mp.js.map +1 -1
- package/dist/shared/multiplayer-types.d.ts +15 -2
- package/dist/shared/multiplayer-types.d.ts.map +1 -1
- package/dist/shared/race-types.d.ts +1 -1
- package/dist/shared/race.js +1 -1
- package/dist/shared/room-types.d.ts +28 -3
- package/dist/shared/room-types.d.ts.map +1 -1
- package/dist/shared/rooms.d.ts.map +1 -1
- package/dist/shared/rooms.js +10 -2
- package/dist/shared/rooms.js.map +1 -1
- package/dist/shared/runtime-opener.js +1 -1
- package/dist/shared/social.d.ts +23 -8
- package/dist/shared/social.d.ts.map +1 -1
- package/dist/shared/social.js +6 -1
- package/dist/shared/social.js.map +1 -1
- package/dist/shared/src.js +1588 -388
- package/dist/shared/src.js.map +1 -1
- package/dist/shared/wire-types.d.ts +6 -2
- package/dist/shared/wire-types.d.ts.map +1 -1
- package/dist/social.d.ts +5 -5
- package/dist/social.js +1 -1
- package/package.json +2 -2
- package/src/core/connection.ts +187 -92
- package/src/core/game-data.ts +11 -9
- package/src/core/hub-economy.ts +47 -10
- package/src/core/hub-progress.ts +13 -16
- package/src/core/hub.ts +4 -2
- package/src/core/offline/hub-kinds/daily-kind.ts +68 -0
- package/src/core/offline/hub-kinds/economy-kinds.ts +86 -0
- package/src/core/offline/hub-kinds/game-fields.ts +2 -0
- package/src/core/offline/hub-kinds/index.ts +35 -0
- package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
- package/src/core/offline/hub-kinds/progress-kinds.ts +148 -0
- package/src/core/offline/kind-adapters.ts +48 -0
- package/src/core/offline/local-inventory.ts +64 -0
- package/src/core/offline/local-store.ts +20 -22
- package/src/core/offline/offline-api.ts +65 -0
- package/src/core/offline/outbox-entry.ts +59 -0
- package/src/core/offline/outbox-kinds.ts +78 -0
- package/src/core/offline/outbox-merge.ts +39 -0
- package/src/core/offline/outbox-replayer.ts +141 -0
- package/src/core/offline/outbox-store.ts +85 -0
- package/src/core/offline/outbox.ts +232 -0
- package/src/core/offline/queue.ts +4 -49
- package/src/core/offline/replay-lease.ts +87 -0
- package/src/core/offline/replay-policy.ts +52 -0
- package/src/core/rest/rest-client.ts +14 -3
- package/src/core/rest/routes.gen.ts +108 -87
- package/src/core/storage/json-guards.ts +6 -0
- package/src/core/version/client-version.gen.ts +1 -1
- package/src/daily/daily-system.ts +8 -1
- package/src/daily/types.ts +3 -2
- package/src/doctor/checks.ts +10 -2
- package/src/index.ts +6 -1
- package/src/input/input-registry.ts +9 -2
- package/src/mp.ts +1 -1
- package/src/realtime/bridge.ts +5 -0
- package/src/realtime/multiplayer-forwarder.ts +13 -1
- package/src/realtime/multiplayer-types.ts +15 -1
- package/src/realtime/room-types.ts +28 -2
- package/src/realtime/rooms-forwarder.ts +13 -1
- package/src/realtime/runtime-api.ts +19 -8
- package/src/realtime/social-forwarder.ts +5 -0
- package/src/realtime/social-types.ts +8 -0
- package/src/realtime/wire-types.ts +7 -0
- package/src/rooms.ts +1 -1
package/src/core/connection.ts
CHANGED
|
@@ -1,36 +1,43 @@
|
|
|
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';
|
|
11
14
|
import { absoluteApiBase, detectApiBase, detectSlug, emitWindowEvent, isBrowserPage } from './page';
|
|
12
15
|
import { createRestClient, type RestClient } from './rest/rest-client';
|
|
16
|
+
import { createPageStore } from './storage/key-value';
|
|
13
17
|
import type { HubOptions, HubStatus, SnapshotResponse } from './types';
|
|
14
18
|
|
|
15
19
|
/** How long `prefetchAll()` waits between snapshots unless forced. */
|
|
16
20
|
const PREFETCH_INTERVAL_MS = 30_000;
|
|
17
21
|
|
|
18
|
-
/** A queued
|
|
22
|
+
/** A queued op the hub refused during a sync (or that kept failing): dropped, with the reason. */
|
|
19
23
|
export interface SyncConflict {
|
|
20
|
-
/** The
|
|
21
|
-
readonly op:
|
|
22
|
-
/** The hub's message. */
|
|
24
|
+
/** The dropped op. */
|
|
25
|
+
readonly op: OfflineOp;
|
|
26
|
+
/** The hub's (or the handler's) message. */
|
|
23
27
|
readonly error?: string;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
/** What one `sync()` did. */
|
|
27
31
|
export interface SyncResult {
|
|
28
|
-
/** Queued
|
|
32
|
+
/** Queued ops that landed. */
|
|
29
33
|
readonly flushed: number;
|
|
30
|
-
/** Queued
|
|
34
|
+
/** Queued ops that were dropped. */
|
|
31
35
|
readonly conflicts: SyncConflict[];
|
|
32
36
|
}
|
|
33
37
|
|
|
38
|
+
/** A sync that did nothing. */
|
|
39
|
+
const NOTHING_SYNCED: SyncResult = { flushed: 0, conflicts: [] };
|
|
40
|
+
|
|
34
41
|
/** Whether a failure means "the hub can't be reached" (rather than a refusal). */
|
|
35
42
|
// eslint-disable-next-line no-restricted-syntax -- trust boundary: whatever a call threw
|
|
36
43
|
function isTransportFailure(failure: unknown): boolean {
|
|
@@ -51,12 +58,18 @@ export class HubConnection {
|
|
|
51
58
|
readonly apiBase: string;
|
|
52
59
|
/** Typed REST calls to {@link apiBase}. */
|
|
53
60
|
protected readonly rest: RestClient;
|
|
54
|
-
/** This game's offline cache
|
|
61
|
+
/** This game's offline cache and event buffer. */
|
|
55
62
|
protected readonly store: LocalStore;
|
|
63
|
+
/** This game's queued operations. */
|
|
64
|
+
protected readonly outbox: Outbox;
|
|
56
65
|
private readonly offlineMode: NonNullable<HubOptions['offline']>;
|
|
57
66
|
private isReachable = true;
|
|
58
|
-
private
|
|
67
|
+
private runningSync: Promise<SyncResult> | null = null;
|
|
68
|
+
private followingSync: Promise<SyncResult> | null = null;
|
|
69
|
+
private isFollowingEager = false;
|
|
70
|
+
private directSend: Promise<Json | undefined> | null = null;
|
|
59
71
|
private lastPrefetchAt = 0;
|
|
72
|
+
private offlineApi: OfflineApi | undefined;
|
|
60
73
|
private readonly statusListeners = new Set<(status: HubStatus) => void>();
|
|
61
74
|
|
|
62
75
|
/** A connection for `options` (slug and API base default to the page's). */
|
|
@@ -71,17 +84,35 @@ export class HubConnection {
|
|
|
71
84
|
this.setReachable(true);
|
|
72
85
|
},
|
|
73
86
|
});
|
|
74
|
-
|
|
87
|
+
const pageStore = createPageStore();
|
|
88
|
+
this.store = new LocalStore(this.slug ?? 'app', pageStore);
|
|
89
|
+
this.outbox = new Outbox({
|
|
90
|
+
slug: this.slug ?? 'app',
|
|
91
|
+
store: pageStore,
|
|
92
|
+
rest: this.rest,
|
|
93
|
+
emit: emitWindowEvent,
|
|
94
|
+
onUnreachable: () => {
|
|
95
|
+
this.setReachable(false);
|
|
96
|
+
},
|
|
97
|
+
onChange: () => {
|
|
98
|
+
this.notifyStatus();
|
|
99
|
+
},
|
|
100
|
+
onRetryDue: () => {
|
|
101
|
+
void this.retrySync();
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
registerHubKinds(this.outbox, {
|
|
105
|
+
game: () => this.gameFields(),
|
|
106
|
+
onDailyComplete: (payload, completion) => {
|
|
107
|
+
emitWindowEvent('hub:daily-complete', { day: payload.day, count: completion.count, rank: completion.rank, meta: payload.meta });
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
this.importLegacyQueue();
|
|
75
111
|
if (typeof navigator !== 'undefined' && 'onLine' in navigator) {
|
|
76
112
|
this.isReachable = navigator.onLine;
|
|
77
113
|
}
|
|
78
114
|
if (isBrowserPage()) {
|
|
79
|
-
|
|
80
|
-
this.setReachable(true);
|
|
81
|
-
});
|
|
82
|
-
window.addEventListener('offline', () => {
|
|
83
|
-
this.setReachable(false);
|
|
84
|
-
});
|
|
115
|
+
this.listenToPage();
|
|
85
116
|
}
|
|
86
117
|
}
|
|
87
118
|
|
|
@@ -95,9 +126,25 @@ export class HubConnection {
|
|
|
95
126
|
return this.isReachable;
|
|
96
127
|
}
|
|
97
128
|
|
|
98
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* The offline outbox: define your own kinds of operation, queue them, and
|
|
131
|
+
* hear how they replay (`hub.offline.define`, `enqueue`, `pending`, `on`).
|
|
132
|
+
*/
|
|
133
|
+
get offline(): OfflineApi {
|
|
134
|
+
this.offlineApi ??= new OfflineApi({
|
|
135
|
+
outbox: this.outbox,
|
|
136
|
+
requestSync: () => {
|
|
137
|
+
if (!this.isOffline) {
|
|
138
|
+
void this.sync();
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
return this.offlineApi;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Queued operations plus buffered analytics events waiting to sync. */
|
|
99
146
|
pending(): number {
|
|
100
|
-
return this.
|
|
147
|
+
return this.outbox.count() + this.store.getEvents().length;
|
|
101
148
|
}
|
|
102
149
|
|
|
103
150
|
/** Subscribe to connection and sync status changes; returns the unsubscribe function. */
|
|
@@ -121,7 +168,7 @@ export class HubConnection {
|
|
|
121
168
|
emitWindowEvent('hub:status', status);
|
|
122
169
|
}
|
|
123
170
|
|
|
124
|
-
/** Record reachability; coming back online
|
|
171
|
+
/** Record reachability; coming back online replays the outbox and re-warms the cache. */
|
|
125
172
|
protected setReachable(isReachableNow: boolean): void {
|
|
126
173
|
if (isReachableNow === this.isReachable) {
|
|
127
174
|
return;
|
|
@@ -130,8 +177,8 @@ export class HubConnection {
|
|
|
130
177
|
emitWindowEvent(isReachableNow ? 'hub:online' : 'hub:offline', { pending: this.pending() });
|
|
131
178
|
this.notifyStatus();
|
|
132
179
|
if (isReachableNow) {
|
|
133
|
-
|
|
134
|
-
void this.prefetchAll().catch(() => undefined);
|
|
180
|
+
// Replay first, so the snapshot that re-warms the cache already has the queued changes.
|
|
181
|
+
void this.replayAll(true).then(() => this.prefetchAll()).catch(() => undefined);
|
|
135
182
|
}
|
|
136
183
|
}
|
|
137
184
|
|
|
@@ -163,98 +210,105 @@ export class HubConnection {
|
|
|
163
210
|
}
|
|
164
211
|
}
|
|
165
212
|
|
|
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
213
|
/**
|
|
195
|
-
* Apply a write to the offline cache at once, then
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
214
|
+
* Apply a write to the offline cache at once, then perform it as an op of
|
|
215
|
+
* `kind`: sent now when online with nothing waiting, else queued behind
|
|
216
|
+
* the others. Resolves with the local answer (or, sent now, with
|
|
217
|
+
* `replyAnswer`'s reading of the hub's reply). A real API error on the
|
|
218
|
+
* direct path reaches the caller and is not queued; a transport failure
|
|
219
|
+
* queues the op under the same id, so if the request did land, its replay
|
|
220
|
+
* is recognised by the hub and not applied twice.
|
|
199
221
|
*/
|
|
200
|
-
protected async
|
|
222
|
+
protected async mutateOffline<Reply>(kind: string, payload: Json, localAnswer: () => Reply, replyAnswer?: (result: Json | undefined) => Reply | undefined): Promise<Reply> {
|
|
201
223
|
const answer = localAnswer();
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
this.
|
|
224
|
+
const op = this.outbox.stamp(kind, payload);
|
|
225
|
+
if (this.isOffline || this.outbox.count() > 0 || this.runningSync !== null || this.directSend !== null) {
|
|
226
|
+
this.outbox.add(op);
|
|
205
227
|
if (!this.isOffline) {
|
|
206
228
|
void this.sync();
|
|
207
229
|
}
|
|
208
230
|
return answer;
|
|
209
231
|
}
|
|
232
|
+
const sending = this.outbox.runNow(op);
|
|
233
|
+
this.directSend = sending;
|
|
210
234
|
try {
|
|
211
|
-
await
|
|
212
|
-
return answer;
|
|
235
|
+
const result = await sending;
|
|
236
|
+
return replyAnswer?.(result) ?? answer;
|
|
213
237
|
} catch (failure) {
|
|
214
238
|
if (!isTransportFailure(failure)) {
|
|
215
239
|
throw failure;
|
|
216
240
|
}
|
|
217
241
|
this.setReachable(false);
|
|
218
|
-
this.
|
|
219
|
-
this.notifyStatus();
|
|
242
|
+
this.outbox.add({ ...op, attempts: 1 });
|
|
220
243
|
return answer;
|
|
244
|
+
} finally {
|
|
245
|
+
if (this.directSend === sending) {
|
|
246
|
+
this.directSend = null;
|
|
247
|
+
}
|
|
221
248
|
}
|
|
222
249
|
}
|
|
223
250
|
|
|
224
251
|
/**
|
|
225
|
-
* Replay the
|
|
226
|
-
* transport failure (offline again: the rest stays queued);
|
|
227
|
-
* refuses is dropped and reported as a conflict.
|
|
252
|
+
* Replay the outbox, then the buffered events, in order. Stops at the
|
|
253
|
+
* first transport failure (offline again: the rest stays queued); an op
|
|
254
|
+
* the hub refuses is dropped and reported as a conflict.
|
|
228
255
|
*/
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
256
|
+
sync(): Promise<SyncResult> {
|
|
257
|
+
return this.isOffline ? Promise.resolve(NOTHING_SYNCED) : this.replayAll(false);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** A retry came due: try again even when the hub was last unreachable (the try is the probe), unless the browser itself is offline. */
|
|
261
|
+
private retrySync(): Promise<SyncResult> {
|
|
262
|
+
const isBrowserOffline = typeof navigator !== 'undefined' && 'onLine' in navigator && !navigator.onLine;
|
|
263
|
+
return this.offlineMode === 'always' || isBrowserOffline ? Promise.resolve(NOTHING_SYNCED) : this.replayAll(false);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* One replay at a time per client. A call during one queues a single
|
|
268
|
+
* follow-up replay (eager if any caller asked for that), so an op queued
|
|
269
|
+
* or a reconnect that happens mid-replay is never missed.
|
|
270
|
+
*/
|
|
271
|
+
private replayAll(isEager: boolean): Promise<SyncResult> {
|
|
272
|
+
if (this.slug === null || this.offlineMode === 'always') {
|
|
273
|
+
return Promise.resolve(NOTHING_SYNCED);
|
|
232
274
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
275
|
+
const running = this.runningSync;
|
|
276
|
+
if (running === null) {
|
|
277
|
+
return this.startReplay(isEager);
|
|
278
|
+
}
|
|
279
|
+
this.isFollowingEager ||= isEager;
|
|
280
|
+
this.followingSync ??= running.catch(() => NOTHING_SYNCED).then(() => {
|
|
281
|
+
const isFollowUpEager = this.isFollowingEager;
|
|
282
|
+
this.isFollowingEager = false;
|
|
283
|
+
this.followingSync = null;
|
|
284
|
+
return this.startReplay(isFollowUpEager);
|
|
285
|
+
});
|
|
286
|
+
return this.followingSync;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private startReplay(isEager: boolean): Promise<SyncResult> {
|
|
290
|
+
const run = this.runReplay(isEager).finally(() => {
|
|
291
|
+
if (this.runningSync === run) {
|
|
292
|
+
this.runningSync = null;
|
|
250
293
|
}
|
|
294
|
+
});
|
|
295
|
+
this.runningSync = run;
|
|
296
|
+
return run;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private async runReplay(isEager: boolean): Promise<SyncResult> {
|
|
300
|
+
if (this.directSend !== null) {
|
|
301
|
+
await this.directSend.catch(() => undefined);
|
|
302
|
+
}
|
|
303
|
+
const report = await this.outbox.replay(isEager);
|
|
304
|
+
const conflicts = report.failed.map((failure) => ({ op: failure.entry, error: failure.error }));
|
|
305
|
+
if (report.stoppedBy !== 'connection') {
|
|
251
306
|
await this.flushEvents();
|
|
252
|
-
} finally {
|
|
253
|
-
this.isSyncing = false;
|
|
254
307
|
}
|
|
255
|
-
|
|
308
|
+
const result: SyncResult = { flushed: report.replayed.length, conflicts };
|
|
309
|
+
emitWindowEvent('hub:sync', { pending: this.pending(), ...result });
|
|
256
310
|
this.notifyStatus();
|
|
257
|
-
return
|
|
311
|
+
return result;
|
|
258
312
|
}
|
|
259
313
|
|
|
260
314
|
/** Send the buffered analytics events; a refused one is dropped (telemetry never blocks). */
|
|
@@ -273,6 +327,43 @@ export class HubConnection {
|
|
|
273
327
|
}
|
|
274
328
|
}
|
|
275
329
|
|
|
330
|
+
/** Move a v1 copy's queued writes into the outbox, in their order. */
|
|
331
|
+
private importLegacyQueue(): void {
|
|
332
|
+
if (this.slug === null) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
for (const legacy of this.store.takeLegacyQueue()) {
|
|
337
|
+
const operation = legacyOperation(legacy);
|
|
338
|
+
if (operation !== undefined) {
|
|
339
|
+
this.outbox.add(this.outbox.stamp(operation.kind, operation.payload));
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
} catch {
|
|
343
|
+
// A full outbox keeps what fitted; the client must still start.
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Follow the browser's connection, other tabs' outbox changes, and replay what an earlier page left queued. */
|
|
348
|
+
private listenToPage(): void {
|
|
349
|
+
window.addEventListener('online', () => {
|
|
350
|
+
this.setReachable(true);
|
|
351
|
+
});
|
|
352
|
+
window.addEventListener('offline', () => {
|
|
353
|
+
this.setReachable(false);
|
|
354
|
+
});
|
|
355
|
+
window.addEventListener('storage', (event) => {
|
|
356
|
+
if (event instanceof StorageEvent && this.outbox.entries.owns(event.key)) {
|
|
357
|
+
this.notifyStatus();
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
if (this.outbox.count() > 0) {
|
|
361
|
+
setTimeout(() => {
|
|
362
|
+
void this.sync();
|
|
363
|
+
}, 0);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
276
367
|
/**
|
|
277
368
|
* Warm the offline cache for every game the player has touched, in one
|
|
278
369
|
* request, so going offline later still shows complete progress.
|
|
@@ -292,6 +383,10 @@ export class HubConnection {
|
|
|
292
383
|
throw failure;
|
|
293
384
|
}
|
|
294
385
|
this.lastPrefetchAt = Date.now();
|
|
386
|
+
if (this.outbox.count() > 0) {
|
|
387
|
+
// Changes are still waiting to sync: the cache already holds them, and the snapshot doesn't yet.
|
|
388
|
+
return snapshot;
|
|
389
|
+
}
|
|
295
390
|
this.store.hydrate(snapshot);
|
|
296
391
|
emitWindowEvent('hub:prefetch', { games: Object.keys(snapshot.games).length });
|
|
297
392
|
return snapshot;
|
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. */
|
package/src/core/hub-progress.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import type { Board, Json, Stats } from '@diffenderfer-games/hub-contract';
|
|
8
8
|
import { HubConnection } from './connection';
|
|
9
9
|
import { toGameData, toStoredJson, type GamePayload } from './game-data';
|
|
10
|
-
import
|
|
10
|
+
import { PROGRESS_KINDS, type ProfilePayload, type ScorePayload } from './offline/hub-kinds';
|
|
11
11
|
import type { LeaderboardResult, MeResponse, Profile, Save, SaveMeta, SubmitScoreOptions, SubmitScoreResult, User } from './types';
|
|
12
12
|
|
|
13
13
|
/** A signed-in user, as the auth calls answer. */
|
|
@@ -111,14 +111,11 @@ export class HubProgress extends HubConnection {
|
|
|
111
111
|
|
|
112
112
|
/** Create or update this game's profile for the current user. */
|
|
113
113
|
setProfile<TData extends GamePayload = Json>(patch: ProfileUpdate<TData>): Promise<{ profile: Profile<TData> }> {
|
|
114
|
-
const queued:
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
queued.data = toStoredJson(patch.data);
|
|
120
|
-
}
|
|
121
|
-
return this.mutateQueued({ kind: 'setProfile', patch: queued }, () => {
|
|
114
|
+
const queued: ProfilePayload = {
|
|
115
|
+
...(patch.displayName === undefined ? {} : { displayName: patch.displayName }),
|
|
116
|
+
...(patch.data === undefined ? {} : { data: toStoredJson(patch.data) }),
|
|
117
|
+
};
|
|
118
|
+
return this.mutateOffline(PROGRESS_KINDS.profile, queued, () => {
|
|
122
119
|
const stored = this.store.setProfile(this.slug ?? 'app', patch.displayName ?? null, queued.data ?? {});
|
|
123
120
|
return { profile: { ...stored, data: toGameData<TData>(stored.data) } };
|
|
124
121
|
});
|
|
@@ -141,12 +138,12 @@ export class HubProgress extends HubConnection {
|
|
|
141
138
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- kept so v1 calls such as putSave<MySave>(...) still compile
|
|
142
139
|
putSave<TData extends GamePayload = Json>(slot: string, saveData: TData, label?: string): Promise<{ ok: true; updatedAt: number }> {
|
|
143
140
|
const stored = toStoredJson(saveData);
|
|
144
|
-
return this.
|
|
141
|
+
return this.mutateOffline(PROGRESS_KINDS.save, { slot, data: stored, label: label ?? null }, () => ({ ok: true as const, updatedAt: this.store.putSave(slot, stored, label ?? null) }));
|
|
145
142
|
}
|
|
146
143
|
|
|
147
144
|
/** Delete a save slot. */
|
|
148
145
|
deleteSave(slot: string): Promise<{ ok: true }> {
|
|
149
|
-
return this.
|
|
146
|
+
return this.mutateOffline(PROGRESS_KINDS.save, { slot, deleted: true }, () => {
|
|
150
147
|
this.store.deleteSave(slot);
|
|
151
148
|
return { ok: true as const };
|
|
152
149
|
});
|
|
@@ -162,27 +159,27 @@ export class HubProgress extends HubConnection {
|
|
|
162
159
|
|
|
163
160
|
/** Overwrite a stat. */
|
|
164
161
|
setStat(key: string, value: number): Promise<StatResult> {
|
|
165
|
-
return this.
|
|
162
|
+
return this.mutateOffline(PROGRESS_KINDS.stat, { key, mode: 'set', value }, () => ({ key, value: this.store.updateStat(key, value, 'set') }));
|
|
166
163
|
}
|
|
167
164
|
|
|
168
165
|
/** Add to a stat (creating it at `amount` when absent). */
|
|
169
166
|
incrStat(key: string, amount = 1): Promise<StatResult> {
|
|
170
|
-
return this.
|
|
167
|
+
return this.mutateOffline(PROGRESS_KINDS.stat, { key, mode: 'incr', value: amount }, () => ({ key, value: this.store.updateStat(key, amount, 'incr') }));
|
|
171
168
|
}
|
|
172
169
|
|
|
173
170
|
/** Keep the larger of the stored and the given value. */
|
|
174
171
|
maxStat(key: string, value: number): Promise<StatResult> {
|
|
175
|
-
return this.
|
|
172
|
+
return this.mutateOffline(PROGRESS_KINDS.stat, { key, mode: 'max', value }, () => ({ key, value: this.store.updateStat(key, value, 'max') }));
|
|
176
173
|
}
|
|
177
174
|
|
|
178
175
|
/** Submit a score. The board is created on first use; only a player's best score sticks. */
|
|
179
176
|
submitScore<TMeta extends GamePayload = Json>(boardKey: string, score: number, opts: SubmitScoreOptions<TMeta> = {}): Promise<SubmitScoreResult> {
|
|
180
|
-
const queued:
|
|
177
|
+
const queued: ScorePayload['opts'] = {
|
|
181
178
|
...(opts.title === undefined ? {} : { title: opts.title }),
|
|
182
179
|
...(opts.sort === undefined ? {} : { sort: opts.sort }),
|
|
183
180
|
...(opts.meta === undefined ? {} : { meta: toStoredJson(opts.meta) }),
|
|
184
181
|
};
|
|
185
|
-
return this.
|
|
182
|
+
return this.mutateOffline(PROGRESS_KINDS.score, { key: boardKey, score, opts: queued }, () => ({ ...this.store.submitScore(boardKey, score, queued), rank: 1 }));
|
|
186
183
|
}
|
|
187
184
|
|
|
188
185
|
/** One board's ranked entries (top `limit`, default 50). Offline: only the player's own best. */
|
package/src/core/hub.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* `hub.race`, ...), so v1 code such as `hub.input.define(...)` works unchanged.
|
|
6
6
|
*/
|
|
7
7
|
import type { HubVersion } from '@diffenderfer-games/hub-contract';
|
|
8
|
-
import { getDaily, type DailyHost, type DailySystem } from '../daily';
|
|
8
|
+
import { getDaily, type DailyCompleteResponse, type DailyHost, type DailySystem } from '../daily';
|
|
9
9
|
import { getInput, getUiStack, type InputSystem } from '../input';
|
|
10
10
|
import { mp } from '../mp';
|
|
11
11
|
import { race } from '../race';
|
|
@@ -13,6 +13,8 @@ import { rooms } from '../rooms';
|
|
|
13
13
|
import { linkRealtimeToHub, notify, presence, social } from '../social';
|
|
14
14
|
import { OfflineError, toHubError } from './errors';
|
|
15
15
|
import { HubEconomy } from './hub-economy';
|
|
16
|
+
import { DAILY_KIND } from './offline/hub-kinds';
|
|
17
|
+
import { readDailyCompletion } from './offline/hub-kinds/daily-kind';
|
|
16
18
|
import { hubMenu, type HubMenuControl } from './menu-control';
|
|
17
19
|
import { emitWindowEvent } from './page';
|
|
18
20
|
import { fetchGameServerTicket } from './ticket';
|
|
@@ -108,7 +110,7 @@ export class Hub extends HubEconomy {
|
|
|
108
110
|
status: () => this.rest.call('getGameDailyChallenges', this.gameFields()),
|
|
109
111
|
all: () => this.rest.call('listDailyChallengesForAllGames'),
|
|
110
112
|
start: (day) => this.rest.call('startDailyChallenge', { ...this.gameFields(), day }),
|
|
111
|
-
complete: (day, token, meta) => this.
|
|
113
|
+
complete: (day, token, meta) => this.mutateOffline<DailyCompleteResponse | null>(DAILY_KIND, { day, token, meta }, () => null, (result) => readDailyCompletion(result)),
|
|
112
114
|
emit: (eventName, detail) => {
|
|
113
115
|
emitWindowEvent(eventName, detail);
|
|
114
116
|
},
|