@diffenderfer-games/hub 2.0.0-next.1 → 2.0.0-next.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -6
- package/README.md +25 -10
- package/dist/auto.d.ts +7 -7
- package/dist/auto.js +3 -3
- package/dist/doctor/cli.mjs +14 -7
- package/dist/doctor/cli.mjs.map +1 -1
- package/dist/index.d.ts +7 -7
- package/dist/index.js +3 -3
- package/dist/input.d.ts +2 -2
- package/dist/input.js +2 -2
- package/dist/mp.d.ts +3 -3
- package/dist/race.d.ts +1 -1
- package/dist/rooms.d.ts +3 -3
- package/dist/shared/bridge.js +9 -3
- package/dist/shared/bridge.js.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.d.ts +10 -4
- package/dist/shared/daily.d.ts.map +1 -1
- package/dist/shared/daily.js +12 -2
- package/dist/shared/daily.js.map +1 -1
- package/dist/shared/index.d.ts +315 -36
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/input.d.ts +47 -5
- package/dist/shared/input.d.ts.map +1 -1
- package/dist/shared/input.js +139 -23
- package/dist/shared/input.js.map +1 -1
- package/dist/shared/latest-state.js +1 -1
- package/dist/shared/mp.js +10 -2
- package/dist/shared/mp.js.map +1 -1
- package/dist/shared/multiplayer-types.d.ts +15 -2
- package/dist/shared/multiplayer-types.d.ts.map +1 -1
- package/dist/shared/race-types.d.ts +1 -1
- package/dist/shared/race.js +1 -1
- package/dist/shared/room-types.d.ts +28 -3
- package/dist/shared/room-types.d.ts.map +1 -1
- package/dist/shared/rooms.d.ts.map +1 -1
- package/dist/shared/rooms.js +10 -2
- package/dist/shared/rooms.js.map +1 -1
- package/dist/shared/runtime-opener.js +1 -1
- package/dist/shared/social.d.ts +23 -8
- package/dist/shared/social.d.ts.map +1 -1
- package/dist/shared/social.js +6 -1
- package/dist/shared/social.js.map +1 -1
- package/dist/shared/src.js +1588 -388
- package/dist/shared/src.js.map +1 -1
- package/dist/shared/wire-types.d.ts +6 -2
- package/dist/shared/wire-types.d.ts.map +1 -1
- package/dist/social.d.ts +5 -5
- package/dist/social.js +1 -1
- package/package.json +2 -2
- package/src/core/connection.ts +187 -92
- package/src/core/game-data.ts +11 -9
- package/src/core/hub-economy.ts +47 -10
- package/src/core/hub-progress.ts +13 -16
- package/src/core/hub.ts +4 -2
- package/src/core/offline/hub-kinds/daily-kind.ts +68 -0
- package/src/core/offline/hub-kinds/economy-kinds.ts +86 -0
- package/src/core/offline/hub-kinds/game-fields.ts +2 -0
- package/src/core/offline/hub-kinds/index.ts +35 -0
- package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
- package/src/core/offline/hub-kinds/progress-kinds.ts +148 -0
- package/src/core/offline/kind-adapters.ts +48 -0
- package/src/core/offline/local-inventory.ts +64 -0
- package/src/core/offline/local-store.ts +20 -22
- package/src/core/offline/offline-api.ts +65 -0
- package/src/core/offline/outbox-entry.ts +59 -0
- package/src/core/offline/outbox-kinds.ts +78 -0
- package/src/core/offline/outbox-merge.ts +39 -0
- package/src/core/offline/outbox-replayer.ts +141 -0
- package/src/core/offline/outbox-store.ts +85 -0
- package/src/core/offline/outbox.ts +232 -0
- package/src/core/offline/queue.ts +4 -49
- package/src/core/offline/replay-lease.ts +87 -0
- package/src/core/offline/replay-policy.ts +52 -0
- package/src/core/rest/rest-client.ts +14 -3
- package/src/core/rest/routes.gen.ts +108 -87
- package/src/core/storage/json-guards.ts +6 -0
- package/src/core/version/client-version.gen.ts +1 -1
- package/src/daily/daily-system.ts +8 -1
- package/src/daily/types.ts +3 -2
- package/src/doctor/checks.ts +10 -2
- package/src/index.ts +6 -1
- package/src/input/focus-ring.ts +13 -0
- package/src/input/input-registry.ts +9 -2
- package/src/input/input-system.ts +4 -0
- package/src/input/modality.ts +122 -0
- package/src/input/nav-state.ts +13 -4
- package/src/input/zero-config.ts +1 -14
- package/src/input.ts +3 -0
- package/src/mp.ts +1 -1
- package/src/overlay/overlay.ts +5 -3
- package/src/overlay/styles.ts +4 -3
- package/src/realtime/bridge.ts +5 -0
- package/src/realtime/multiplayer-forwarder.ts +13 -1
- package/src/realtime/multiplayer-types.ts +15 -1
- package/src/realtime/room-types.ts +28 -2
- package/src/realtime/rooms-forwarder.ts +13 -1
- package/src/realtime/runtime-api.ts +19 -8
- package/src/realtime/social-forwarder.ts +5 -0
- package/src/realtime/social-types.ts +8 -0
- package/src/realtime/wire-types.ts +7 -0
- package/src/rooms.ts +1 -1
package/dist/shared/src.js
CHANGED
|
@@ -3,7 +3,7 @@ import { n as getDaily } from "./daily.js";
|
|
|
3
3
|
import { t as HUB_CLIENT_VERSION } from "./client-version.gen.js";
|
|
4
4
|
import { n as getInput } from "./input.js";
|
|
5
5
|
import { t as getUiStack } from "./page-stack.js";
|
|
6
|
-
import {
|
|
6
|
+
import { f as linkRealtimeToHub } from "./bridge.js";
|
|
7
7
|
import { t as multiplayerForwarder } from "./mp.js";
|
|
8
8
|
import { t as raceForwarder } from "./race.js";
|
|
9
9
|
import { t as roomsForwarder } from "./rooms.js";
|
|
@@ -48,6 +48,10 @@ function parseJsonText(text) {
|
|
|
48
48
|
return;
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
+
/** Whether `candidate` is a JSON object (not an array or `null`). */
|
|
52
|
+
function isJsonObject(candidate) {
|
|
53
|
+
return isRecord(candidate) && Object.values(candidate).every(isJson);
|
|
54
|
+
}
|
|
51
55
|
//#endregion
|
|
52
56
|
//#region src/core/errors.ts
|
|
53
57
|
/**
|
|
@@ -177,37 +181,212 @@ function createPageStore() {
|
|
|
177
181
|
const storage = pageStorage();
|
|
178
182
|
return storage === void 0 ? createMemoryStore() : storageBacked(storage);
|
|
179
183
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/core/offline/favorites.ts
|
|
186
|
+
/**
|
|
187
|
+
* Starred games are site-wide, not per game, so they live under one global
|
|
188
|
+
* key (`hub:favorites`, the v1 client's) rather than in a game's cache. The
|
|
189
|
+
* list mirrors the hub's so the menu still shows it offline.
|
|
190
|
+
*/
|
|
191
|
+
/** The key of the mirrored favorites list. */
|
|
192
|
+
const FAVORITES_KEY = "hub:favorites";
|
|
193
|
+
/** The mirrored favorite slugs (empty when none or unreadable). */
|
|
194
|
+
function readLocalFavorites() {
|
|
195
|
+
const text = createPageStore().read(FAVORITES_KEY);
|
|
196
|
+
return text === null ? [] : stringsIn(parseJsonText(text));
|
|
192
197
|
}
|
|
193
|
-
/**
|
|
194
|
-
function
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
198
|
+
/** Mirror the hub's favorites list. */
|
|
199
|
+
function writeLocalFavorites(games) {
|
|
200
|
+
createPageStore().write(FAVORITES_KEY, JSON.stringify(games));
|
|
201
|
+
}
|
|
202
|
+
//#endregion
|
|
203
|
+
//#region src/core/game-data.ts
|
|
204
|
+
/** Always true: JSON the game stored is read back as the game's type. */
|
|
205
|
+
function isGameOwnData(_stored) {
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* A game's payload as the JSON the hub stores: a copy of exactly what
|
|
210
|
+
* `JSON.stringify` sends, so an optional field left `undefined` is simply
|
|
211
|
+
* absent (as on the wire) and the outbox's payload checks accept it, and a
|
|
212
|
+
* later change to the game's object can't alter a queued write.
|
|
213
|
+
*/
|
|
214
|
+
function toStoredJson(payload) {
|
|
215
|
+
const stored = parseJsonText(JSON.stringify(payload));
|
|
216
|
+
if (isJson(stored)) return stored;
|
|
217
|
+
throw new TypeError("This value cannot be stored as JSON.");
|
|
218
|
+
}
|
|
219
|
+
/** JSON the game stored, typed as the game's own type. */
|
|
220
|
+
function toGameData(stored) {
|
|
221
|
+
if (isGameOwnData(stored)) return stored;
|
|
222
|
+
throw new TypeError("unreachable: stored game data is the game's own");
|
|
223
|
+
}
|
|
224
|
+
//#endregion
|
|
225
|
+
//#region src/core/offline/kind-adapters.ts
|
|
226
|
+
/** A kind whose payloads are checked by `read` (the hub's own kinds). */
|
|
227
|
+
function checkedKind(read, definition) {
|
|
228
|
+
const readOrThrow = (stored) => {
|
|
229
|
+
const payload = read(stored);
|
|
230
|
+
if (payload === void 0) throw new Error("Malformed offline operation payload.");
|
|
231
|
+
return payload;
|
|
232
|
+
};
|
|
233
|
+
const { mergeKey, merge } = definition;
|
|
234
|
+
return {
|
|
235
|
+
run: (stored, context) => definition.run(readOrThrow(stored), context),
|
|
236
|
+
accepts: (stored) => read(stored) !== void 0,
|
|
237
|
+
...mergeKey === void 0 ? {} : { mergeKey: (stored) => mergeKey(readOrThrow(stored)) },
|
|
238
|
+
...merge === void 0 ? {} : { merge: (waiting, incoming) => merge(readOrThrow(waiting), readOrThrow(incoming)) },
|
|
239
|
+
replaces: definition.replaces === true
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
/** A game's kind: a bare handler or a full definition, its payload typed by the game. */
|
|
243
|
+
function gameKind(definition) {
|
|
244
|
+
const full = typeof definition === "function" ? { run: definition } : definition;
|
|
245
|
+
const { mergeKey, merge } = full;
|
|
246
|
+
return {
|
|
247
|
+
run: (stored, context) => full.run(toGameData(stored), context),
|
|
248
|
+
accepts: () => true,
|
|
249
|
+
...mergeKey === void 0 ? {} : { mergeKey: (stored) => mergeKey(toGameData(stored)) },
|
|
250
|
+
...merge === void 0 ? {} : { merge: (waiting, incoming) => {
|
|
251
|
+
const merged = merge(toGameData(waiting), toGameData(incoming));
|
|
252
|
+
return merged === void 0 ? void 0 : toStoredJson(merged);
|
|
253
|
+
} },
|
|
254
|
+
replaces: full.replaces === true
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
//#endregion
|
|
258
|
+
//#region src/core/offline/hub-kinds/daily-kind.ts
|
|
259
|
+
/** The kind name. */
|
|
260
|
+
const DAILY_KIND = "hub.daily";
|
|
261
|
+
function readDaily(stored) {
|
|
262
|
+
if (!isRecord(stored) || !isJsonObject(stored.meta)) return;
|
|
263
|
+
const day = optionalString(stored.day);
|
|
264
|
+
const token = optionalString(stored.token);
|
|
265
|
+
return day === void 0 || token === void 0 ? void 0 : {
|
|
266
|
+
day,
|
|
267
|
+
token,
|
|
268
|
+
meta: stored.meta
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
/** A completion reply read back, or `undefined` when it doesn't read as one. */
|
|
272
|
+
function readDailyCompletion(result) {
|
|
273
|
+
if (!isRecord(result) || result.ok !== true) return;
|
|
274
|
+
const day = optionalString(result.day);
|
|
275
|
+
const count = optionalNumber(result.count);
|
|
276
|
+
const rank = result.rank === null ? null : optionalNumber(result.rank);
|
|
277
|
+
return day === void 0 || count === void 0 || rank === void 0 ? void 0 : {
|
|
278
|
+
ok: true,
|
|
279
|
+
day,
|
|
280
|
+
count,
|
|
281
|
+
rank
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
/** The daily kind, by name; `onCompleted` hears each completion that lands from the queue. */
|
|
285
|
+
function dailyKinds(game, onCompleted) {
|
|
286
|
+
return { [DAILY_KIND]: checkedKind(readDaily, { run: async ({ day, token, meta }, context) => {
|
|
287
|
+
let completion;
|
|
288
|
+
try {
|
|
289
|
+
completion = await context.call("completeDailyChallenge", {
|
|
290
|
+
...game(),
|
|
291
|
+
day,
|
|
292
|
+
token,
|
|
293
|
+
meta
|
|
294
|
+
});
|
|
295
|
+
} catch (failure) {
|
|
296
|
+
if (!(failure instanceof HubError) || failure.status !== 401) throw failure;
|
|
297
|
+
const started = await context.call("startDailyChallenge", {
|
|
298
|
+
...game(),
|
|
299
|
+
day
|
|
300
|
+
});
|
|
301
|
+
completion = await context.call("completeDailyChallenge", {
|
|
302
|
+
...game(),
|
|
303
|
+
day,
|
|
304
|
+
token: started.token,
|
|
305
|
+
meta
|
|
306
|
+
});
|
|
202
307
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
308
|
+
if (context.isReplay) onCompleted({
|
|
309
|
+
day,
|
|
310
|
+
token,
|
|
311
|
+
meta
|
|
312
|
+
}, completion);
|
|
313
|
+
return completion;
|
|
314
|
+
} }) };
|
|
315
|
+
}
|
|
316
|
+
//#endregion
|
|
317
|
+
//#region src/core/offline/hub-kinds/economy-kinds.ts
|
|
318
|
+
/** The kind names. */
|
|
319
|
+
const ECONOMY_KINDS = {
|
|
320
|
+
grant: "hub.grant",
|
|
321
|
+
exchange: "hub.exchange",
|
|
322
|
+
favorite: "hub.favorite"
|
|
323
|
+
};
|
|
324
|
+
/** Goods read back, or `undefined` when malformed. */
|
|
325
|
+
function readGoods(stored) {
|
|
326
|
+
if (!isRecord(stored)) return;
|
|
327
|
+
const coins = stored.coins === void 0 ? void 0 : optionalNumber(stored.coins);
|
|
328
|
+
if (stored.coins !== void 0 && coins === void 0) return;
|
|
329
|
+
const items = {};
|
|
330
|
+
if (stored.items !== void 0) {
|
|
331
|
+
if (!isRecord(stored.items)) return;
|
|
332
|
+
for (const [key, qty] of Object.entries(stored.items)) {
|
|
333
|
+
const count = optionalNumber(qty);
|
|
334
|
+
if (count === void 0) return;
|
|
335
|
+
items[key] = count;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
...stored.items === void 0 ? {} : { items },
|
|
340
|
+
...coins === void 0 ? {} : { coins }
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
function readExchange(stored) {
|
|
344
|
+
if (!isRecord(stored)) return;
|
|
345
|
+
const take = stored.take === void 0 ? void 0 : readGoods(stored.take);
|
|
346
|
+
const give = stored.give === void 0 ? void 0 : readGoods(stored.give);
|
|
347
|
+
if (stored.take !== void 0 && take === void 0 || stored.give !== void 0 && give === void 0) return;
|
|
348
|
+
return {
|
|
349
|
+
...take === void 0 ? {} : { take },
|
|
350
|
+
...give === void 0 ? {} : { give }
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function readFavorite(stored) {
|
|
354
|
+
if (!isRecord(stored) || typeof stored.on !== "boolean") return;
|
|
355
|
+
const slug = optionalString(stored.slug);
|
|
356
|
+
return slug === void 0 ? void 0 : {
|
|
357
|
+
slug,
|
|
358
|
+
on: stored.on
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
/** The economy kinds, by name; `onFavorites` hears the starred list each star answers with. */
|
|
362
|
+
function economyKinds(game, onFavorites) {
|
|
363
|
+
return {
|
|
364
|
+
[ECONOMY_KINDS.grant]: checkedKind(readGoods, { run: (goods, context) => context.call("grantInventoryItems", {
|
|
365
|
+
...game(),
|
|
366
|
+
...goods
|
|
367
|
+
}) }),
|
|
368
|
+
[ECONOMY_KINDS.exchange]: checkedKind(readExchange, { run: (exchange, context) => context.call("exchangeInventoryItems", {
|
|
369
|
+
...game(),
|
|
370
|
+
...exchange
|
|
371
|
+
}) }),
|
|
372
|
+
[ECONOMY_KINDS.favorite]: checkedKind(readFavorite, {
|
|
373
|
+
mergeKey: (favorite) => favorite.slug,
|
|
374
|
+
replaces: true,
|
|
375
|
+
run: async (favorite, context) => {
|
|
376
|
+
const reply = await context.call("setGameFavorite", {
|
|
377
|
+
slug: favorite.slug,
|
|
378
|
+
on: favorite.on
|
|
379
|
+
});
|
|
380
|
+
onFavorites(reply.games);
|
|
381
|
+
return reply;
|
|
382
|
+
}
|
|
383
|
+
})
|
|
384
|
+
};
|
|
206
385
|
}
|
|
207
386
|
//#endregion
|
|
208
387
|
//#region src/core/offline/queue.ts
|
|
209
388
|
/** The score options in a stored op, or `undefined` when they are malformed. */
|
|
210
|
-
function readScoreOptions(stored) {
|
|
389
|
+
function readScoreOptions$1(stored) {
|
|
211
390
|
const title = optionalString(stored.title);
|
|
212
391
|
const sort = stored.sort === "asc" || stored.sort === "desc" ? stored.sort : void 0;
|
|
213
392
|
if (stored.meta !== void 0 && !isJson(stored.meta)) return;
|
|
@@ -257,7 +436,7 @@ function readOpInput(stored) {
|
|
|
257
436
|
}
|
|
258
437
|
case "submitScore": {
|
|
259
438
|
const score = optionalNumber(stored.score);
|
|
260
|
-
const opts = isRecord(stored.opts) ? readScoreOptions(stored.opts) : void 0;
|
|
439
|
+
const opts = isRecord(stored.opts) ? readScoreOptions$1(stored.opts) : void 0;
|
|
261
440
|
return key !== void 0 && score !== void 0 && opts !== void 0 ? {
|
|
262
441
|
kind: "submitScore",
|
|
263
442
|
key,
|
|
@@ -294,47 +473,310 @@ function readQueue(stored) {
|
|
|
294
473
|
function betterScore(first, second, sort) {
|
|
295
474
|
return sort === "asc" ? Math.min(first, second) : Math.max(first, second);
|
|
296
475
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
476
|
+
//#endregion
|
|
477
|
+
//#region src/core/offline/hub-kinds/progress-kinds.ts
|
|
478
|
+
/** The kind names. */
|
|
479
|
+
const PROGRESS_KINDS = {
|
|
480
|
+
save: "hub.save",
|
|
481
|
+
stat: "hub.stat",
|
|
482
|
+
score: "hub.score",
|
|
483
|
+
profile: "hub.profile"
|
|
484
|
+
};
|
|
485
|
+
function readSave$1(stored) {
|
|
486
|
+
if (!isRecord(stored)) return;
|
|
487
|
+
const slot = optionalString(stored.slot);
|
|
488
|
+
if (slot === void 0) return;
|
|
489
|
+
if (stored.deleted === true) return {
|
|
490
|
+
slot,
|
|
491
|
+
deleted: true
|
|
302
492
|
};
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
493
|
+
return isJson(stored.data) && isNullableString(stored.label) ? {
|
|
494
|
+
slot,
|
|
495
|
+
data: stored.data,
|
|
496
|
+
label: stored.label
|
|
497
|
+
} : void 0;
|
|
498
|
+
}
|
|
499
|
+
function readStat(stored) {
|
|
500
|
+
if (!isRecord(stored)) return;
|
|
501
|
+
const key = optionalString(stored.key);
|
|
502
|
+
const value = optionalNumber(stored.value);
|
|
503
|
+
const mode = stored.mode === "set" || stored.mode === "incr" || stored.mode === "max" ? stored.mode : void 0;
|
|
504
|
+
return key === void 0 || value === void 0 || mode === void 0 ? void 0 : {
|
|
505
|
+
key,
|
|
506
|
+
mode,
|
|
507
|
+
value
|
|
306
508
|
};
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
509
|
+
}
|
|
510
|
+
/** Score options read back, or `undefined` when malformed. */
|
|
511
|
+
function readScoreOptions(stored) {
|
|
512
|
+
if (!isRecord(stored)) return;
|
|
513
|
+
const title = optionalString(stored.title);
|
|
514
|
+
const sort = stored.sort === "asc" || stored.sort === "desc" ? stored.sort : void 0;
|
|
515
|
+
if (stored.meta !== void 0 && !isJson(stored.meta)) return;
|
|
516
|
+
return {
|
|
517
|
+
...title === void 0 ? {} : { title },
|
|
518
|
+
...sort === void 0 ? {} : { sort },
|
|
519
|
+
...stored.meta === void 0 ? {} : { meta: stored.meta }
|
|
310
520
|
};
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
521
|
+
}
|
|
522
|
+
function readScore(stored) {
|
|
523
|
+
if (!isRecord(stored) || !isJson(stored.opts)) return;
|
|
524
|
+
const key = optionalString(stored.key);
|
|
525
|
+
const score = optionalNumber(stored.score);
|
|
526
|
+
const opts = readScoreOptions(stored.opts);
|
|
527
|
+
return key === void 0 || score === void 0 || opts === void 0 ? void 0 : {
|
|
528
|
+
key,
|
|
529
|
+
score,
|
|
530
|
+
opts
|
|
315
531
|
};
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
532
|
+
}
|
|
533
|
+
/** A profile patch read back, or `undefined` when malformed. */
|
|
534
|
+
function readProfile$1(stored) {
|
|
535
|
+
if (!isRecord(stored)) return;
|
|
536
|
+
const { displayName, data: profileData } = stored;
|
|
537
|
+
if (displayName !== void 0 && !isNullableString(displayName) || profileData !== void 0 && !isJson(profileData)) return;
|
|
538
|
+
return {
|
|
539
|
+
...displayName === void 0 ? {} : { displayName },
|
|
540
|
+
...profileData === void 0 ? {} : { data: profileData }
|
|
322
541
|
};
|
|
323
542
|
}
|
|
324
|
-
/**
|
|
325
|
-
function
|
|
326
|
-
|
|
543
|
+
/** Two waiting stat updates of one key folded together, or `undefined` when their modes differ. */
|
|
544
|
+
function mergeStats(waiting, incoming) {
|
|
545
|
+
if (waiting.mode !== incoming.mode) return;
|
|
546
|
+
switch (incoming.mode) {
|
|
547
|
+
case "incr": return {
|
|
548
|
+
...waiting,
|
|
549
|
+
value: waiting.value + incoming.value
|
|
550
|
+
};
|
|
551
|
+
case "max": return {
|
|
552
|
+
...waiting,
|
|
553
|
+
value: Math.max(waiting.value, incoming.value)
|
|
554
|
+
};
|
|
555
|
+
case "set": return incoming;
|
|
556
|
+
}
|
|
327
557
|
}
|
|
558
|
+
/** The progress kinds, by name. */
|
|
559
|
+
function progressKinds(game) {
|
|
560
|
+
return {
|
|
561
|
+
[PROGRESS_KINDS.save]: checkedKind(readSave$1, {
|
|
562
|
+
mergeKey: (save) => save.slot,
|
|
563
|
+
replaces: true,
|
|
564
|
+
run: async (save, context) => {
|
|
565
|
+
if ("deleted" in save) await context.call("deleteSave", {
|
|
566
|
+
...game(),
|
|
567
|
+
slot: save.slot
|
|
568
|
+
});
|
|
569
|
+
else await context.call("writeSave", {
|
|
570
|
+
...game(),
|
|
571
|
+
slot: save.slot,
|
|
572
|
+
data: save.data,
|
|
573
|
+
label: save.label
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
}),
|
|
577
|
+
[PROGRESS_KINDS.stat]: checkedKind(readStat, {
|
|
578
|
+
mergeKey: (stat) => stat.key,
|
|
579
|
+
merge: mergeStats,
|
|
580
|
+
run: async (stat, context) => {
|
|
581
|
+
await context.call("updateGameStat", {
|
|
582
|
+
...game(),
|
|
583
|
+
key: stat.key,
|
|
584
|
+
value: stat.value,
|
|
585
|
+
mode: stat.mode
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
}),
|
|
589
|
+
[PROGRESS_KINDS.score]: checkedKind(readScore, {
|
|
590
|
+
mergeKey: (submission) => submission.key,
|
|
591
|
+
merge: (waiting, incoming) => ({
|
|
592
|
+
...waiting,
|
|
593
|
+
score: betterScore(waiting.score, incoming.score, incoming.opts.sort),
|
|
594
|
+
opts: incoming.opts
|
|
595
|
+
}),
|
|
596
|
+
run: async (submission, context) => {
|
|
597
|
+
await context.call("submitScore", {
|
|
598
|
+
...game(),
|
|
599
|
+
key: submission.key,
|
|
600
|
+
score: submission.score,
|
|
601
|
+
...submission.opts
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
}),
|
|
605
|
+
[PROGRESS_KINDS.profile]: checkedKind(readProfile$1, {
|
|
606
|
+
mergeKey: () => "profile",
|
|
607
|
+
merge: (waiting, incoming) => ({
|
|
608
|
+
...waiting,
|
|
609
|
+
...incoming
|
|
610
|
+
}),
|
|
611
|
+
run: async (patch, context) => {
|
|
612
|
+
await context.call("setGameProfile", {
|
|
613
|
+
...game(),
|
|
614
|
+
...patch
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
})
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
//#endregion
|
|
621
|
+
//#region src/core/offline/hub-kinds/legacy-ops.ts
|
|
622
|
+
/** The outbox operation for a v1 queued write, or `undefined` when it no longer reads as one. */
|
|
623
|
+
function legacyOperation(op) {
|
|
624
|
+
switch (op.kind) {
|
|
625
|
+
case "putSave": return {
|
|
626
|
+
kind: PROGRESS_KINDS.save,
|
|
627
|
+
payload: {
|
|
628
|
+
slot: op.slot,
|
|
629
|
+
data: op.data,
|
|
630
|
+
label: op.label
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
case "deleteSave": return {
|
|
634
|
+
kind: PROGRESS_KINDS.save,
|
|
635
|
+
payload: {
|
|
636
|
+
slot: op.slot,
|
|
637
|
+
deleted: true
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
case "setStat": return {
|
|
641
|
+
kind: PROGRESS_KINDS.stat,
|
|
642
|
+
payload: {
|
|
643
|
+
key: op.key,
|
|
644
|
+
mode: "set",
|
|
645
|
+
value: op.value
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
case "maxStat": return {
|
|
649
|
+
kind: PROGRESS_KINDS.stat,
|
|
650
|
+
payload: {
|
|
651
|
+
key: op.key,
|
|
652
|
+
mode: "max",
|
|
653
|
+
value: op.value
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
case "incrStat": return {
|
|
657
|
+
kind: PROGRESS_KINDS.stat,
|
|
658
|
+
payload: {
|
|
659
|
+
key: op.key,
|
|
660
|
+
mode: "incr",
|
|
661
|
+
value: op.amount
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
case "submitScore": {
|
|
665
|
+
const opts = readScoreOptions({ ...op.opts });
|
|
666
|
+
return opts === void 0 ? void 0 : {
|
|
667
|
+
kind: PROGRESS_KINDS.score,
|
|
668
|
+
payload: {
|
|
669
|
+
key: op.key,
|
|
670
|
+
score: op.score,
|
|
671
|
+
opts
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
case "setProfile": {
|
|
676
|
+
const patch = readProfile$1({ ...op.patch });
|
|
677
|
+
return patch === void 0 ? void 0 : {
|
|
678
|
+
kind: PROGRESS_KINDS.profile,
|
|
679
|
+
payload: patch
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
//#endregion
|
|
685
|
+
//#region src/core/offline/hub-kinds/index.ts
|
|
328
686
|
/**
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
* first queued one in place; anything else is appended.
|
|
687
|
+
* Every kind the hub itself queues offline (`hub.*`), registered on a
|
|
688
|
+
* client's outbox. The docs' "Offline and replay" table lists them.
|
|
332
689
|
*/
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
690
|
+
/** Register every hub kind on `outbox`. */
|
|
691
|
+
function registerHubKinds(outbox, hooks) {
|
|
692
|
+
const kinds = {
|
|
693
|
+
...progressKinds(hooks.game),
|
|
694
|
+
...dailyKinds(hooks.game, hooks.onDailyComplete),
|
|
695
|
+
...economyKinds(hooks.game, writeLocalFavorites)
|
|
696
|
+
};
|
|
697
|
+
for (const [kind, definition] of Object.entries(kinds)) outbox.define(kind, definition);
|
|
698
|
+
}
|
|
699
|
+
/** The well-formed events of a stored buffer. */
|
|
700
|
+
function readEvents(stored) {
|
|
701
|
+
if (!Array.isArray(stored)) return [];
|
|
702
|
+
return stored.flatMap((entry) => {
|
|
703
|
+
if (!isRecord(entry)) return [];
|
|
704
|
+
const type = optionalString(entry.type);
|
|
705
|
+
const ts = optionalNumber(entry.ts);
|
|
706
|
+
return type === void 0 || ts === void 0 ? [] : [{
|
|
707
|
+
type,
|
|
708
|
+
ts
|
|
709
|
+
}];
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
/** The buffer with one more event, trimmed to {@link EVENT_BUFFER_CAP}: oldest heartbeats go first, then the oldest events. */
|
|
713
|
+
function appendEvent(events, incoming) {
|
|
714
|
+
let buffered = [...events, incoming];
|
|
715
|
+
let excess = buffered.length - 100;
|
|
716
|
+
if (excess <= 0) return buffered;
|
|
717
|
+
buffered = buffered.filter((event) => {
|
|
718
|
+
if (excess > 0 && event.type === "heartbeat") {
|
|
719
|
+
excess -= 1;
|
|
720
|
+
return false;
|
|
721
|
+
}
|
|
722
|
+
return true;
|
|
723
|
+
});
|
|
724
|
+
return buffered.length > 100 ? buffered.slice(buffered.length - 100) : buffered;
|
|
725
|
+
}
|
|
726
|
+
//#endregion
|
|
727
|
+
//#region src/core/offline/local-inventory.ts
|
|
728
|
+
/** An empty inventory. */
|
|
729
|
+
const EMPTY_INVENTORY = {
|
|
730
|
+
items: [],
|
|
731
|
+
coins: 0
|
|
732
|
+
};
|
|
733
|
+
/** A cached inventory, or `null` when there is none or it is malformed. */
|
|
734
|
+
function readInventory(stored) {
|
|
735
|
+
if (!isRecord(stored) || !Array.isArray(stored.items)) return null;
|
|
736
|
+
const coins = optionalNumber(stored.coins);
|
|
737
|
+
const items = stored.items.flatMap((item) => {
|
|
738
|
+
const key = isRecord(item) ? optionalString(item.key) : void 0;
|
|
739
|
+
const qty = isRecord(item) ? optionalNumber(item.qty) : void 0;
|
|
740
|
+
return key === void 0 || qty === void 0 ? [] : [{
|
|
741
|
+
key,
|
|
742
|
+
qty
|
|
743
|
+
}];
|
|
744
|
+
});
|
|
745
|
+
return coins === void 0 ? null : {
|
|
746
|
+
items,
|
|
747
|
+
coins
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
/** The holdings as a key-to-quantity map. */
|
|
751
|
+
function quantities(inventory) {
|
|
752
|
+
return new Map(inventory.items.map((item) => [item.key, item.qty]));
|
|
753
|
+
}
|
|
754
|
+
/** The holdings from a map (positive counts only, sorted by key). */
|
|
755
|
+
function fromQuantities(held, coins) {
|
|
756
|
+
return {
|
|
757
|
+
items: [...held].filter(([, qty]) => qty > 0).sort(([first], [second]) => first.localeCompare(second)).map(([key, qty]) => ({
|
|
758
|
+
key,
|
|
759
|
+
qty
|
|
760
|
+
})),
|
|
761
|
+
coins
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
/** `inventory` with `goods` added. */
|
|
765
|
+
function withGoods(inventory, goods) {
|
|
766
|
+
const held = quantities(inventory);
|
|
767
|
+
for (const [key, qty] of Object.entries(goods.items ?? {})) held.set(key, (held.get(key) ?? 0) + qty);
|
|
768
|
+
return fromQuantities(held, inventory.coins + (goods.coins ?? 0));
|
|
769
|
+
}
|
|
770
|
+
/** `inventory` with `goods` removed, or the hub's refusal message when it can't cover them. */
|
|
771
|
+
function withoutGoods(inventory, goods) {
|
|
772
|
+
if ((goods.coins ?? 0) > inventory.coins) return "You don't have enough coins.";
|
|
773
|
+
const held = quantities(inventory);
|
|
774
|
+
for (const [key, qty] of Object.entries(goods.items ?? {})) {
|
|
775
|
+
const have = held.get(key) ?? 0;
|
|
776
|
+
if (qty > have) return `You don't have ${String(qty)}× "${key}".`;
|
|
777
|
+
held.set(key, have - qty);
|
|
778
|
+
}
|
|
779
|
+
return fromQuantities(held, inventory.coins - (goods.coins ?? 0));
|
|
338
780
|
}
|
|
339
781
|
//#endregion
|
|
340
782
|
//#region src/core/offline/local-store.ts
|
|
@@ -400,7 +842,6 @@ var LocalStore = class {
|
|
|
400
842
|
constructor(slug, store = createPageStore()) {
|
|
401
843
|
_defineProperty(this, "prefix", void 0);
|
|
402
844
|
_defineProperty(this, "store", void 0);
|
|
403
|
-
_defineProperty(this, "sequence", 0);
|
|
404
845
|
this.prefix = `hub:${slug}:`;
|
|
405
846
|
this.store = store;
|
|
406
847
|
}
|
|
@@ -514,27 +955,20 @@ var LocalStore = class {
|
|
|
514
955
|
best
|
|
515
956
|
};
|
|
516
957
|
}
|
|
517
|
-
/** The queued writes,
|
|
518
|
-
|
|
519
|
-
|
|
958
|
+
/** The v1 client's queued writes, removed from storage (the caller moves them into the outbox). */
|
|
959
|
+
takeLegacyQueue() {
|
|
960
|
+
const queue = readQueue(this.readStored(`${this.prefix}queue`));
|
|
961
|
+
this.store.remove(`${this.prefix}queue`);
|
|
962
|
+
return queue;
|
|
520
963
|
}
|
|
521
|
-
/**
|
|
522
|
-
|
|
523
|
-
this.
|
|
524
|
-
}
|
|
525
|
-
/** How many writes are queued. */
|
|
526
|
-
queueLength() {
|
|
527
|
-
return this.getQueue().length;
|
|
964
|
+
/** This game's cached inventory (empty when none is cached). */
|
|
965
|
+
getInventory() {
|
|
966
|
+
return readInventory(this.readStored(`${this.prefix}inventory`)) ?? EMPTY_INVENTORY;
|
|
528
967
|
}
|
|
529
|
-
/**
|
|
530
|
-
|
|
531
|
-
this.
|
|
532
|
-
|
|
533
|
-
this.setQueue(enqueueOp(this.getQueue(), {
|
|
534
|
-
...input,
|
|
535
|
-
id: `${String(now)}-${String(this.sequence)}`,
|
|
536
|
-
ts: now
|
|
537
|
-
}));
|
|
968
|
+
/** Store this game's inventory and return it. */
|
|
969
|
+
setInventory(inventory) {
|
|
970
|
+
this.writeStored(`${this.prefix}inventory`, inventory);
|
|
971
|
+
return inventory;
|
|
538
972
|
}
|
|
539
973
|
/** The buffered analytics events, oldest first. */
|
|
540
974
|
getEvents() {
|
|
@@ -574,6 +1008,574 @@ var LocalStore = class {
|
|
|
574
1008
|
}
|
|
575
1009
|
};
|
|
576
1010
|
//#endregion
|
|
1011
|
+
//#region src/core/offline/outbox-kinds.ts
|
|
1012
|
+
/** The prefix reserved for the hub's own kinds. */
|
|
1013
|
+
const HUB_KIND_PREFIX = "hub.";
|
|
1014
|
+
/** The registered kinds of one client. */
|
|
1015
|
+
var KindRegistry = class {
|
|
1016
|
+
constructor() {
|
|
1017
|
+
_defineProperty(this, "kinds", /* @__PURE__ */ new Map());
|
|
1018
|
+
}
|
|
1019
|
+
/** Register (or replace) a kind. */
|
|
1020
|
+
register(kind, definition) {
|
|
1021
|
+
this.kinds.set(kind, definition);
|
|
1022
|
+
}
|
|
1023
|
+
/** A registered kind, or `undefined`. */
|
|
1024
|
+
get(kind) {
|
|
1025
|
+
return this.kinds.get(kind);
|
|
1026
|
+
}
|
|
1027
|
+
};
|
|
1028
|
+
//#endregion
|
|
1029
|
+
//#region src/core/offline/offline-api.ts
|
|
1030
|
+
/**
|
|
1031
|
+
* `hub.offline`: the outbox, opened to games. A game defines its own kinds
|
|
1032
|
+
* (a handler per kind, which may make several dependent calls) and enqueues
|
|
1033
|
+
* operations of them; the hub queues them in order, replays them when it is
|
|
1034
|
+
* reachable, and reports what happened.
|
|
1035
|
+
*/
|
|
1036
|
+
/** The game-facing outbox API. */
|
|
1037
|
+
var OfflineApi = class {
|
|
1038
|
+
/** The API over `host`. */
|
|
1039
|
+
constructor(host) {
|
|
1040
|
+
_defineProperty(this, "host", void 0);
|
|
1041
|
+
this.host = host;
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Define what an operation of `kind` does when it replays. Define every
|
|
1045
|
+
* kind at load, before or after enqueueing: waiting ops of an undefined
|
|
1046
|
+
* kind hold the queue until it is defined. `hub.*` kinds are the hub's own.
|
|
1047
|
+
*/
|
|
1048
|
+
define(kind, definition) {
|
|
1049
|
+
if (kind.startsWith("hub.") || kind === "") throw new HubError(`Offline kinds starting with "${HUB_KIND_PREFIX}" are the hub's own; name yours after your game.`);
|
|
1050
|
+
this.host.outbox.define(kind, gameKind(definition));
|
|
1051
|
+
this.host.requestSync();
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Queue an operation of `kind` and replay it as soon as the hub is
|
|
1055
|
+
* reachable (at once when online). Resolves the queued op; its outcome
|
|
1056
|
+
* arrives as a `replayed` or `failed` event. Throws a `HubError` (code
|
|
1057
|
+
* `offline_queue_full`) when the outbox is full.
|
|
1058
|
+
*/
|
|
1059
|
+
enqueue(kind, payload) {
|
|
1060
|
+
const op = this.host.outbox.stamp(kind, toStoredJson(payload));
|
|
1061
|
+
this.host.outbox.add(op);
|
|
1062
|
+
this.host.requestSync();
|
|
1063
|
+
return op;
|
|
1064
|
+
}
|
|
1065
|
+
/** The waiting operations (the hub's and the game's), oldest first. */
|
|
1066
|
+
pending() {
|
|
1067
|
+
return this.host.outbox.pending();
|
|
1068
|
+
}
|
|
1069
|
+
/** Listen for `queued`, `replayed` or `failed`; returns the unsubscribe function. */
|
|
1070
|
+
on(name, listener) {
|
|
1071
|
+
return this.host.outbox.on(name, listener);
|
|
1072
|
+
}
|
|
1073
|
+
};
|
|
1074
|
+
//#endregion
|
|
1075
|
+
//#region src/core/offline/outbox-entry.ts
|
|
1076
|
+
/** How wide the time part of an id is (enough for epoch ms until the year 5138). */
|
|
1077
|
+
const TIME_DIGITS = 14;
|
|
1078
|
+
/** How wide the sequence part of an id is. */
|
|
1079
|
+
const SEQUENCE_DIGITS = 6;
|
|
1080
|
+
/**
|
|
1081
|
+
* A new entry id: the queue time, a per-page sequence and a random tag, so
|
|
1082
|
+
* ids sort by time and two tabs queueing in the same millisecond never clash.
|
|
1083
|
+
*/
|
|
1084
|
+
function newEntryId(now, sequence) {
|
|
1085
|
+
const tag = Math.floor(Math.random() * 36 ** 6).toString(36).padStart(6, "0");
|
|
1086
|
+
return `${String(now).padStart(TIME_DIGITS, "0")}-${String(sequence % 10 ** SEQUENCE_DIGITS).padStart(SEQUENCE_DIGITS, "0")}-${tag}`;
|
|
1087
|
+
}
|
|
1088
|
+
/** A stored entry, or `undefined` when it is malformed. */
|
|
1089
|
+
function readEntry(stored) {
|
|
1090
|
+
if (!isRecord(stored) || !isJson(stored.payload)) return;
|
|
1091
|
+
const id = optionalString(stored.id);
|
|
1092
|
+
const kind = optionalString(stored.kind);
|
|
1093
|
+
const createdAt = optionalNumber(stored.createdAt);
|
|
1094
|
+
const attempts = optionalNumber(stored.attempts);
|
|
1095
|
+
const retryAt = optionalNumber(stored.retryAt);
|
|
1096
|
+
const lastError = optionalString(stored.lastError);
|
|
1097
|
+
if (id === void 0 || kind === void 0 || createdAt === void 0 || attempts === void 0 || retryAt === void 0) return;
|
|
1098
|
+
return {
|
|
1099
|
+
id,
|
|
1100
|
+
kind,
|
|
1101
|
+
payload: stored.payload,
|
|
1102
|
+
createdAt,
|
|
1103
|
+
attempts,
|
|
1104
|
+
retryAt,
|
|
1105
|
+
...lastError === void 0 ? {} : { lastError }
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
//#endregion
|
|
1109
|
+
//#region src/core/offline/outbox-merge.ts
|
|
1110
|
+
/** How `incoming` (of kind `kind`) joins `entries` (oldest first). */
|
|
1111
|
+
function planEnqueue(entries, incoming, kind) {
|
|
1112
|
+
const separately = {
|
|
1113
|
+
put: incoming,
|
|
1114
|
+
remove: [],
|
|
1115
|
+
isMerged: false
|
|
1116
|
+
};
|
|
1117
|
+
const { mergeKey } = kind;
|
|
1118
|
+
if (mergeKey === void 0) return separately;
|
|
1119
|
+
const key = mergeKey(incoming.payload);
|
|
1120
|
+
const sameKey = entries.filter((entry) => entry.kind === incoming.kind && kind.accepts(entry.payload) && mergeKey(entry.payload) === key);
|
|
1121
|
+
if (kind.replaces) return {
|
|
1122
|
+
put: incoming,
|
|
1123
|
+
remove: sameKey.filter((entry) => entry.attempts === 0).map((entry) => entry.id),
|
|
1124
|
+
isMerged: false
|
|
1125
|
+
};
|
|
1126
|
+
const newest = sameKey.at(-1);
|
|
1127
|
+
if (kind.merge === void 0 || newest === void 0 || newest.attempts > 0) return separately;
|
|
1128
|
+
const merged = kind.merge(newest.payload, incoming.payload);
|
|
1129
|
+
return merged === void 0 ? separately : {
|
|
1130
|
+
put: {
|
|
1131
|
+
...newest,
|
|
1132
|
+
payload: merged
|
|
1133
|
+
},
|
|
1134
|
+
remove: [],
|
|
1135
|
+
isMerged: true
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
//#endregion
|
|
1139
|
+
//#region src/core/offline/replay-policy.ts
|
|
1140
|
+
/**
|
|
1141
|
+
* What a failed replay does next, and how long it waits:
|
|
1142
|
+
*
|
|
1143
|
+
* - `offline`: the hub (or the network) can't be reached. The op stays at
|
|
1144
|
+
* the head of the queue and replay waits for the connection.
|
|
1145
|
+
* - `retry`: the hub answered with a passing problem (408, 429, 5xx) or a
|
|
1146
|
+
* game handler's fetch failed. The op stays at the head and is retried with
|
|
1147
|
+
* backoff, up to {@link MAX_RETRIES} times, then dropped.
|
|
1148
|
+
* - `drop`: the hub refused it (any other status) or the handler threw
|
|
1149
|
+
* something else. The op is dropped and reported, and the queue moves on.
|
|
1150
|
+
*/
|
|
1151
|
+
/** The first retry's delay. */
|
|
1152
|
+
const BASE_DELAY_MS = 1e3;
|
|
1153
|
+
/** The longest delay between tries. */
|
|
1154
|
+
const MAX_DELAY_MS = 6e4;
|
|
1155
|
+
/** Statuses that mean "try again later". */
|
|
1156
|
+
const PASSING_STATUSES = /* @__PURE__ */ new Set([
|
|
1157
|
+
408,
|
|
1158
|
+
425,
|
|
1159
|
+
429
|
|
1160
|
+
]);
|
|
1161
|
+
/** The verdict for a failure. */
|
|
1162
|
+
function verdictFor(failure) {
|
|
1163
|
+
if (failure instanceof HubError) {
|
|
1164
|
+
if (failure.status === void 0) return "offline";
|
|
1165
|
+
return failure.status >= 500 || PASSING_STATUSES.has(failure.status) ? "retry" : "drop";
|
|
1166
|
+
}
|
|
1167
|
+
return failure instanceof TypeError ? "retry" : "drop";
|
|
1168
|
+
}
|
|
1169
|
+
/** The delay before try `attempts + 1`: doubling from 1 s, capped at 60 s, with jitter so tabs and players spread out. */
|
|
1170
|
+
function backoffMs(attempts, random = Math.random) {
|
|
1171
|
+
const ceiling = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** Math.max(0, attempts - 1));
|
|
1172
|
+
return Math.round(ceiling / 2 + ceiling / 2 * random());
|
|
1173
|
+
}
|
|
1174
|
+
/** A failure's message, for reports. */
|
|
1175
|
+
function failureMessage(failure) {
|
|
1176
|
+
return failure instanceof Error ? failure.message : String(failure);
|
|
1177
|
+
}
|
|
1178
|
+
//#endregion
|
|
1179
|
+
//#region src/core/offline/outbox-replayer.ts
|
|
1180
|
+
/** A report for a replay that didn't get the lease. */
|
|
1181
|
+
const LEASE_HELD = {
|
|
1182
|
+
replayed: [],
|
|
1183
|
+
failed: [],
|
|
1184
|
+
stoppedBy: "lease"
|
|
1185
|
+
};
|
|
1186
|
+
/** Replays one game's outbox. */
|
|
1187
|
+
var OutboxReplayer = class {
|
|
1188
|
+
/** A replayer over `parts`. */
|
|
1189
|
+
constructor(parts) {
|
|
1190
|
+
_defineProperty(this, "parts", void 0);
|
|
1191
|
+
this.parts = parts;
|
|
1192
|
+
}
|
|
1193
|
+
/** Replay everything due, in order. `isEager` skips the backoff of ops waiting for the connection (it just came back). */
|
|
1194
|
+
async replay(isEager = false) {
|
|
1195
|
+
let report = LEASE_HELD;
|
|
1196
|
+
return await this.parts.lease.hold(async () => {
|
|
1197
|
+
report = await this.drain(isEager);
|
|
1198
|
+
}) ? report : LEASE_HELD;
|
|
1199
|
+
}
|
|
1200
|
+
async drain(isEager) {
|
|
1201
|
+
const { store, kinds, now } = this.parts;
|
|
1202
|
+
const replayed = [];
|
|
1203
|
+
const failed = [];
|
|
1204
|
+
for (let head = store.list()[0]; head !== void 0; head = store.list()[0]) {
|
|
1205
|
+
if (head.retryAt > now() && !(isEager && head.lastError === void 0)) {
|
|
1206
|
+
this.parts.scheduleRetry(head.retryAt - now());
|
|
1207
|
+
return {
|
|
1208
|
+
replayed,
|
|
1209
|
+
failed,
|
|
1210
|
+
stoppedBy: "backoff"
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
const kind = kinds.get(head.kind);
|
|
1214
|
+
if (kind === void 0) return {
|
|
1215
|
+
replayed,
|
|
1216
|
+
failed,
|
|
1217
|
+
stoppedBy: "unknown-kind"
|
|
1218
|
+
};
|
|
1219
|
+
if (!kind.accepts(head.payload)) {
|
|
1220
|
+
store.remove(head.id);
|
|
1221
|
+
this.drop(failed, head, "Malformed offline operation payload.");
|
|
1222
|
+
continue;
|
|
1223
|
+
}
|
|
1224
|
+
const started = {
|
|
1225
|
+
...head,
|
|
1226
|
+
attempts: head.attempts + 1
|
|
1227
|
+
};
|
|
1228
|
+
store.put(started);
|
|
1229
|
+
try {
|
|
1230
|
+
const result = await kind.run(head.payload, this.parts.contextFor(head, started.attempts));
|
|
1231
|
+
store.remove(head.id);
|
|
1232
|
+
replayed.push(started);
|
|
1233
|
+
this.parts.onReplayed(started, result ?? void 0);
|
|
1234
|
+
} catch (failure) {
|
|
1235
|
+
const stop = this.settleFailure(started, failure, failed);
|
|
1236
|
+
if (stop !== void 0) return {
|
|
1237
|
+
replayed,
|
|
1238
|
+
failed,
|
|
1239
|
+
stoppedBy: stop
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
return {
|
|
1244
|
+
replayed,
|
|
1245
|
+
failed
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
/** Record a failed try; answers why replay stops, or `undefined` to carry on with the next op. */
|
|
1249
|
+
settleFailure(started, failure, failed) {
|
|
1250
|
+
const { store, now } = this.parts;
|
|
1251
|
+
const verdict = verdictFor(failure);
|
|
1252
|
+
const message = failureMessage(failure);
|
|
1253
|
+
if (verdict === "drop" || verdict === "retry" && started.attempts >= 8) {
|
|
1254
|
+
store.remove(started.id);
|
|
1255
|
+
this.drop(failed, started, message);
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
const delay = backoffMs(started.attempts);
|
|
1259
|
+
const { id, kind, payload, createdAt, attempts } = started;
|
|
1260
|
+
const retrying = {
|
|
1261
|
+
id,
|
|
1262
|
+
kind,
|
|
1263
|
+
payload,
|
|
1264
|
+
createdAt,
|
|
1265
|
+
attempts,
|
|
1266
|
+
retryAt: now() + delay
|
|
1267
|
+
};
|
|
1268
|
+
const waiting = verdict === "offline" ? retrying : {
|
|
1269
|
+
...retrying,
|
|
1270
|
+
lastError: message
|
|
1271
|
+
};
|
|
1272
|
+
store.put(waiting);
|
|
1273
|
+
if (verdict === "offline") this.parts.onUnreachable();
|
|
1274
|
+
this.parts.scheduleRetry(delay);
|
|
1275
|
+
return verdict === "offline" ? "connection" : "backoff";
|
|
1276
|
+
}
|
|
1277
|
+
drop(failed, entry, error) {
|
|
1278
|
+
const failure = {
|
|
1279
|
+
entry,
|
|
1280
|
+
error
|
|
1281
|
+
};
|
|
1282
|
+
failed.push(failure);
|
|
1283
|
+
this.parts.onFailed(failure);
|
|
1284
|
+
}
|
|
1285
|
+
};
|
|
1286
|
+
//#endregion
|
|
1287
|
+
//#region src/core/offline/outbox-store.ts
|
|
1288
|
+
/**
|
|
1289
|
+
* Where the outbox lives: one `localStorage` key per entry,
|
|
1290
|
+
* `hub:<slug>:outbox:<id>`, read back in id (queue) order. A tab adding an
|
|
1291
|
+
* entry and another removing one touch different keys, so neither loses the
|
|
1292
|
+
* other's change. The outbox is capped by entry count and stored size.
|
|
1293
|
+
*/
|
|
1294
|
+
/** The most entries one game's outbox holds. */
|
|
1295
|
+
const OUTBOX_MAX_ENTRIES = 500;
|
|
1296
|
+
/** The most stored characters one game's outbox holds (about 2 MB of `localStorage`). */
|
|
1297
|
+
const OUTBOX_MAX_CHARS = 1e6;
|
|
1298
|
+
/** One game's outbox. */
|
|
1299
|
+
var OutboxStore = class {
|
|
1300
|
+
/** The outbox for game `slug` in `store`. */
|
|
1301
|
+
constructor(slug, store) {
|
|
1302
|
+
_defineProperty(this, "prefix", void 0);
|
|
1303
|
+
_defineProperty(this, "store", void 0);
|
|
1304
|
+
this.prefix = `hub:${slug}:outbox:`;
|
|
1305
|
+
this.store = store;
|
|
1306
|
+
}
|
|
1307
|
+
/** Every well-formed entry, oldest first. Malformed ones are removed. */
|
|
1308
|
+
list() {
|
|
1309
|
+
const entries = [];
|
|
1310
|
+
for (const key of this.entryKeys()) {
|
|
1311
|
+
const text = this.store.read(key);
|
|
1312
|
+
const entry = text === null ? void 0 : readEntry(parseJsonText(text));
|
|
1313
|
+
if (entry === void 0) this.store.remove(key);
|
|
1314
|
+
else entries.push(entry);
|
|
1315
|
+
}
|
|
1316
|
+
return entries;
|
|
1317
|
+
}
|
|
1318
|
+
/** How many entries are queued. */
|
|
1319
|
+
count() {
|
|
1320
|
+
return this.entryKeys().length;
|
|
1321
|
+
}
|
|
1322
|
+
/** One entry, or `undefined` once it is gone (replayed, or removed by another tab). */
|
|
1323
|
+
get(id) {
|
|
1324
|
+
const text = this.store.read(this.prefix + id);
|
|
1325
|
+
return text === null ? void 0 : readEntry(parseJsonText(text));
|
|
1326
|
+
}
|
|
1327
|
+
/** Whether `extra` more stored characters in one more entry still fit under the caps. */
|
|
1328
|
+
fits(extraChars) {
|
|
1329
|
+
const keys = this.entryKeys();
|
|
1330
|
+
if (keys.length >= 500) return false;
|
|
1331
|
+
return keys.reduce((total, key) => total + (this.store.read(key)?.length ?? 0), 0) + extraChars <= OUTBOX_MAX_CHARS;
|
|
1332
|
+
}
|
|
1333
|
+
/** Store an entry (new or updated); `false` when the storage refused it (full or blocked). */
|
|
1334
|
+
put(entry) {
|
|
1335
|
+
const text = JSON.stringify(entry);
|
|
1336
|
+
this.store.write(this.prefix + entry.id, text);
|
|
1337
|
+
return this.store.read(this.prefix + entry.id) === text;
|
|
1338
|
+
}
|
|
1339
|
+
/** Forget an entry. */
|
|
1340
|
+
remove(id) {
|
|
1341
|
+
this.store.remove(this.prefix + id);
|
|
1342
|
+
}
|
|
1343
|
+
/** Whether a storage key belongs to this outbox (for cross-tab `storage` events). */
|
|
1344
|
+
owns(key) {
|
|
1345
|
+
return key === null || key.startsWith(this.prefix);
|
|
1346
|
+
}
|
|
1347
|
+
entryKeys() {
|
|
1348
|
+
return this.store.keys().filter((key) => key.startsWith(this.prefix)).sort();
|
|
1349
|
+
}
|
|
1350
|
+
};
|
|
1351
|
+
//#endregion
|
|
1352
|
+
//#region src/core/offline/replay-lease.ts
|
|
1353
|
+
/**
|
|
1354
|
+
* One replayer at a time per game, across tabs and across the client copies
|
|
1355
|
+
* on one page: a Web Lock where the browser has them, else a lease in
|
|
1356
|
+
* `localStorage` that its holder renews while it works and that lapses if
|
|
1357
|
+
* the tab dies. A replayer that can't get it simply doesn't run; the holder
|
|
1358
|
+
* replays for everyone.
|
|
1359
|
+
*/
|
|
1360
|
+
/** How long a `localStorage` lease lasts without renewal. */
|
|
1361
|
+
const STORAGE_LEASE_MS = 15e3;
|
|
1362
|
+
/** How often the holder renews it. */
|
|
1363
|
+
const RENEW_EVERY_MS = 5e3;
|
|
1364
|
+
/** A lease on the Web Locks API. */
|
|
1365
|
+
function webLockLease(locks, name) {
|
|
1366
|
+
return { hold: (work) => locks.request(name, { ifAvailable: true }, async (lock) => {
|
|
1367
|
+
if (lock === null) return false;
|
|
1368
|
+
await work();
|
|
1369
|
+
return true;
|
|
1370
|
+
}) };
|
|
1371
|
+
}
|
|
1372
|
+
/** The lease's holder and expiry, or `undefined` when there is none. */
|
|
1373
|
+
function readLease(text) {
|
|
1374
|
+
const stored = text === null ? void 0 : parseJsonText(text);
|
|
1375
|
+
if (!isRecord(stored)) return;
|
|
1376
|
+
const owner = optionalString(stored.owner);
|
|
1377
|
+
const until = optionalNumber(stored.until);
|
|
1378
|
+
return owner === void 0 || until === void 0 ? void 0 : {
|
|
1379
|
+
owner,
|
|
1380
|
+
until
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
/** A lease kept in `store` under `key`, for pages without Web Locks. */
|
|
1384
|
+
function storageLease(store, key, now = Date.now) {
|
|
1385
|
+
const owner = `${String(now())}-${Math.random().toString(36).slice(2)}`;
|
|
1386
|
+
const claim = () => {
|
|
1387
|
+
const current = readLease(store.read(key));
|
|
1388
|
+
if (current !== void 0 && current.owner !== owner && current.until > now()) return false;
|
|
1389
|
+
store.write(key, JSON.stringify({
|
|
1390
|
+
owner,
|
|
1391
|
+
until: now() + STORAGE_LEASE_MS
|
|
1392
|
+
}));
|
|
1393
|
+
return readLease(store.read(key))?.owner === owner;
|
|
1394
|
+
};
|
|
1395
|
+
return { async hold(work) {
|
|
1396
|
+
if (!claim()) return false;
|
|
1397
|
+
const renewal = setInterval(claim, RENEW_EVERY_MS);
|
|
1398
|
+
try {
|
|
1399
|
+
await work();
|
|
1400
|
+
} finally {
|
|
1401
|
+
clearInterval(renewal);
|
|
1402
|
+
if (readLease(store.read(key))?.owner === owner) store.remove(key);
|
|
1403
|
+
}
|
|
1404
|
+
return true;
|
|
1405
|
+
} };
|
|
1406
|
+
}
|
|
1407
|
+
/** The page's Web Locks, when it has them. */
|
|
1408
|
+
function pageLocks() {
|
|
1409
|
+
return typeof navigator !== "undefined" && "locks" in navigator ? navigator.locks : void 0;
|
|
1410
|
+
}
|
|
1411
|
+
/** The lease for game `slug`'s outbox: a Web Lock when available, else one in `store`. */
|
|
1412
|
+
function createReplayLease(slug, store) {
|
|
1413
|
+
const name = `hub:${slug}:outbox-lease`;
|
|
1414
|
+
const locks = pageLocks();
|
|
1415
|
+
return locks === void 0 ? storageLease(store, name) : webLockLease(locks, name);
|
|
1416
|
+
}
|
|
1417
|
+
//#endregion
|
|
1418
|
+
//#region src/core/offline/outbox.ts
|
|
1419
|
+
/** The code of the error `add` throws when the outbox is full. */
|
|
1420
|
+
const OUTBOX_FULL_CODE = "offline_queue_full";
|
|
1421
|
+
/** How long a replayer that lost the lease waits before checking again. */
|
|
1422
|
+
const LEASE_RECHECK_MS = 3e3;
|
|
1423
|
+
/** One game's outbox. */
|
|
1424
|
+
var Outbox = class {
|
|
1425
|
+
/** The outbox for `options.slug`. */
|
|
1426
|
+
constructor(options) {
|
|
1427
|
+
_defineProperty(this, "kinds", new KindRegistry());
|
|
1428
|
+
_defineProperty(this, "entries", void 0);
|
|
1429
|
+
_defineProperty(this, "options", void 0);
|
|
1430
|
+
_defineProperty(this, "replayer", void 0);
|
|
1431
|
+
_defineProperty(this, "now", void 0);
|
|
1432
|
+
_defineProperty(this, "sequence", 0);
|
|
1433
|
+
_defineProperty(this, "retryTimer", void 0);
|
|
1434
|
+
_defineProperty(this, "retryAt", Number.POSITIVE_INFINITY);
|
|
1435
|
+
_defineProperty(this, "listeners", {
|
|
1436
|
+
queued: /* @__PURE__ */ new Set(),
|
|
1437
|
+
replayed: /* @__PURE__ */ new Set(),
|
|
1438
|
+
failed: /* @__PURE__ */ new Set()
|
|
1439
|
+
});
|
|
1440
|
+
this.options = options;
|
|
1441
|
+
this.now = options.now ?? Date.now;
|
|
1442
|
+
this.entries = new OutboxStore(options.slug, options.store);
|
|
1443
|
+
this.replayer = new OutboxReplayer({
|
|
1444
|
+
store: this.entries,
|
|
1445
|
+
kinds: this.kinds,
|
|
1446
|
+
lease: createReplayLease(options.slug, options.store),
|
|
1447
|
+
contextFor: (entry, attempt) => this.contextFor(entry, attempt, true),
|
|
1448
|
+
onReplayed: (op, result) => {
|
|
1449
|
+
this.fire("replayed", result === void 0 ? { op } : {
|
|
1450
|
+
op,
|
|
1451
|
+
result
|
|
1452
|
+
});
|
|
1453
|
+
},
|
|
1454
|
+
onFailed: ({ entry, error }) => {
|
|
1455
|
+
this.fire("failed", {
|
|
1456
|
+
op: entry,
|
|
1457
|
+
error
|
|
1458
|
+
});
|
|
1459
|
+
},
|
|
1460
|
+
onUnreachable: options.onUnreachable,
|
|
1461
|
+
scheduleRetry: (delayMs) => {
|
|
1462
|
+
this.scheduleRetry(delayMs);
|
|
1463
|
+
},
|
|
1464
|
+
now: this.now
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
/** Register a kind. */
|
|
1468
|
+
define(kind, definition) {
|
|
1469
|
+
this.kinds.register(kind, definition);
|
|
1470
|
+
}
|
|
1471
|
+
/** A new, not yet stored, op of `kind`. */
|
|
1472
|
+
stamp(kind, payload) {
|
|
1473
|
+
this.sequence += 1;
|
|
1474
|
+
const now = this.now();
|
|
1475
|
+
return {
|
|
1476
|
+
id: newEntryId(now, this.sequence),
|
|
1477
|
+
kind,
|
|
1478
|
+
payload,
|
|
1479
|
+
createdAt: now,
|
|
1480
|
+
attempts: 0,
|
|
1481
|
+
retryAt: 0
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
/**
|
|
1485
|
+
* Queue an op, folding it into a waiting one where its kind says so.
|
|
1486
|
+
* Throws a {@link HubError} (code {@link OUTBOX_FULL_CODE}) when the
|
|
1487
|
+
* outbox is full or the storage refuses it.
|
|
1488
|
+
*/
|
|
1489
|
+
add(op) {
|
|
1490
|
+
const kind = this.kinds.get(op.kind);
|
|
1491
|
+
const plan = kind === void 0 ? {
|
|
1492
|
+
put: op,
|
|
1493
|
+
remove: [],
|
|
1494
|
+
isMerged: false
|
|
1495
|
+
} : planEnqueue(this.entries.list(), op, kind);
|
|
1496
|
+
const extra = plan.isMerged ? 0 : JSON.stringify(plan.put).length;
|
|
1497
|
+
if (plan.remove.length === 0 && !plan.isMerged && !this.entries.fits(extra)) throw outboxFull();
|
|
1498
|
+
if (!this.entries.put(plan.put)) throw outboxFull();
|
|
1499
|
+
for (const id of plan.remove) this.entries.remove(id);
|
|
1500
|
+
this.fire("queued", { op: plan.put });
|
|
1501
|
+
this.options.onChange();
|
|
1502
|
+
}
|
|
1503
|
+
/** Perform an op now, without queueing it (the online path); throws what its handler throws. */
|
|
1504
|
+
async runNow(op) {
|
|
1505
|
+
const kind = this.kinds.get(op.kind);
|
|
1506
|
+
if (kind === void 0) throw new HubError(`No offline kind "${op.kind}" is defined.`);
|
|
1507
|
+
return await kind.run(op.payload, this.contextFor(op, 1, false)) ?? void 0;
|
|
1508
|
+
}
|
|
1509
|
+
/** Replay what is due, in order. `isEager`: the connection just came back, so ops waiting for it go at once. */
|
|
1510
|
+
async replay(isEager = false) {
|
|
1511
|
+
this.clearRetry();
|
|
1512
|
+
const report = await this.replayer.replay(isEager);
|
|
1513
|
+
if (report.stoppedBy === "lease" && this.entries.count() > 0 || report.stoppedBy === "unknown-kind") this.scheduleRetry(report.stoppedBy === "lease" ? LEASE_RECHECK_MS : backoffMs(this.entries.list()[0]?.attempts ?? 1));
|
|
1514
|
+
this.options.onChange();
|
|
1515
|
+
return report;
|
|
1516
|
+
}
|
|
1517
|
+
/** The waiting ops, oldest first. */
|
|
1518
|
+
pending() {
|
|
1519
|
+
return this.entries.list();
|
|
1520
|
+
}
|
|
1521
|
+
/** How many ops are waiting. */
|
|
1522
|
+
count() {
|
|
1523
|
+
return this.entries.count();
|
|
1524
|
+
}
|
|
1525
|
+
/** Listen for an event; returns the unsubscribe function. */
|
|
1526
|
+
on(name, listener) {
|
|
1527
|
+
const listeners = this.listeners[name];
|
|
1528
|
+
listeners.add(listener);
|
|
1529
|
+
return () => {
|
|
1530
|
+
listeners.delete(listener);
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
fire(name, detail) {
|
|
1534
|
+
const listeners = this.listeners[name];
|
|
1535
|
+
for (const listener of listeners) try {
|
|
1536
|
+
listener(detail);
|
|
1537
|
+
} catch {}
|
|
1538
|
+
this.options.emit(`hub:offline-${name}`, detail);
|
|
1539
|
+
}
|
|
1540
|
+
contextFor(op, attempt, isReplay) {
|
|
1541
|
+
const { rest } = this.options;
|
|
1542
|
+
let step = 0;
|
|
1543
|
+
return {
|
|
1544
|
+
opId: op.id,
|
|
1545
|
+
attempt,
|
|
1546
|
+
isReplay,
|
|
1547
|
+
key: (stepName) => `${op.id}.${stepName}`,
|
|
1548
|
+
call: (name, ...fieldsArgument) => {
|
|
1549
|
+
const opKey = `${op.id}.${String(step)}`;
|
|
1550
|
+
step += 1;
|
|
1551
|
+
return rest.callOnce(opKey, name, ...fieldsArgument);
|
|
1552
|
+
}
|
|
1553
|
+
};
|
|
1554
|
+
}
|
|
1555
|
+
scheduleRetry(delayMs) {
|
|
1556
|
+
const at = this.now() + delayMs;
|
|
1557
|
+
if (at >= this.retryAt) return;
|
|
1558
|
+
this.clearRetry();
|
|
1559
|
+
this.retryAt = at;
|
|
1560
|
+
this.retryTimer = setTimeout(() => {
|
|
1561
|
+
this.retryTimer = void 0;
|
|
1562
|
+
this.retryAt = Number.POSITIVE_INFINITY;
|
|
1563
|
+
this.options.onRetryDue();
|
|
1564
|
+
}, delayMs);
|
|
1565
|
+
}
|
|
1566
|
+
clearRetry() {
|
|
1567
|
+
if (this.retryTimer !== void 0) {
|
|
1568
|
+
clearTimeout(this.retryTimer);
|
|
1569
|
+
this.retryTimer = void 0;
|
|
1570
|
+
}
|
|
1571
|
+
this.retryAt = Number.POSITIVE_INFINITY;
|
|
1572
|
+
}
|
|
1573
|
+
};
|
|
1574
|
+
/** The error for a full outbox. */
|
|
1575
|
+
function outboxFull() {
|
|
1576
|
+
return new HubError("Too many changes are waiting to sync. Reconnect to save more.", 507, { code: OUTBOX_FULL_CODE });
|
|
1577
|
+
}
|
|
1578
|
+
//#endregion
|
|
577
1579
|
//#region src/core/page.ts
|
|
578
1580
|
/** Whether this code runs in a browser page. */
|
|
579
1581
|
function isBrowserPage() {
|
|
@@ -614,427 +1616,547 @@ function emitWindowEvent(eventName, detail) {
|
|
|
614
1616
|
}
|
|
615
1617
|
//#endregion
|
|
616
1618
|
//#region src/core/rest/routes.gen.ts
|
|
617
|
-
/** Every public REST endpoint's method, path (under `/_api`) and
|
|
1619
|
+
/** Every public REST endpoint's method, path (under `/_api`), query keys and offline mode (`'replay'` when it may be queued offline), from the contract. */
|
|
618
1620
|
const ROUTES = {
|
|
619
1621
|
ensureGuestSession: {
|
|
620
1622
|
method: "POST",
|
|
621
1623
|
path: "/auth/guest",
|
|
622
|
-
query: []
|
|
1624
|
+
query: [],
|
|
1625
|
+
offline: null
|
|
623
1626
|
},
|
|
624
1627
|
signUpWithPassword: {
|
|
625
1628
|
method: "POST",
|
|
626
1629
|
path: "/auth/signup",
|
|
627
|
-
query: []
|
|
1630
|
+
query: [],
|
|
1631
|
+
offline: null
|
|
628
1632
|
},
|
|
629
1633
|
logInWithPassword: {
|
|
630
1634
|
method: "POST",
|
|
631
1635
|
path: "/auth/login",
|
|
632
|
-
query: []
|
|
1636
|
+
query: [],
|
|
1637
|
+
offline: null
|
|
633
1638
|
},
|
|
634
1639
|
logOut: {
|
|
635
1640
|
method: "POST",
|
|
636
1641
|
path: "/auth/logout",
|
|
637
|
-
query: []
|
|
1642
|
+
query: [],
|
|
1643
|
+
offline: null
|
|
638
1644
|
},
|
|
639
1645
|
getCurrentUser: {
|
|
640
1646
|
method: "GET",
|
|
641
1647
|
path: "/me",
|
|
642
|
-
query: []
|
|
1648
|
+
query: [],
|
|
1649
|
+
offline: null
|
|
643
1650
|
},
|
|
644
1651
|
claimGuestAccount: {
|
|
645
1652
|
method: "POST",
|
|
646
1653
|
path: "/auth/claim",
|
|
647
|
-
query: []
|
|
1654
|
+
query: [],
|
|
1655
|
+
offline: null
|
|
1656
|
+
},
|
|
1657
|
+
getGamePlayerCounts: {
|
|
1658
|
+
method: "GET",
|
|
1659
|
+
path: "/catalog/players",
|
|
1660
|
+
query: [],
|
|
1661
|
+
offline: null
|
|
1662
|
+
},
|
|
1663
|
+
getMyGameActivity: {
|
|
1664
|
+
method: "GET",
|
|
1665
|
+
path: "/games/:slug/me/activity",
|
|
1666
|
+
query: [],
|
|
1667
|
+
offline: null
|
|
648
1668
|
},
|
|
649
1669
|
listGames: {
|
|
650
1670
|
method: "GET",
|
|
651
1671
|
path: "/games",
|
|
652
|
-
query: []
|
|
1672
|
+
query: [],
|
|
1673
|
+
offline: null
|
|
653
1674
|
},
|
|
654
1675
|
listWhatsNewNotes: {
|
|
655
1676
|
method: "GET",
|
|
656
1677
|
path: "/changes",
|
|
657
|
-
query: ["game", "since"]
|
|
1678
|
+
query: ["game", "since"],
|
|
1679
|
+
offline: null
|
|
658
1680
|
},
|
|
659
1681
|
listFavoriteGames: {
|
|
660
1682
|
method: "GET",
|
|
661
1683
|
path: "/favorites",
|
|
662
|
-
query: []
|
|
1684
|
+
query: [],
|
|
1685
|
+
offline: null
|
|
663
1686
|
},
|
|
664
1687
|
setGameFavorite: {
|
|
665
1688
|
method: "PUT",
|
|
666
1689
|
path: "/favorites/:slug",
|
|
667
|
-
query: []
|
|
1690
|
+
query: [],
|
|
1691
|
+
offline: "replay"
|
|
668
1692
|
},
|
|
669
1693
|
markWhatsNewSeen: {
|
|
670
1694
|
method: "POST",
|
|
671
1695
|
path: "/changes/seen",
|
|
672
|
-
query: []
|
|
1696
|
+
query: [],
|
|
1697
|
+
offline: null
|
|
673
1698
|
},
|
|
674
1699
|
getGameStats: {
|
|
675
1700
|
method: "GET",
|
|
676
1701
|
path: "/games/:slug/stats",
|
|
677
|
-
query: ["user"]
|
|
1702
|
+
query: ["user"],
|
|
1703
|
+
offline: null
|
|
678
1704
|
},
|
|
679
1705
|
getGameProfile: {
|
|
680
1706
|
method: "GET",
|
|
681
1707
|
path: "/games/:slug/profile",
|
|
682
|
-
query: []
|
|
1708
|
+
query: [],
|
|
1709
|
+
offline: null
|
|
683
1710
|
},
|
|
684
1711
|
setGameProfile: {
|
|
685
1712
|
method: "PUT",
|
|
686
1713
|
path: "/games/:slug/profile",
|
|
687
|
-
query: []
|
|
1714
|
+
query: [],
|
|
1715
|
+
offline: "replay"
|
|
688
1716
|
},
|
|
689
1717
|
listSaves: {
|
|
690
1718
|
method: "GET",
|
|
691
1719
|
path: "/games/:slug/saves",
|
|
692
|
-
query: []
|
|
1720
|
+
query: [],
|
|
1721
|
+
offline: null
|
|
693
1722
|
},
|
|
694
1723
|
getSave: {
|
|
695
1724
|
method: "GET",
|
|
696
1725
|
path: "/games/:slug/saves/:slot",
|
|
697
|
-
query: []
|
|
1726
|
+
query: [],
|
|
1727
|
+
offline: null
|
|
698
1728
|
},
|
|
699
1729
|
writeSave: {
|
|
700
1730
|
method: "PUT",
|
|
701
1731
|
path: "/games/:slug/saves/:slot",
|
|
702
|
-
query: []
|
|
1732
|
+
query: [],
|
|
1733
|
+
offline: "replay"
|
|
703
1734
|
},
|
|
704
1735
|
deleteSave: {
|
|
705
1736
|
method: "DELETE",
|
|
706
1737
|
path: "/games/:slug/saves/:slot",
|
|
707
|
-
query: []
|
|
1738
|
+
query: [],
|
|
1739
|
+
offline: "replay"
|
|
708
1740
|
},
|
|
709
1741
|
updateGameStat: {
|
|
710
1742
|
method: "POST",
|
|
711
1743
|
path: "/games/:slug/stats",
|
|
712
|
-
query: []
|
|
1744
|
+
query: [],
|
|
1745
|
+
offline: "replay"
|
|
713
1746
|
},
|
|
714
1747
|
resetMyGameProgress: {
|
|
715
1748
|
method: "POST",
|
|
716
1749
|
path: "/games/:slug/reset",
|
|
717
|
-
query: []
|
|
1750
|
+
query: [],
|
|
1751
|
+
offline: null
|
|
718
1752
|
},
|
|
719
1753
|
listMyLeaderboardRecords: {
|
|
720
1754
|
method: "GET",
|
|
721
1755
|
path: "/me/records",
|
|
722
|
-
query: ["game"]
|
|
1756
|
+
query: ["game"],
|
|
1757
|
+
offline: null
|
|
723
1758
|
},
|
|
724
1759
|
getMyOfflineSnapshot: {
|
|
725
1760
|
method: "GET",
|
|
726
1761
|
path: "/me/snapshot",
|
|
727
|
-
query: ["top"]
|
|
1762
|
+
query: ["top"],
|
|
1763
|
+
offline: null
|
|
728
1764
|
},
|
|
729
1765
|
listTopOfEveryLeaderboard: {
|
|
730
1766
|
method: "GET",
|
|
731
1767
|
path: "/leaderboards",
|
|
732
|
-
query: ["top"]
|
|
1768
|
+
query: ["top"],
|
|
1769
|
+
offline: null
|
|
733
1770
|
},
|
|
734
1771
|
listGameLeaderboards: {
|
|
735
1772
|
method: "GET",
|
|
736
1773
|
path: "/games/:slug/leaderboards",
|
|
737
|
-
query: []
|
|
1774
|
+
query: [],
|
|
1775
|
+
offline: null
|
|
738
1776
|
},
|
|
739
1777
|
getLeaderboard: {
|
|
740
1778
|
method: "GET",
|
|
741
1779
|
path: "/games/:slug/leaderboards/:key",
|
|
742
|
-
query: ["limit"]
|
|
1780
|
+
query: ["limit"],
|
|
1781
|
+
offline: null
|
|
743
1782
|
},
|
|
744
1783
|
submitScore: {
|
|
745
1784
|
method: "POST",
|
|
746
1785
|
path: "/games/:slug/leaderboards/:key/scores",
|
|
747
|
-
query: []
|
|
1786
|
+
query: [],
|
|
1787
|
+
offline: "replay"
|
|
748
1788
|
},
|
|
749
1789
|
listDailyChallengesForAllGames: {
|
|
750
1790
|
method: "GET",
|
|
751
1791
|
path: "/daily",
|
|
752
|
-
query: []
|
|
1792
|
+
query: [],
|
|
1793
|
+
offline: null
|
|
753
1794
|
},
|
|
754
1795
|
getGameDailyChallenges: {
|
|
755
1796
|
method: "GET",
|
|
756
1797
|
path: "/games/:slug/daily",
|
|
757
|
-
query: []
|
|
1798
|
+
query: [],
|
|
1799
|
+
offline: null
|
|
758
1800
|
},
|
|
759
1801
|
startDailyChallenge: {
|
|
760
1802
|
method: "POST",
|
|
761
1803
|
path: "/games/:slug/daily/:day/start",
|
|
762
|
-
query: []
|
|
1804
|
+
query: [],
|
|
1805
|
+
offline: null
|
|
763
1806
|
},
|
|
764
1807
|
completeDailyChallenge: {
|
|
765
1808
|
method: "POST",
|
|
766
1809
|
path: "/games/:slug/daily/:day/complete",
|
|
767
|
-
query: []
|
|
1810
|
+
query: [],
|
|
1811
|
+
offline: "replay"
|
|
768
1812
|
},
|
|
769
1813
|
getMyInventory: {
|
|
770
1814
|
method: "GET",
|
|
771
1815
|
path: "/games/:slug/inventory",
|
|
772
|
-
query: []
|
|
1816
|
+
query: [],
|
|
1817
|
+
offline: null
|
|
773
1818
|
},
|
|
774
1819
|
grantInventoryItems: {
|
|
775
1820
|
method: "POST",
|
|
776
1821
|
path: "/games/:slug/inventory/grant",
|
|
777
|
-
query: []
|
|
1822
|
+
query: [],
|
|
1823
|
+
offline: "replay"
|
|
778
1824
|
},
|
|
779
1825
|
exchangeInventoryItems: {
|
|
780
1826
|
method: "POST",
|
|
781
1827
|
path: "/games/:slug/inventory/exchange",
|
|
782
|
-
query: []
|
|
1828
|
+
query: [],
|
|
1829
|
+
offline: "replay"
|
|
783
1830
|
},
|
|
784
1831
|
getPlayerInventory: {
|
|
785
1832
|
method: "GET",
|
|
786
1833
|
path: "/games/:slug/players/:userId/inventory",
|
|
787
|
-
query: []
|
|
1834
|
+
query: [],
|
|
1835
|
+
offline: null
|
|
788
1836
|
},
|
|
789
1837
|
listTradePartners: {
|
|
790
1838
|
method: "GET",
|
|
791
1839
|
path: "/games/:slug/trades/players",
|
|
792
|
-
query: []
|
|
1840
|
+
query: [],
|
|
1841
|
+
offline: null
|
|
793
1842
|
},
|
|
794
1843
|
listMyTrades: {
|
|
795
1844
|
method: "GET",
|
|
796
1845
|
path: "/games/:slug/trades",
|
|
797
|
-
query: ["box", "status"]
|
|
1846
|
+
query: ["box", "status"],
|
|
1847
|
+
offline: null
|
|
798
1848
|
},
|
|
799
1849
|
declineTrade: {
|
|
800
1850
|
method: "POST",
|
|
801
1851
|
path: "/games/:slug/trades/:id/decline",
|
|
802
|
-
query: []
|
|
1852
|
+
query: [],
|
|
1853
|
+
offline: null
|
|
803
1854
|
},
|
|
804
1855
|
cancelTrade: {
|
|
805
1856
|
method: "POST",
|
|
806
1857
|
path: "/games/:slug/trades/:id/cancel",
|
|
807
|
-
query: []
|
|
1858
|
+
query: [],
|
|
1859
|
+
offline: null
|
|
808
1860
|
},
|
|
809
1861
|
createTrade: {
|
|
810
1862
|
method: "POST",
|
|
811
1863
|
path: "/games/:slug/trades",
|
|
812
|
-
query: []
|
|
1864
|
+
query: [],
|
|
1865
|
+
offline: null
|
|
813
1866
|
},
|
|
814
1867
|
acceptTrade: {
|
|
815
1868
|
method: "POST",
|
|
816
1869
|
path: "/games/:slug/trades/:id/accept",
|
|
817
|
-
query: []
|
|
1870
|
+
query: [],
|
|
1871
|
+
offline: null
|
|
818
1872
|
},
|
|
819
1873
|
counterTradeOffer: {
|
|
820
1874
|
method: "POST",
|
|
821
1875
|
path: "/games/:slug/trades/:id/counter",
|
|
822
|
-
query: []
|
|
1876
|
+
query: [],
|
|
1877
|
+
offline: null
|
|
823
1878
|
},
|
|
824
1879
|
getSiteAnalytics: {
|
|
825
1880
|
method: "GET",
|
|
826
1881
|
path: "/analytics",
|
|
827
|
-
query: []
|
|
1882
|
+
query: [],
|
|
1883
|
+
offline: null
|
|
828
1884
|
},
|
|
829
1885
|
reportClientError: {
|
|
830
1886
|
method: "POST",
|
|
831
1887
|
path: "/client-log",
|
|
832
|
-
query: []
|
|
1888
|
+
query: [],
|
|
1889
|
+
offline: null
|
|
833
1890
|
},
|
|
834
1891
|
recordGameEvent: {
|
|
835
1892
|
method: "POST",
|
|
836
1893
|
path: "/games/:slug/events",
|
|
837
|
-
query: []
|
|
1894
|
+
query: [],
|
|
1895
|
+
offline: null
|
|
838
1896
|
},
|
|
839
1897
|
getGameAnalytics: {
|
|
840
1898
|
method: "GET",
|
|
841
1899
|
path: "/games/:slug/analytics",
|
|
842
|
-
query: []
|
|
1900
|
+
query: [],
|
|
1901
|
+
offline: null
|
|
843
1902
|
},
|
|
844
1903
|
getSocialConfig: {
|
|
845
1904
|
method: "GET",
|
|
846
1905
|
path: "/social/config",
|
|
847
|
-
query: []
|
|
1906
|
+
query: [],
|
|
1907
|
+
offline: null
|
|
848
1908
|
},
|
|
849
1909
|
getOnlineCounts: {
|
|
850
1910
|
method: "GET",
|
|
851
1911
|
path: "/social/counts",
|
|
852
|
-
query: []
|
|
1912
|
+
query: [],
|
|
1913
|
+
offline: null
|
|
853
1914
|
},
|
|
854
1915
|
getMySocialProfile: {
|
|
855
1916
|
method: "GET",
|
|
856
1917
|
path: "/social/me",
|
|
857
|
-
query: []
|
|
1918
|
+
query: [],
|
|
1919
|
+
offline: null
|
|
858
1920
|
},
|
|
859
1921
|
listFriendsAndRequests: {
|
|
860
1922
|
method: "GET",
|
|
861
1923
|
path: "/social/friends",
|
|
862
|
-
query: []
|
|
1924
|
+
query: [],
|
|
1925
|
+
offline: null
|
|
863
1926
|
},
|
|
864
1927
|
findPlayerByName: {
|
|
865
1928
|
method: "GET",
|
|
866
1929
|
path: "/social/players/by-name/:name",
|
|
867
|
-
query: []
|
|
1930
|
+
query: [],
|
|
1931
|
+
offline: null
|
|
868
1932
|
},
|
|
869
1933
|
getPlayerCard: {
|
|
870
1934
|
method: "GET",
|
|
871
1935
|
path: "/social/players/:id",
|
|
872
|
-
query: []
|
|
1936
|
+
query: [],
|
|
1937
|
+
offline: null
|
|
873
1938
|
},
|
|
874
1939
|
updateMySocialProfile: {
|
|
875
1940
|
method: "PUT",
|
|
876
1941
|
path: "/social/me",
|
|
877
|
-
query: []
|
|
1942
|
+
query: [],
|
|
1943
|
+
offline: null
|
|
878
1944
|
},
|
|
879
1945
|
setParentChatLock: {
|
|
880
1946
|
method: "POST",
|
|
881
1947
|
path: "/social/chat-lock",
|
|
882
|
-
query: []
|
|
1948
|
+
query: [],
|
|
1949
|
+
offline: null
|
|
883
1950
|
},
|
|
884
1951
|
sendFriendRequest: {
|
|
885
1952
|
method: "POST",
|
|
886
1953
|
path: "/social/friends",
|
|
887
|
-
query: []
|
|
1954
|
+
query: [],
|
|
1955
|
+
offline: null
|
|
888
1956
|
},
|
|
889
1957
|
acceptFriendRequest: {
|
|
890
1958
|
method: "POST",
|
|
891
1959
|
path: "/social/friends/:id/accept",
|
|
892
|
-
query: []
|
|
1960
|
+
query: [],
|
|
1961
|
+
offline: null
|
|
893
1962
|
},
|
|
894
1963
|
declineFriendRequest: {
|
|
895
1964
|
method: "POST",
|
|
896
1965
|
path: "/social/friends/:id/decline",
|
|
897
|
-
query: []
|
|
1966
|
+
query: [],
|
|
1967
|
+
offline: null
|
|
898
1968
|
},
|
|
899
1969
|
removeFriend: {
|
|
900
1970
|
method: "DELETE",
|
|
901
1971
|
path: "/social/friends/:id",
|
|
902
|
-
query: []
|
|
1972
|
+
query: [],
|
|
1973
|
+
offline: null
|
|
903
1974
|
},
|
|
904
1975
|
setFriendNotifications: {
|
|
905
1976
|
method: "PUT",
|
|
906
1977
|
path: "/social/friends/:id/notify",
|
|
907
|
-
query: []
|
|
1978
|
+
query: [],
|
|
1979
|
+
offline: null
|
|
908
1980
|
},
|
|
909
1981
|
blockPlayer: {
|
|
910
1982
|
method: "POST",
|
|
911
1983
|
path: "/social/blocks",
|
|
912
|
-
query: []
|
|
1984
|
+
query: [],
|
|
1985
|
+
offline: null
|
|
913
1986
|
},
|
|
914
1987
|
unblockPlayer: {
|
|
915
1988
|
method: "DELETE",
|
|
916
1989
|
path: "/social/blocks/:id",
|
|
917
|
-
query: []
|
|
1990
|
+
query: [],
|
|
1991
|
+
offline: null
|
|
918
1992
|
},
|
|
919
1993
|
listRecentPlayers: {
|
|
920
1994
|
method: "GET",
|
|
921
1995
|
path: "/social/recent",
|
|
922
|
-
query: []
|
|
1996
|
+
query: [],
|
|
1997
|
+
offline: null
|
|
923
1998
|
},
|
|
924
1999
|
listConversations: {
|
|
925
2000
|
method: "GET",
|
|
926
2001
|
path: "/social/conversations",
|
|
927
|
-
query: []
|
|
2002
|
+
query: [],
|
|
2003
|
+
offline: null
|
|
928
2004
|
},
|
|
929
2005
|
listDirectMessages: {
|
|
930
2006
|
method: "GET",
|
|
931
2007
|
path: "/social/messages/:userId",
|
|
932
|
-
query: ["before"]
|
|
2008
|
+
query: ["before"],
|
|
2009
|
+
offline: null
|
|
933
2010
|
},
|
|
934
2011
|
sendDirectMessage: {
|
|
935
2012
|
method: "POST",
|
|
936
2013
|
path: "/social/messages/:userId",
|
|
937
|
-
query: []
|
|
2014
|
+
query: [],
|
|
2015
|
+
offline: null
|
|
938
2016
|
},
|
|
939
2017
|
markDirectMessagesRead: {
|
|
940
2018
|
method: "POST",
|
|
941
2019
|
path: "/social/messages/:userId/read",
|
|
942
|
-
query: []
|
|
2020
|
+
query: [],
|
|
2021
|
+
offline: null
|
|
943
2022
|
},
|
|
944
2023
|
getNotificationPreferences: {
|
|
945
2024
|
method: "GET",
|
|
946
2025
|
path: "/social/notify",
|
|
947
|
-
query: []
|
|
2026
|
+
query: [],
|
|
2027
|
+
offline: null
|
|
948
2028
|
},
|
|
949
2029
|
unsubscribeFromPush: {
|
|
950
2030
|
method: "DELETE",
|
|
951
2031
|
path: "/social/push",
|
|
952
|
-
query: []
|
|
2032
|
+
query: [],
|
|
2033
|
+
offline: null
|
|
953
2034
|
},
|
|
954
2035
|
setNotificationPreferences: {
|
|
955
2036
|
method: "PUT",
|
|
956
2037
|
path: "/social/notify",
|
|
957
|
-
query: []
|
|
2038
|
+
query: [],
|
|
2039
|
+
offline: null
|
|
958
2040
|
},
|
|
959
2041
|
subscribeToPush: {
|
|
960
2042
|
method: "POST",
|
|
961
2043
|
path: "/social/push",
|
|
962
|
-
query: []
|
|
2044
|
+
query: [],
|
|
2045
|
+
offline: null
|
|
963
2046
|
},
|
|
964
2047
|
fileReport: {
|
|
965
2048
|
method: "POST",
|
|
966
2049
|
path: "/social/reports",
|
|
967
|
-
query: []
|
|
2050
|
+
query: [],
|
|
2051
|
+
offline: null
|
|
968
2052
|
},
|
|
969
2053
|
listPublicSuggestions: {
|
|
970
2054
|
method: "GET",
|
|
971
2055
|
path: "/suggestions",
|
|
972
|
-
query: ["sort"]
|
|
2056
|
+
query: ["sort"],
|
|
2057
|
+
offline: null
|
|
973
2058
|
},
|
|
974
2059
|
submitSuggestion: {
|
|
975
2060
|
method: "POST",
|
|
976
2061
|
path: "/suggestions",
|
|
977
|
-
query: []
|
|
2062
|
+
query: [],
|
|
2063
|
+
offline: null
|
|
978
2064
|
},
|
|
979
2065
|
voteForSuggestion: {
|
|
980
2066
|
method: "POST",
|
|
981
2067
|
path: "/suggestions/:id/vote",
|
|
982
|
-
query: []
|
|
2068
|
+
query: [],
|
|
2069
|
+
offline: null
|
|
983
2070
|
},
|
|
984
2071
|
removeSuggestionVote: {
|
|
985
2072
|
method: "DELETE",
|
|
986
2073
|
path: "/suggestions/:id/vote",
|
|
987
|
-
query: []
|
|
2074
|
+
query: [],
|
|
2075
|
+
offline: null
|
|
988
2076
|
},
|
|
989
2077
|
withdrawSuggestion: {
|
|
990
2078
|
method: "DELETE",
|
|
991
2079
|
path: "/suggestions/:id",
|
|
992
|
-
query: []
|
|
2080
|
+
query: [],
|
|
2081
|
+
offline: null
|
|
993
2082
|
},
|
|
994
2083
|
sendGameInvite: {
|
|
995
2084
|
method: "POST",
|
|
996
2085
|
path: "/mp/invites",
|
|
997
|
-
query: []
|
|
2086
|
+
query: [],
|
|
2087
|
+
offline: null
|
|
998
2088
|
},
|
|
999
2089
|
acceptGameInvite: {
|
|
1000
2090
|
method: "POST",
|
|
1001
2091
|
path: "/mp/invites/:id/accept",
|
|
1002
|
-
query: []
|
|
2092
|
+
query: [],
|
|
2093
|
+
offline: null
|
|
1003
2094
|
},
|
|
1004
2095
|
declineGameInvite: {
|
|
1005
2096
|
method: "POST",
|
|
1006
2097
|
path: "/mp/invites/:id/decline",
|
|
1007
|
-
query: []
|
|
2098
|
+
query: [],
|
|
2099
|
+
offline: null
|
|
2100
|
+
},
|
|
2101
|
+
createInviteLink: {
|
|
2102
|
+
method: "POST",
|
|
2103
|
+
path: "/mp/links",
|
|
2104
|
+
query: [],
|
|
2105
|
+
offline: null
|
|
2106
|
+
},
|
|
2107
|
+
listInviteLinks: {
|
|
2108
|
+
method: "GET",
|
|
2109
|
+
path: "/mp/links",
|
|
2110
|
+
query: ["game"],
|
|
2111
|
+
offline: null
|
|
2112
|
+
},
|
|
2113
|
+
revokeInviteLink: {
|
|
2114
|
+
method: "DELETE",
|
|
2115
|
+
path: "/mp/links/:token",
|
|
2116
|
+
query: [],
|
|
2117
|
+
offline: null
|
|
1008
2118
|
},
|
|
1009
2119
|
cancelGameInvite: {
|
|
1010
2120
|
method: "DELETE",
|
|
1011
2121
|
path: "/mp/invites/:id",
|
|
1012
|
-
query: []
|
|
2122
|
+
query: [],
|
|
2123
|
+
offline: null
|
|
1013
2124
|
},
|
|
1014
2125
|
redeemLaunchToken: {
|
|
1015
2126
|
method: "GET",
|
|
1016
2127
|
path: "/mp/launch/:token",
|
|
1017
|
-
query: []
|
|
2128
|
+
query: [],
|
|
2129
|
+
offline: null
|
|
2130
|
+
},
|
|
2131
|
+
openInviteLink: {
|
|
2132
|
+
method: "POST",
|
|
2133
|
+
path: "/mp/links/:token/open",
|
|
2134
|
+
query: [],
|
|
2135
|
+
offline: null
|
|
1018
2136
|
},
|
|
1019
2137
|
setPartyJoinInfo: {
|
|
1020
2138
|
method: "POST",
|
|
1021
2139
|
path: "/mp/parties/:id/join-info",
|
|
1022
|
-
query: []
|
|
2140
|
+
query: [],
|
|
2141
|
+
offline: null
|
|
1023
2142
|
},
|
|
1024
2143
|
leaveParty: {
|
|
1025
2144
|
method: "POST",
|
|
1026
2145
|
path: "/mp/parties/:id/leave",
|
|
1027
|
-
query: []
|
|
2146
|
+
query: [],
|
|
2147
|
+
offline: null
|
|
1028
2148
|
},
|
|
1029
2149
|
launchIntoFriendsRoom: {
|
|
1030
2150
|
method: "POST",
|
|
1031
2151
|
path: "/mp/join",
|
|
1032
|
-
query: []
|
|
2152
|
+
query: [],
|
|
2153
|
+
offline: null
|
|
1033
2154
|
},
|
|
1034
2155
|
getGameServerTicket: {
|
|
1035
2156
|
method: "POST",
|
|
1036
2157
|
path: "/mp/ticket",
|
|
1037
|
-
query: []
|
|
2158
|
+
query: [],
|
|
2159
|
+
offline: null
|
|
1038
2160
|
},
|
|
1039
2161
|
listMyRaceHistory: {
|
|
1040
2162
|
method: "GET",
|
|
@@ -1044,14 +2166,90 @@ const ROUTES = {
|
|
|
1044
2166
|
"with",
|
|
1045
2167
|
"before",
|
|
1046
2168
|
"limit"
|
|
1047
|
-
]
|
|
2169
|
+
],
|
|
2170
|
+
offline: null
|
|
2171
|
+
},
|
|
2172
|
+
listTournaments: {
|
|
2173
|
+
method: "GET",
|
|
2174
|
+
path: "/tournaments",
|
|
2175
|
+
query: ["game"],
|
|
2176
|
+
offline: null
|
|
2177
|
+
},
|
|
2178
|
+
getTournament: {
|
|
2179
|
+
method: "GET",
|
|
2180
|
+
path: "/tournaments/:id",
|
|
2181
|
+
query: ["key"],
|
|
2182
|
+
offline: null
|
|
2183
|
+
},
|
|
2184
|
+
createTournament: {
|
|
2185
|
+
method: "POST",
|
|
2186
|
+
path: "/tournaments",
|
|
2187
|
+
query: [],
|
|
2188
|
+
offline: null
|
|
2189
|
+
},
|
|
2190
|
+
inviteToTournament: {
|
|
2191
|
+
method: "POST",
|
|
2192
|
+
path: "/tournaments/:id/invites",
|
|
2193
|
+
query: [],
|
|
2194
|
+
offline: null
|
|
2195
|
+
},
|
|
2196
|
+
followTournament: {
|
|
2197
|
+
method: "PUT",
|
|
2198
|
+
path: "/tournaments/:id/follow",
|
|
2199
|
+
query: [],
|
|
2200
|
+
offline: null
|
|
2201
|
+
},
|
|
2202
|
+
unfollowTournament: {
|
|
2203
|
+
method: "DELETE",
|
|
2204
|
+
path: "/tournaments/:id/follow",
|
|
2205
|
+
query: [],
|
|
2206
|
+
offline: null
|
|
2207
|
+
},
|
|
2208
|
+
startTournament: {
|
|
2209
|
+
method: "POST",
|
|
2210
|
+
path: "/tournaments/:id/start",
|
|
2211
|
+
query: [],
|
|
2212
|
+
offline: null
|
|
2213
|
+
},
|
|
2214
|
+
cancelTournament: {
|
|
2215
|
+
method: "DELETE",
|
|
2216
|
+
path: "/tournaments/:id",
|
|
2217
|
+
query: [],
|
|
2218
|
+
offline: null
|
|
2219
|
+
},
|
|
2220
|
+
joinTournament: {
|
|
2221
|
+
method: "POST",
|
|
2222
|
+
path: "/tournaments/:id/join",
|
|
2223
|
+
query: [],
|
|
2224
|
+
offline: null
|
|
2225
|
+
},
|
|
2226
|
+
leaveTournament: {
|
|
2227
|
+
method: "POST",
|
|
2228
|
+
path: "/tournaments/:id/leave",
|
|
2229
|
+
query: [],
|
|
2230
|
+
offline: null
|
|
2231
|
+
},
|
|
2232
|
+
launchTournamentMatch: {
|
|
2233
|
+
method: "POST",
|
|
2234
|
+
path: "/tournaments/:id/matches/:match/launch",
|
|
2235
|
+
query: [],
|
|
2236
|
+
offline: null
|
|
2237
|
+
},
|
|
2238
|
+
getPlayOnline: {
|
|
2239
|
+
method: "GET",
|
|
2240
|
+
path: "/play-online",
|
|
2241
|
+
query: [],
|
|
2242
|
+
offline: null
|
|
1048
2243
|
},
|
|
1049
2244
|
getHubVersion: {
|
|
1050
2245
|
method: "GET",
|
|
1051
2246
|
path: "/version",
|
|
1052
|
-
query: []
|
|
2247
|
+
query: [],
|
|
2248
|
+
offline: null
|
|
1053
2249
|
}
|
|
1054
2250
|
};
|
|
2251
|
+
/** The request header that carries an offline operation key (the contract's `OFFLINE_OP_HEADER`). */
|
|
2252
|
+
const OFFLINE_OP_HEADER = "x-hub-op";
|
|
1055
2253
|
//#endregion
|
|
1056
2254
|
//#region src/core/rest/rest-client.ts
|
|
1057
2255
|
/**
|
|
@@ -1122,7 +2320,7 @@ async function failureOf(name, response) {
|
|
|
1122
2320
|
}
|
|
1123
2321
|
/** A REST client for one API base. */
|
|
1124
2322
|
function createRestClient(options) {
|
|
1125
|
-
async function send(name, fields) {
|
|
2323
|
+
async function send(name, fields, opKey) {
|
|
1126
2324
|
const planned = planRequest(name, fields);
|
|
1127
2325
|
let response;
|
|
1128
2326
|
try {
|
|
@@ -1131,7 +2329,8 @@ function createRestClient(options) {
|
|
|
1131
2329
|
credentials: "same-origin",
|
|
1132
2330
|
headers: {
|
|
1133
2331
|
"content-type": "application/json",
|
|
1134
|
-
[CLIENT_VERSION_HEADER]: HUB_CLIENT_VERSION
|
|
2332
|
+
[CLIENT_VERSION_HEADER]: HUB_CLIENT_VERSION,
|
|
2333
|
+
...opKey !== void 0 && ROUTES[name].offline === "replay" ? { [OFFLINE_OP_HEADER]: opKey } : {}
|
|
1135
2334
|
},
|
|
1136
2335
|
...planned.bodyText === void 0 ? {} : { body: planned.bodyText }
|
|
1137
2336
|
});
|
|
@@ -1144,20 +2343,19 @@ function createRestClient(options) {
|
|
|
1144
2343
|
}
|
|
1145
2344
|
return {
|
|
1146
2345
|
apiBase: options.apiBase,
|
|
1147
|
-
call: (name, ...fieldsArgument) => send(name, fieldsArgument[0])
|
|
2346
|
+
call: (name, ...fieldsArgument) => send(name, fieldsArgument[0]),
|
|
2347
|
+
callOnce: (opKey, name, ...fieldsArgument) => send(name, fieldsArgument[0], opKey)
|
|
1148
2348
|
};
|
|
1149
2349
|
}
|
|
1150
2350
|
//#endregion
|
|
1151
2351
|
//#region src/core/connection.ts
|
|
1152
|
-
/**
|
|
1153
|
-
* The machinery under every `Hub` call: reaching the REST API, falling back
|
|
1154
|
-
* to the offline cache, queueing writes made offline and replaying them,
|
|
1155
|
-
* buffering analytics, warming the cache for every game, and reporting
|
|
1156
|
-
* online/offline/sync status (to `onStatus` listeners and as `hub:*` window
|
|
1157
|
-
* events, which the injected menu listens for).
|
|
1158
|
-
*/
|
|
1159
2352
|
/** How long `prefetchAll()` waits between snapshots unless forced. */
|
|
1160
2353
|
const PREFETCH_INTERVAL_MS = 3e4;
|
|
2354
|
+
/** A sync that did nothing. */
|
|
2355
|
+
const NOTHING_SYNCED = {
|
|
2356
|
+
flushed: 0,
|
|
2357
|
+
conflicts: []
|
|
2358
|
+
};
|
|
1161
2359
|
/** Whether a failure means "the hub can't be reached" (rather than a refusal). */
|
|
1162
2360
|
function isTransportFailure(failure) {
|
|
1163
2361
|
return failure instanceof HubError && failure.isOffline;
|
|
@@ -1170,10 +2368,15 @@ var HubConnection = class {
|
|
|
1170
2368
|
_defineProperty(this, "apiBase", void 0);
|
|
1171
2369
|
_defineProperty(this, "rest", void 0);
|
|
1172
2370
|
_defineProperty(this, "store", void 0);
|
|
2371
|
+
_defineProperty(this, "outbox", void 0);
|
|
1173
2372
|
_defineProperty(this, "offlineMode", void 0);
|
|
1174
2373
|
_defineProperty(this, "isReachable", true);
|
|
1175
|
-
_defineProperty(this, "
|
|
2374
|
+
_defineProperty(this, "runningSync", null);
|
|
2375
|
+
_defineProperty(this, "followingSync", null);
|
|
2376
|
+
_defineProperty(this, "isFollowingEager", false);
|
|
2377
|
+
_defineProperty(this, "directSend", null);
|
|
1176
2378
|
_defineProperty(this, "lastPrefetchAt", 0);
|
|
2379
|
+
_defineProperty(this, "offlineApi", void 0);
|
|
1177
2380
|
_defineProperty(this, "statusListeners", /* @__PURE__ */ new Set());
|
|
1178
2381
|
this.slug = options.slug ?? detectSlug();
|
|
1179
2382
|
this.apiBase = options.apiBase === void 0 ? detectApiBase() : absoluteApiBase(options.apiBase);
|
|
@@ -1185,16 +2388,37 @@ var HubConnection = class {
|
|
|
1185
2388
|
this.setReachable(true);
|
|
1186
2389
|
}
|
|
1187
2390
|
});
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
2391
|
+
const pageStore = createPageStore();
|
|
2392
|
+
this.store = new LocalStore(this.slug ?? "app", pageStore);
|
|
2393
|
+
this.outbox = new Outbox({
|
|
2394
|
+
slug: this.slug ?? "app",
|
|
2395
|
+
store: pageStore,
|
|
2396
|
+
rest: this.rest,
|
|
2397
|
+
emit: emitWindowEvent,
|
|
2398
|
+
onUnreachable: () => {
|
|
1195
2399
|
this.setReachable(false);
|
|
1196
|
-
}
|
|
1197
|
-
|
|
2400
|
+
},
|
|
2401
|
+
onChange: () => {
|
|
2402
|
+
this.notifyStatus();
|
|
2403
|
+
},
|
|
2404
|
+
onRetryDue: () => {
|
|
2405
|
+
this.retrySync();
|
|
2406
|
+
}
|
|
2407
|
+
});
|
|
2408
|
+
registerHubKinds(this.outbox, {
|
|
2409
|
+
game: () => this.gameFields(),
|
|
2410
|
+
onDailyComplete: (payload, completion) => {
|
|
2411
|
+
emitWindowEvent("hub:daily-complete", {
|
|
2412
|
+
day: payload.day,
|
|
2413
|
+
count: completion.count,
|
|
2414
|
+
rank: completion.rank,
|
|
2415
|
+
meta: payload.meta
|
|
2416
|
+
});
|
|
2417
|
+
}
|
|
2418
|
+
});
|
|
2419
|
+
this.importLegacyQueue();
|
|
2420
|
+
if (typeof navigator !== "undefined" && "onLine" in navigator) this.isReachable = navigator.onLine;
|
|
2421
|
+
if (isBrowserPage()) this.listenToPage();
|
|
1198
2422
|
}
|
|
1199
2423
|
/** `true` when calls are being served from the offline cache rather than the hub. */
|
|
1200
2424
|
get isOffline() {
|
|
@@ -1204,9 +2428,22 @@ var HubConnection = class {
|
|
|
1204
2428
|
get isOnline() {
|
|
1205
2429
|
return this.isReachable;
|
|
1206
2430
|
}
|
|
1207
|
-
/**
|
|
2431
|
+
/**
|
|
2432
|
+
* The offline outbox: define your own kinds of operation, queue them, and
|
|
2433
|
+
* hear how they replay (`hub.offline.define`, `enqueue`, `pending`, `on`).
|
|
2434
|
+
*/
|
|
2435
|
+
get offline() {
|
|
2436
|
+
this.offlineApi ?? (this.offlineApi = new OfflineApi({
|
|
2437
|
+
outbox: this.outbox,
|
|
2438
|
+
requestSync: () => {
|
|
2439
|
+
if (!this.isOffline) this.sync();
|
|
2440
|
+
}
|
|
2441
|
+
}));
|
|
2442
|
+
return this.offlineApi;
|
|
2443
|
+
}
|
|
2444
|
+
/** Queued operations plus buffered analytics events waiting to sync. */
|
|
1208
2445
|
pending() {
|
|
1209
|
-
return this.
|
|
2446
|
+
return this.outbox.count() + this.store.getEvents().length;
|
|
1210
2447
|
}
|
|
1211
2448
|
/** Subscribe to connection and sync status changes; returns the unsubscribe function. */
|
|
1212
2449
|
onStatus(listener) {
|
|
@@ -1227,16 +2464,13 @@ var HubConnection = class {
|
|
|
1227
2464
|
} catch {}
|
|
1228
2465
|
emitWindowEvent("hub:status", status);
|
|
1229
2466
|
}
|
|
1230
|
-
/** Record reachability; coming back online
|
|
2467
|
+
/** Record reachability; coming back online replays the outbox and re-warms the cache. */
|
|
1231
2468
|
setReachable(isReachableNow) {
|
|
1232
2469
|
if (isReachableNow === this.isReachable) return;
|
|
1233
2470
|
this.isReachable = isReachableNow;
|
|
1234
2471
|
emitWindowEvent(isReachableNow ? "hub:online" : "hub:offline", { pending: this.pending() });
|
|
1235
2472
|
this.notifyStatus();
|
|
1236
|
-
if (isReachableNow)
|
|
1237
|
-
this.sync();
|
|
1238
|
-
this.prefetchAll().catch(() => void 0);
|
|
1239
|
-
}
|
|
2473
|
+
if (isReachableNow) this.replayAll(true).then(() => this.prefetchAll()).catch(() => void 0);
|
|
1240
2474
|
}
|
|
1241
2475
|
/** `{ slug }` for this game's endpoints; throws when the slug is unknown. */
|
|
1242
2476
|
gameFields() {
|
|
@@ -1258,132 +2492,96 @@ var HubConnection = class {
|
|
|
1258
2492
|
return offlineAnswer();
|
|
1259
2493
|
}
|
|
1260
2494
|
}
|
|
1261
|
-
/** Send one queued write. */
|
|
1262
|
-
async sendOp(op) {
|
|
1263
|
-
const game = this.gameFields();
|
|
1264
|
-
switch (op.kind) {
|
|
1265
|
-
case "putSave":
|
|
1266
|
-
await this.rest.call("writeSave", {
|
|
1267
|
-
...game,
|
|
1268
|
-
slot: op.slot,
|
|
1269
|
-
data: op.data,
|
|
1270
|
-
label: op.label
|
|
1271
|
-
});
|
|
1272
|
-
return;
|
|
1273
|
-
case "deleteSave":
|
|
1274
|
-
await this.rest.call("deleteSave", {
|
|
1275
|
-
...game,
|
|
1276
|
-
slot: op.slot
|
|
1277
|
-
});
|
|
1278
|
-
return;
|
|
1279
|
-
case "setStat":
|
|
1280
|
-
await this.rest.call("updateGameStat", {
|
|
1281
|
-
...game,
|
|
1282
|
-
key: op.key,
|
|
1283
|
-
value: op.value,
|
|
1284
|
-
mode: "set"
|
|
1285
|
-
});
|
|
1286
|
-
return;
|
|
1287
|
-
case "maxStat":
|
|
1288
|
-
await this.rest.call("updateGameStat", {
|
|
1289
|
-
...game,
|
|
1290
|
-
key: op.key,
|
|
1291
|
-
value: op.value,
|
|
1292
|
-
mode: "max"
|
|
1293
|
-
});
|
|
1294
|
-
return;
|
|
1295
|
-
case "incrStat":
|
|
1296
|
-
await this.rest.call("updateGameStat", {
|
|
1297
|
-
...game,
|
|
1298
|
-
key: op.key,
|
|
1299
|
-
value: op.amount,
|
|
1300
|
-
mode: "incr"
|
|
1301
|
-
});
|
|
1302
|
-
return;
|
|
1303
|
-
case "submitScore":
|
|
1304
|
-
await this.rest.call("submitScore", {
|
|
1305
|
-
...game,
|
|
1306
|
-
key: op.key,
|
|
1307
|
-
score: op.score,
|
|
1308
|
-
...op.opts
|
|
1309
|
-
});
|
|
1310
|
-
return;
|
|
1311
|
-
case "setProfile":
|
|
1312
|
-
await this.rest.call("setGameProfile", {
|
|
1313
|
-
...game,
|
|
1314
|
-
...op.patch
|
|
1315
|
-
});
|
|
1316
|
-
return;
|
|
1317
|
-
}
|
|
1318
|
-
}
|
|
1319
2495
|
/**
|
|
1320
|
-
* Apply a write to the offline cache at once, then
|
|
1321
|
-
*
|
|
1322
|
-
*
|
|
1323
|
-
*
|
|
2496
|
+
* Apply a write to the offline cache at once, then perform it as an op of
|
|
2497
|
+
* `kind`: sent now when online with nothing waiting, else queued behind
|
|
2498
|
+
* the others. Resolves with the local answer (or, sent now, with
|
|
2499
|
+
* `replyAnswer`'s reading of the hub's reply). A real API error on the
|
|
2500
|
+
* direct path reaches the caller and is not queued; a transport failure
|
|
2501
|
+
* queues the op under the same id, so if the request did land, its replay
|
|
2502
|
+
* is recognised by the hub and not applied twice.
|
|
1324
2503
|
*/
|
|
1325
|
-
async
|
|
2504
|
+
async mutateOffline(kind, payload, localAnswer, replyAnswer) {
|
|
1326
2505
|
const answer = localAnswer();
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
this.
|
|
2506
|
+
const op = this.outbox.stamp(kind, payload);
|
|
2507
|
+
if (this.isOffline || this.outbox.count() > 0 || this.runningSync !== null || this.directSend !== null) {
|
|
2508
|
+
this.outbox.add(op);
|
|
1330
2509
|
if (!this.isOffline) this.sync();
|
|
1331
2510
|
return answer;
|
|
1332
2511
|
}
|
|
2512
|
+
const sending = this.outbox.runNow(op);
|
|
2513
|
+
this.directSend = sending;
|
|
1333
2514
|
try {
|
|
1334
|
-
await
|
|
1335
|
-
return answer;
|
|
2515
|
+
const result = await sending;
|
|
2516
|
+
return replyAnswer?.(result) ?? answer;
|
|
1336
2517
|
} catch (failure) {
|
|
1337
2518
|
if (!isTransportFailure(failure)) throw failure;
|
|
1338
2519
|
this.setReachable(false);
|
|
1339
|
-
this.
|
|
1340
|
-
|
|
2520
|
+
this.outbox.add({
|
|
2521
|
+
...op,
|
|
2522
|
+
attempts: 1
|
|
2523
|
+
});
|
|
1341
2524
|
return answer;
|
|
2525
|
+
} finally {
|
|
2526
|
+
if (this.directSend === sending) this.directSend = null;
|
|
1342
2527
|
}
|
|
1343
2528
|
}
|
|
1344
2529
|
/**
|
|
1345
|
-
* Replay the
|
|
1346
|
-
* transport failure (offline again: the rest stays queued);
|
|
1347
|
-
* refuses is dropped and reported as a conflict.
|
|
2530
|
+
* Replay the outbox, then the buffered events, in order. Stops at the
|
|
2531
|
+
* first transport failure (offline again: the rest stays queued); an op
|
|
2532
|
+
* the hub refuses is dropped and reported as a conflict.
|
|
2533
|
+
*/
|
|
2534
|
+
sync() {
|
|
2535
|
+
return this.isOffline ? Promise.resolve(NOTHING_SYNCED) : this.replayAll(false);
|
|
2536
|
+
}
|
|
2537
|
+
/** A retry came due: try again even when the hub was last unreachable (the try is the probe), unless the browser itself is offline. */
|
|
2538
|
+
retrySync() {
|
|
2539
|
+
const isBrowserOffline = typeof navigator !== "undefined" && "onLine" in navigator && !navigator.onLine;
|
|
2540
|
+
return this.offlineMode === "always" || isBrowserOffline ? Promise.resolve(NOTHING_SYNCED) : this.replayAll(false);
|
|
2541
|
+
}
|
|
2542
|
+
/**
|
|
2543
|
+
* One replay at a time per client. A call during one queues a single
|
|
2544
|
+
* follow-up replay (eager if any caller asked for that), so an op queued
|
|
2545
|
+
* or a reconnect that happens mid-replay is never missed.
|
|
1348
2546
|
*/
|
|
1349
|
-
|
|
1350
|
-
if (this.
|
|
1351
|
-
|
|
1352
|
-
|
|
2547
|
+
replayAll(isEager) {
|
|
2548
|
+
if (this.slug === null || this.offlineMode === "always") return Promise.resolve(NOTHING_SYNCED);
|
|
2549
|
+
const running = this.runningSync;
|
|
2550
|
+
if (running === null) return this.startReplay(isEager);
|
|
2551
|
+
this.isFollowingEager || (this.isFollowingEager = isEager);
|
|
2552
|
+
this.followingSync ?? (this.followingSync = running.catch(() => NOTHING_SYNCED).then(() => {
|
|
2553
|
+
const isFollowUpEager = this.isFollowingEager;
|
|
2554
|
+
this.isFollowingEager = false;
|
|
2555
|
+
this.followingSync = null;
|
|
2556
|
+
return this.startReplay(isFollowUpEager);
|
|
2557
|
+
}));
|
|
2558
|
+
return this.followingSync;
|
|
2559
|
+
}
|
|
2560
|
+
startReplay(isEager) {
|
|
2561
|
+
const run = this.runReplay(isEager).finally(() => {
|
|
2562
|
+
if (this.runningSync === run) this.runningSync = null;
|
|
2563
|
+
});
|
|
2564
|
+
this.runningSync = run;
|
|
2565
|
+
return run;
|
|
2566
|
+
}
|
|
2567
|
+
async runReplay(isEager) {
|
|
2568
|
+
if (this.directSend !== null) await this.directSend.catch(() => void 0);
|
|
2569
|
+
const report = await this.outbox.replay(isEager);
|
|
2570
|
+
const conflicts = report.failed.map((failure) => ({
|
|
2571
|
+
op: failure.entry,
|
|
2572
|
+
error: failure.error
|
|
2573
|
+
}));
|
|
2574
|
+
if (report.stoppedBy !== "connection") await this.flushEvents();
|
|
2575
|
+
const result = {
|
|
2576
|
+
flushed: report.replayed.length,
|
|
2577
|
+
conflicts
|
|
1353
2578
|
};
|
|
1354
|
-
this.isSyncing = true;
|
|
1355
|
-
let flushed = 0;
|
|
1356
|
-
const conflicts = [];
|
|
1357
|
-
try {
|
|
1358
|
-
let queue = this.store.getQueue();
|
|
1359
|
-
for (let op = queue[0]; op !== void 0; op = queue[0]) {
|
|
1360
|
-
try {
|
|
1361
|
-
await this.sendOp(op);
|
|
1362
|
-
flushed += 1;
|
|
1363
|
-
} catch (failure) {
|
|
1364
|
-
if (isTransportFailure(failure)) break;
|
|
1365
|
-
conflicts.push({
|
|
1366
|
-
op,
|
|
1367
|
-
error: failure instanceof Error ? failure.message : String(failure)
|
|
1368
|
-
});
|
|
1369
|
-
}
|
|
1370
|
-
queue = queue.slice(1);
|
|
1371
|
-
this.store.setQueue(queue);
|
|
1372
|
-
}
|
|
1373
|
-
await this.flushEvents();
|
|
1374
|
-
} finally {
|
|
1375
|
-
this.isSyncing = false;
|
|
1376
|
-
}
|
|
1377
2579
|
emitWindowEvent("hub:sync", {
|
|
1378
2580
|
pending: this.pending(),
|
|
1379
|
-
|
|
1380
|
-
conflicts
|
|
2581
|
+
...result
|
|
1381
2582
|
});
|
|
1382
2583
|
this.notifyStatus();
|
|
1383
|
-
return
|
|
1384
|
-
flushed,
|
|
1385
|
-
conflicts
|
|
1386
|
-
};
|
|
2584
|
+
return result;
|
|
1387
2585
|
}
|
|
1388
2586
|
/** Send the buffered analytics events; a refused one is dropped (telemetry never blocks). */
|
|
1389
2587
|
async flushEvents() {
|
|
@@ -1401,6 +2599,31 @@ var HubConnection = class {
|
|
|
1401
2599
|
this.store.setEvents(events);
|
|
1402
2600
|
}
|
|
1403
2601
|
}
|
|
2602
|
+
/** Move a v1 copy's queued writes into the outbox, in their order. */
|
|
2603
|
+
importLegacyQueue() {
|
|
2604
|
+
if (this.slug === null) return;
|
|
2605
|
+
try {
|
|
2606
|
+
for (const legacy of this.store.takeLegacyQueue()) {
|
|
2607
|
+
const operation = legacyOperation(legacy);
|
|
2608
|
+
if (operation !== void 0) this.outbox.add(this.outbox.stamp(operation.kind, operation.payload));
|
|
2609
|
+
}
|
|
2610
|
+
} catch {}
|
|
2611
|
+
}
|
|
2612
|
+
/** Follow the browser's connection, other tabs' outbox changes, and replay what an earlier page left queued. */
|
|
2613
|
+
listenToPage() {
|
|
2614
|
+
window.addEventListener("online", () => {
|
|
2615
|
+
this.setReachable(true);
|
|
2616
|
+
});
|
|
2617
|
+
window.addEventListener("offline", () => {
|
|
2618
|
+
this.setReachable(false);
|
|
2619
|
+
});
|
|
2620
|
+
window.addEventListener("storage", (event) => {
|
|
2621
|
+
if (event instanceof StorageEvent && this.outbox.entries.owns(event.key)) this.notifyStatus();
|
|
2622
|
+
});
|
|
2623
|
+
if (this.outbox.count() > 0) setTimeout(() => {
|
|
2624
|
+
this.sync();
|
|
2625
|
+
}, 0);
|
|
2626
|
+
}
|
|
1404
2627
|
/**
|
|
1405
2628
|
* Warm the offline cache for every game the player has touched, in one
|
|
1406
2629
|
* request, so going offline later still shows complete progress.
|
|
@@ -1416,32 +2639,13 @@ var HubConnection = class {
|
|
|
1416
2639
|
throw failure;
|
|
1417
2640
|
}
|
|
1418
2641
|
this.lastPrefetchAt = Date.now();
|
|
2642
|
+
if (this.outbox.count() > 0) return snapshot;
|
|
1419
2643
|
this.store.hydrate(snapshot);
|
|
1420
2644
|
emitWindowEvent("hub:prefetch", { games: Object.keys(snapshot.games).length });
|
|
1421
2645
|
return snapshot;
|
|
1422
2646
|
}
|
|
1423
2647
|
};
|
|
1424
2648
|
//#endregion
|
|
1425
|
-
//#region src/core/game-data.ts
|
|
1426
|
-
/** Always true: a game's payload is sent as JSON (`JSON.stringify` drops what JSON can't hold). */
|
|
1427
|
-
function isSentAsJson(_payload) {
|
|
1428
|
-
return true;
|
|
1429
|
-
}
|
|
1430
|
-
/** Always true: JSON the game stored is read back as the game's type. */
|
|
1431
|
-
function isGameOwnData(_stored) {
|
|
1432
|
-
return true;
|
|
1433
|
-
}
|
|
1434
|
-
/** A game's payload as the JSON the hub stores. */
|
|
1435
|
-
function toStoredJson(payload) {
|
|
1436
|
-
if (isSentAsJson(payload)) return payload;
|
|
1437
|
-
throw new TypeError("unreachable: every payload is sent as JSON");
|
|
1438
|
-
}
|
|
1439
|
-
/** JSON the game stored, typed as the game's own type. */
|
|
1440
|
-
function toGameData(stored) {
|
|
1441
|
-
if (isGameOwnData(stored)) return stored;
|
|
1442
|
-
throw new TypeError("unreachable: stored game data is the game's own");
|
|
1443
|
-
}
|
|
1444
|
-
//#endregion
|
|
1445
2649
|
//#region src/core/hub-progress.ts
|
|
1446
2650
|
/** A profile with the game's own data typed as `TData`. */
|
|
1447
2651
|
function typedProfile(profile) {
|
|
@@ -1542,13 +2746,11 @@ var HubProgress = class extends HubConnection {
|
|
|
1542
2746
|
}
|
|
1543
2747
|
/** Create or update this game's profile for the current user. */
|
|
1544
2748
|
setProfile(patch) {
|
|
1545
|
-
const queued = {
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
patch: queued
|
|
1551
|
-
}, () => {
|
|
2749
|
+
const queued = {
|
|
2750
|
+
...patch.displayName === void 0 ? {} : { displayName: patch.displayName },
|
|
2751
|
+
...patch.data === void 0 ? {} : { data: toStoredJson(patch.data) }
|
|
2752
|
+
};
|
|
2753
|
+
return this.mutateOffline(PROGRESS_KINDS.profile, queued, () => {
|
|
1552
2754
|
const stored = this.store.setProfile(this.slug ?? "app", patch.displayName ?? null, queued.data ?? {});
|
|
1553
2755
|
return { profile: {
|
|
1554
2756
|
...stored,
|
|
@@ -1570,8 +2772,7 @@ var HubProgress = class extends HubConnection {
|
|
|
1570
2772
|
/** Create or overwrite a save slot. `saveData` is any JSON up to 64 KB. */
|
|
1571
2773
|
putSave(slot, saveData, label) {
|
|
1572
2774
|
const stored = toStoredJson(saveData);
|
|
1573
|
-
return this.
|
|
1574
|
-
kind: "putSave",
|
|
2775
|
+
return this.mutateOffline(PROGRESS_KINDS.save, {
|
|
1575
2776
|
slot,
|
|
1576
2777
|
data: stored,
|
|
1577
2778
|
label: label ?? null
|
|
@@ -1582,9 +2783,9 @@ var HubProgress = class extends HubConnection {
|
|
|
1582
2783
|
}
|
|
1583
2784
|
/** Delete a save slot. */
|
|
1584
2785
|
deleteSave(slot) {
|
|
1585
|
-
return this.
|
|
1586
|
-
|
|
1587
|
-
|
|
2786
|
+
return this.mutateOffline(PROGRESS_KINDS.save, {
|
|
2787
|
+
slot,
|
|
2788
|
+
deleted: true
|
|
1588
2789
|
}, () => {
|
|
1589
2790
|
this.store.deleteSave(slot);
|
|
1590
2791
|
return { ok: true };
|
|
@@ -1599,9 +2800,9 @@ var HubProgress = class extends HubConnection {
|
|
|
1599
2800
|
}
|
|
1600
2801
|
/** Overwrite a stat. */
|
|
1601
2802
|
setStat(key, value) {
|
|
1602
|
-
return this.
|
|
1603
|
-
kind: "setStat",
|
|
2803
|
+
return this.mutateOffline(PROGRESS_KINDS.stat, {
|
|
1604
2804
|
key,
|
|
2805
|
+
mode: "set",
|
|
1605
2806
|
value
|
|
1606
2807
|
}, () => ({
|
|
1607
2808
|
key,
|
|
@@ -1610,10 +2811,10 @@ var HubProgress = class extends HubConnection {
|
|
|
1610
2811
|
}
|
|
1611
2812
|
/** Add to a stat (creating it at `amount` when absent). */
|
|
1612
2813
|
incrStat(key, amount = 1) {
|
|
1613
|
-
return this.
|
|
1614
|
-
kind: "incrStat",
|
|
2814
|
+
return this.mutateOffline(PROGRESS_KINDS.stat, {
|
|
1615
2815
|
key,
|
|
1616
|
-
|
|
2816
|
+
mode: "incr",
|
|
2817
|
+
value: amount
|
|
1617
2818
|
}, () => ({
|
|
1618
2819
|
key,
|
|
1619
2820
|
value: this.store.updateStat(key, amount, "incr")
|
|
@@ -1621,9 +2822,9 @@ var HubProgress = class extends HubConnection {
|
|
|
1621
2822
|
}
|
|
1622
2823
|
/** Keep the larger of the stored and the given value. */
|
|
1623
2824
|
maxStat(key, value) {
|
|
1624
|
-
return this.
|
|
1625
|
-
kind: "maxStat",
|
|
2825
|
+
return this.mutateOffline(PROGRESS_KINDS.stat, {
|
|
1626
2826
|
key,
|
|
2827
|
+
mode: "max",
|
|
1627
2828
|
value
|
|
1628
2829
|
}, () => ({
|
|
1629
2830
|
key,
|
|
@@ -1637,8 +2838,7 @@ var HubProgress = class extends HubConnection {
|
|
|
1637
2838
|
...opts.sort === void 0 ? {} : { sort: opts.sort },
|
|
1638
2839
|
...opts.meta === void 0 ? {} : { meta: toStoredJson(opts.meta) }
|
|
1639
2840
|
};
|
|
1640
|
-
return this.
|
|
1641
|
-
kind: "submitScore",
|
|
2841
|
+
return this.mutateOffline(PROGRESS_KINDS.score, {
|
|
1642
2842
|
key: boardKey,
|
|
1643
2843
|
score,
|
|
1644
2844
|
opts: queued
|
|
@@ -1682,31 +2882,20 @@ var HubProgress = class extends HubConnection {
|
|
|
1682
2882
|
}
|
|
1683
2883
|
};
|
|
1684
2884
|
//#endregion
|
|
1685
|
-
//#region src/core/
|
|
1686
|
-
/**
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
function readLocalFavorites() {
|
|
1695
|
-
const text = createPageStore().read(FAVORITES_KEY);
|
|
1696
|
-
return text === null ? [] : stringsIn(parseJsonText(text));
|
|
2885
|
+
//#region src/core/hub-economy.ts
|
|
2886
|
+
/** Goods as queued: `null` parts left out. */
|
|
2887
|
+
function goodsOf(bundle) {
|
|
2888
|
+
const items = bundle?.items ?? void 0;
|
|
2889
|
+
const coins = bundle?.coins ?? void 0;
|
|
2890
|
+
return {
|
|
2891
|
+
...items === void 0 ? {} : { items },
|
|
2892
|
+
...coins === void 0 ? {} : { coins }
|
|
2893
|
+
};
|
|
1697
2894
|
}
|
|
1698
|
-
/**
|
|
1699
|
-
function
|
|
1700
|
-
|
|
2895
|
+
/** The holdings in a grant or exchange reply, or `undefined` when it doesn't read as one. */
|
|
2896
|
+
function inventoryReply(result) {
|
|
2897
|
+
return readInventory(result) ?? void 0;
|
|
1701
2898
|
}
|
|
1702
|
-
//#endregion
|
|
1703
|
-
//#region src/core/hub-economy.ts
|
|
1704
|
-
/**
|
|
1705
|
-
* Analytics, the catalog, favorites, records, inventory and trades. Analytics
|
|
1706
|
-
* calls are fire-and-forget and never throw (telemetry must not break a game);
|
|
1707
|
-
* offline they are buffered and sent when the hub is back. Method names are
|
|
1708
|
-
* the v1 client's, unchanged.
|
|
1709
|
-
*/
|
|
1710
2899
|
/** Analytics, catalog, favorites, records, inventory and trades. */
|
|
1711
2900
|
var HubEconomy = class extends HubProgress {
|
|
1712
2901
|
/** Record a "play". Call once when the game starts. */
|
|
@@ -1776,14 +2965,17 @@ var HubEconomy = class extends HubProgress {
|
|
|
1776
2965
|
return reply;
|
|
1777
2966
|
}, () => ({ games: readLocalFavorites() }));
|
|
1778
2967
|
}
|
|
1779
|
-
/** Star (`isOn = true`) or unstar a game.
|
|
1780
|
-
|
|
1781
|
-
|
|
2968
|
+
/** Star (`isOn = true`) or unstar a game. Offline, the local list changes at once and the hub's when it is back. */
|
|
2969
|
+
setFavorite(slug, isOn) {
|
|
2970
|
+
return this.mutateOffline(ECONOMY_KINDS.favorite, {
|
|
1782
2971
|
slug,
|
|
1783
2972
|
on: isOn
|
|
1784
|
-
})
|
|
1785
|
-
|
|
1786
|
-
|
|
2973
|
+
}, () => {
|
|
2974
|
+
const others = readLocalFavorites().filter((starred) => starred !== slug);
|
|
2975
|
+
const games = isOn ? [...others, slug] : others;
|
|
2976
|
+
writeLocalFavorites(games);
|
|
2977
|
+
return { games };
|
|
2978
|
+
}, (result) => isRecord(result) ? { games: stringsIn(result.games) } : void 0);
|
|
1787
2979
|
}
|
|
1788
2980
|
/** The player's leaderboard standings, grouped by game (every game, or one via `forSlug`). Empty offline or signed out. */
|
|
1789
2981
|
myRecords(forSlug) {
|
|
@@ -1791,10 +2983,7 @@ var HubEconomy = class extends HubProgress {
|
|
|
1791
2983
|
}
|
|
1792
2984
|
/** This game's inventory and coin balance for the current user. */
|
|
1793
2985
|
getInventory() {
|
|
1794
|
-
return this.withFallback(() => this.rest.call("getMyInventory", this.gameFields()), () => (
|
|
1795
|
-
items: [],
|
|
1796
|
-
coins: 0
|
|
1797
|
-
}));
|
|
2986
|
+
return this.withFallback(async () => this.store.setInventory(await this.rest.call("getMyInventory", this.gameFields())), () => this.store.getInventory());
|
|
1798
2987
|
}
|
|
1799
2988
|
/** Another player's available holdings (to compose a trade). */
|
|
1800
2989
|
playerInventory(userId) {
|
|
@@ -1806,20 +2995,32 @@ var HubEconomy = class extends HubProgress {
|
|
|
1806
2995
|
coins: 0
|
|
1807
2996
|
}));
|
|
1808
2997
|
}
|
|
1809
|
-
/**
|
|
2998
|
+
/**
|
|
2999
|
+
* Add goods to the player (gameplay rewards); answers the new holdings.
|
|
3000
|
+
* Offline, the cached holdings change at once and the grant replays later.
|
|
3001
|
+
*/
|
|
1810
3002
|
grant(bundle) {
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
...bundle
|
|
1814
|
-
});
|
|
3003
|
+
const goods = goodsOf(bundle);
|
|
3004
|
+
return this.mutateOffline(ECONOMY_KINDS.grant, goods, () => this.store.setInventory(withGoods(this.store.getInventory(), goods)), inventoryReply);
|
|
1815
3005
|
}
|
|
1816
|
-
/**
|
|
3006
|
+
/**
|
|
3007
|
+
* An atomic swap with the house: remove `take`, add `give`. Offline it is
|
|
3008
|
+
* checked against the cached holdings (409 when they can't cover `take`),
|
|
3009
|
+
* applied to them at once, and replayed later; the hub has the last word.
|
|
3010
|
+
*/
|
|
1817
3011
|
exchange(take, give) {
|
|
1818
|
-
|
|
1819
|
-
...
|
|
1820
|
-
...
|
|
1821
|
-
|
|
1822
|
-
|
|
3012
|
+
const payload = {
|
|
3013
|
+
...take === void 0 ? {} : { take: goodsOf(take) },
|
|
3014
|
+
...give === void 0 ? {} : { give: goodsOf(give) }
|
|
3015
|
+
};
|
|
3016
|
+
return this.mutateOffline(ECONOMY_KINDS.exchange, payload, () => {
|
|
3017
|
+
const remaining = withoutGoods(this.store.getInventory(), payload.take ?? {});
|
|
3018
|
+
if (typeof remaining === "string") {
|
|
3019
|
+
if (this.isOffline) throw new HubError(remaining, 409);
|
|
3020
|
+
return this.store.getInventory();
|
|
3021
|
+
}
|
|
3022
|
+
return this.store.setInventory(withGoods(remaining, payload.give ?? {}));
|
|
3023
|
+
}, inventoryReply);
|
|
1823
3024
|
}
|
|
1824
3025
|
/** Sell items to the house for coins. */
|
|
1825
3026
|
sell(items, coins) {
|
|
@@ -2135,12 +3336,11 @@ var Hub = class extends HubEconomy {
|
|
|
2135
3336
|
...this.gameFields(),
|
|
2136
3337
|
day
|
|
2137
3338
|
}),
|
|
2138
|
-
complete: (day, token, meta) => this.
|
|
2139
|
-
...this.gameFields(),
|
|
3339
|
+
complete: (day, token, meta) => this.mutateOffline(DAILY_KIND, {
|
|
2140
3340
|
day,
|
|
2141
3341
|
token,
|
|
2142
3342
|
meta
|
|
2143
|
-
}),
|
|
3343
|
+
}, () => null, (result) => readDailyCompletion(result)),
|
|
2144
3344
|
emit: (eventName, detail) => {
|
|
2145
3345
|
emitWindowEvent(eventName, detail);
|
|
2146
3346
|
}
|
|
@@ -2159,6 +3359,6 @@ function createHub(options = {}) {
|
|
|
2159
3359
|
return new Hub(options);
|
|
2160
3360
|
}
|
|
2161
3361
|
//#endregion
|
|
2162
|
-
export {
|
|
3362
|
+
export { OUTBOX_FULL_CODE as a, OfflineApi as c, OfflineError as d, UNSUPPORTED_BY_HUB as f, CLIENT_VERSION_HEADER as i, ContentRejectedError as l, createHub as n, OUTBOX_MAX_CHARS as o, hub as r, OUTBOX_MAX_ENTRIES as s, Hub as t, HubError as u };
|
|
2163
3363
|
|
|
2164
3364
|
//# sourceMappingURL=src.js.map
|