@diffenderfer-games/hub 2.0.0-next.1 → 2.0.0-next.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -9
- package/README.md +25 -10
- package/dist/auto.d.ts +6 -6
- package/dist/auto.js +2 -2
- package/dist/doctor/cli.mjs +14 -7
- package/dist/doctor/cli.mjs.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.js +2 -2
- package/dist/mp.d.ts +3 -3
- package/dist/race.d.ts +1 -1
- package/dist/rooms.d.ts +3 -3
- package/dist/shared/bridge.js +9 -3
- package/dist/shared/bridge.js.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.d.ts +10 -4
- package/dist/shared/daily.d.ts.map +1 -1
- package/dist/shared/daily.js +12 -2
- package/dist/shared/daily.js.map +1 -1
- package/dist/shared/index.d.ts +314 -35
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/input.js +7 -2
- package/dist/shared/input.js.map +1 -1
- package/dist/shared/latest-state.js +1 -1
- package/dist/shared/mp.js +10 -2
- package/dist/shared/mp.js.map +1 -1
- package/dist/shared/multiplayer-types.d.ts +15 -2
- package/dist/shared/multiplayer-types.d.ts.map +1 -1
- package/dist/shared/race-types.d.ts +1 -1
- package/dist/shared/race.js +1 -1
- package/dist/shared/room-types.d.ts +28 -3
- package/dist/shared/room-types.d.ts.map +1 -1
- package/dist/shared/rooms.d.ts.map +1 -1
- package/dist/shared/rooms.js +10 -2
- package/dist/shared/rooms.js.map +1 -1
- package/dist/shared/runtime-opener.js +1 -1
- package/dist/shared/social.d.ts +23 -8
- package/dist/shared/social.d.ts.map +1 -1
- package/dist/shared/social.js +6 -1
- package/dist/shared/social.js.map +1 -1
- package/dist/shared/src.js +1588 -388
- package/dist/shared/src.js.map +1 -1
- package/dist/shared/wire-types.d.ts +6 -2
- package/dist/shared/wire-types.d.ts.map +1 -1
- package/dist/social.d.ts +5 -5
- package/dist/social.js +1 -1
- package/package.json +2 -2
- package/src/core/connection.ts +187 -92
- package/src/core/game-data.ts +11 -9
- package/src/core/hub-economy.ts +47 -10
- package/src/core/hub-progress.ts +13 -16
- package/src/core/hub.ts +4 -2
- package/src/core/offline/hub-kinds/daily-kind.ts +68 -0
- package/src/core/offline/hub-kinds/economy-kinds.ts +86 -0
- package/src/core/offline/hub-kinds/game-fields.ts +2 -0
- package/src/core/offline/hub-kinds/index.ts +35 -0
- package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
- package/src/core/offline/hub-kinds/progress-kinds.ts +148 -0
- package/src/core/offline/kind-adapters.ts +48 -0
- package/src/core/offline/local-inventory.ts +64 -0
- package/src/core/offline/local-store.ts +20 -22
- package/src/core/offline/offline-api.ts +65 -0
- package/src/core/offline/outbox-entry.ts +59 -0
- package/src/core/offline/outbox-kinds.ts +78 -0
- package/src/core/offline/outbox-merge.ts +39 -0
- package/src/core/offline/outbox-replayer.ts +141 -0
- package/src/core/offline/outbox-store.ts +85 -0
- package/src/core/offline/outbox.ts +232 -0
- package/src/core/offline/queue.ts +4 -49
- package/src/core/offline/replay-lease.ts +87 -0
- package/src/core/offline/replay-policy.ts +52 -0
- package/src/core/rest/rest-client.ts +14 -3
- package/src/core/rest/routes.gen.ts +108 -87
- package/src/core/storage/json-guards.ts +6 -0
- package/src/core/version/client-version.gen.ts +1 -1
- package/src/daily/daily-system.ts +8 -1
- package/src/daily/types.ts +3 -2
- package/src/doctor/checks.ts +10 -2
- package/src/index.ts +6 -1
- package/src/input/input-registry.ts +9 -2
- package/src/mp.ts +1 -1
- package/src/realtime/bridge.ts +5 -0
- package/src/realtime/multiplayer-forwarder.ts +13 -1
- package/src/realtime/multiplayer-types.ts +15 -1
- package/src/realtime/room-types.ts +28 -2
- package/src/realtime/rooms-forwarder.ts +13 -1
- package/src/realtime/runtime-api.ts +19 -8
- package/src/realtime/social-forwarder.ts +5 -0
- package/src/realtime/social-types.ts +8 -0
- package/src/realtime/wire-types.ts +7 -0
- package/src/rooms.ts +1 -1
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The offline
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* merge), so the backlog stays small.
|
|
7
|
-
*
|
|
8
|
-
* The op shapes are the v1 client's, unchanged: a page upgraded from a
|
|
9
|
-
* vendored copy replays the queue that copy left in `localStorage`.
|
|
2
|
+
* The v1 client's offline queue (`hub:<slug>:queue`): its op shapes and the
|
|
3
|
+
* reader for what a vendored v1 copy left in `localStorage`. This client
|
|
4
|
+
* queues into the outbox instead (`outbox.ts`) and moves any v1 ops there on
|
|
5
|
+
* start (`hub-kinds/legacy-ops.ts`), so nothing a v1 copy queued is lost.
|
|
10
6
|
*/
|
|
11
7
|
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
12
8
|
import type { SortDir } from '@diffenderfer-games/hub-contract';
|
|
@@ -128,44 +124,3 @@ export function readQueue(stored: unknown): QueueOp[] {
|
|
|
128
124
|
export function betterScore(first: number, second: number, sort: SortDir | undefined): number {
|
|
129
125
|
return sort === 'asc' ? Math.min(first, second) : Math.max(first, second);
|
|
130
126
|
}
|
|
131
|
-
|
|
132
|
-
/** The op that `queued` becomes when `incoming` collapses into it, or `undefined` when they don't collapse. */
|
|
133
|
-
function collapsedOp(queued: QueueOp, incoming: QueueOp): QueueOp | undefined {
|
|
134
|
-
if (queued.kind === 'incrStat' && incoming.kind === 'incrStat' && queued.key === incoming.key) {
|
|
135
|
-
return { ...queued, amount: queued.amount + incoming.amount };
|
|
136
|
-
}
|
|
137
|
-
if (queued.kind === 'maxStat' && incoming.kind === 'maxStat' && queued.key === incoming.key) {
|
|
138
|
-
return { ...queued, value: Math.max(queued.value, incoming.value) };
|
|
139
|
-
}
|
|
140
|
-
if (queued.kind === 'setStat' && incoming.kind === 'setStat' && queued.key === incoming.key) {
|
|
141
|
-
return { ...queued, value: incoming.value };
|
|
142
|
-
}
|
|
143
|
-
if (queued.kind === 'submitScore' && incoming.kind === 'submitScore' && queued.key === incoming.key) {
|
|
144
|
-
return { ...queued, score: betterScore(queued.score, incoming.score, incoming.opts.sort), opts: incoming.opts };
|
|
145
|
-
}
|
|
146
|
-
if (queued.kind === 'setProfile' && incoming.kind === 'setProfile') {
|
|
147
|
-
return { ...queued, patch: { ...queued.patch, ...incoming.patch } };
|
|
148
|
-
}
|
|
149
|
-
return undefined;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Whether an op writes save slot `slot`. */
|
|
153
|
-
function writesSlot(op: QueueOp, slot: string): boolean {
|
|
154
|
-
return (op.kind === 'putSave' || op.kind === 'deleteSave') && op.slot === slot;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* The queue with `incoming` added: a save or delete replaces any queued
|
|
159
|
-
* write to the same slot (and goes last); a compatible op merges into the
|
|
160
|
-
* first queued one in place; anything else is appended.
|
|
161
|
-
*/
|
|
162
|
-
export function enqueueOp(queue: readonly QueueOp[], incoming: QueueOp): QueueOp[] {
|
|
163
|
-
if (incoming.kind === 'putSave' || incoming.kind === 'deleteSave') {
|
|
164
|
-
return [...queue.filter((op) => !writesSlot(op, incoming.slot)), incoming];
|
|
165
|
-
}
|
|
166
|
-
const mergeAt = queue.findIndex((op) => collapsedOp(op, incoming) !== undefined);
|
|
167
|
-
if (mergeAt < 0) {
|
|
168
|
-
return [...queue, incoming];
|
|
169
|
-
}
|
|
170
|
-
return queue.map((op, i) => (i === mergeAt ? (collapsedOp(op, incoming) ?? op) : op));
|
|
171
|
-
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One replayer at a time per game, across tabs and across the client copies
|
|
3
|
+
* on one page: a Web Lock where the browser has them, else a lease in
|
|
4
|
+
* `localStorage` that its holder renews while it works and that lapses if
|
|
5
|
+
* the tab dies. A replayer that can't get it simply doesn't run; the holder
|
|
6
|
+
* replays for everyone.
|
|
7
|
+
*/
|
|
8
|
+
import { isRecord, optionalNumber, optionalString, parseJsonText } from '../storage/json-guards';
|
|
9
|
+
import type { KeyValueStore } from '../storage/key-value';
|
|
10
|
+
|
|
11
|
+
/** Runs work while holding the lease. */
|
|
12
|
+
export interface ReplayLease {
|
|
13
|
+
/** Run `work` holding the lease; `false` (without running it) when another replayer holds it. */
|
|
14
|
+
hold(work: () => Promise<void>): Promise<boolean>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** How long a `localStorage` lease lasts without renewal. */
|
|
18
|
+
export const STORAGE_LEASE_MS = 15_000;
|
|
19
|
+
|
|
20
|
+
/** How often the holder renews it. */
|
|
21
|
+
const RENEW_EVERY_MS = 5_000;
|
|
22
|
+
|
|
23
|
+
/** A lease on the Web Locks API. */
|
|
24
|
+
function webLockLease(locks: LockManager, name: string): ReplayLease {
|
|
25
|
+
return {
|
|
26
|
+
hold: (work) => locks.request(name, { ifAvailable: true }, async (lock) => {
|
|
27
|
+
if (lock === null) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
await work();
|
|
31
|
+
return true;
|
|
32
|
+
}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The lease's holder and expiry, or `undefined` when there is none. */
|
|
37
|
+
function readLease(text: string | null): { readonly owner: string; readonly until: number } | undefined {
|
|
38
|
+
const stored = text === null ? undefined : parseJsonText(text);
|
|
39
|
+
if (!isRecord(stored)) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
const owner = optionalString(stored.owner);
|
|
43
|
+
const until = optionalNumber(stored.until);
|
|
44
|
+
return owner === undefined || until === undefined ? undefined : { owner, until };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A lease kept in `store` under `key`, for pages without Web Locks. */
|
|
48
|
+
export function storageLease(store: KeyValueStore, key: string, now: () => number = Date.now): ReplayLease {
|
|
49
|
+
const owner = `${String(now())}-${Math.random().toString(36).slice(2)}`;
|
|
50
|
+
const claim = (): boolean => {
|
|
51
|
+
const current = readLease(store.read(key));
|
|
52
|
+
if (current !== undefined && current.owner !== owner && current.until > now()) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
store.write(key, JSON.stringify({ owner, until: now() + STORAGE_LEASE_MS }));
|
|
56
|
+
return readLease(store.read(key))?.owner === owner;
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
async hold(work) {
|
|
60
|
+
if (!claim()) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const renewal = setInterval(claim, RENEW_EVERY_MS);
|
|
64
|
+
try {
|
|
65
|
+
await work();
|
|
66
|
+
} finally {
|
|
67
|
+
clearInterval(renewal);
|
|
68
|
+
if (readLease(store.read(key))?.owner === owner) {
|
|
69
|
+
store.remove(key);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The page's Web Locks, when it has them. */
|
|
78
|
+
function pageLocks(): LockManager | undefined {
|
|
79
|
+
return typeof navigator !== 'undefined' && 'locks' in navigator ? navigator.locks : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The lease for game `slug`'s outbox: a Web Lock when available, else one in `store`. */
|
|
83
|
+
export function createReplayLease(slug: string, store: KeyValueStore): ReplayLease {
|
|
84
|
+
const name = `hub:${slug}:outbox-lease`;
|
|
85
|
+
const locks = pageLocks();
|
|
86
|
+
return locks === undefined ? storageLease(store, name) : webLockLease(locks, name);
|
|
87
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a failed replay does next, and how long it waits:
|
|
3
|
+
*
|
|
4
|
+
* - `offline`: the hub (or the network) can't be reached. The op stays at
|
|
5
|
+
* the head of the queue and replay waits for the connection.
|
|
6
|
+
* - `retry`: the hub answered with a passing problem (408, 429, 5xx) or a
|
|
7
|
+
* game handler's fetch failed. The op stays at the head and is retried with
|
|
8
|
+
* backoff, up to {@link MAX_RETRIES} times, then dropped.
|
|
9
|
+
* - `drop`: the hub refused it (any other status) or the handler threw
|
|
10
|
+
* something else. The op is dropped and reported, and the queue moves on.
|
|
11
|
+
*/
|
|
12
|
+
import { HubError } from '../errors';
|
|
13
|
+
|
|
14
|
+
/** What to do after a failed replay. */
|
|
15
|
+
export type FailureVerdict = 'offline' | 'retry' | 'drop';
|
|
16
|
+
|
|
17
|
+
/** The most tries for an op that keeps getting a passing failure. */
|
|
18
|
+
export const MAX_RETRIES = 8;
|
|
19
|
+
|
|
20
|
+
/** The first retry's delay. */
|
|
21
|
+
const BASE_DELAY_MS = 1000;
|
|
22
|
+
|
|
23
|
+
/** The longest delay between tries. */
|
|
24
|
+
const MAX_DELAY_MS = 60_000;
|
|
25
|
+
|
|
26
|
+
/** Statuses that mean "try again later". */
|
|
27
|
+
const PASSING_STATUSES: ReadonlySet<number> = new Set([408, 425, 429]);
|
|
28
|
+
|
|
29
|
+
/** The verdict for a failure. */
|
|
30
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: whatever a replay handler threw
|
|
31
|
+
export function verdictFor(failure: unknown): FailureVerdict {
|
|
32
|
+
if (failure instanceof HubError) {
|
|
33
|
+
if (failure.status === undefined) {
|
|
34
|
+
return 'offline';
|
|
35
|
+
}
|
|
36
|
+
return failure.status >= 500 || PASSING_STATUSES.has(failure.status) ? 'retry' : 'drop';
|
|
37
|
+
}
|
|
38
|
+
// A game handler's own fetch rejects with a TypeError when the network is down.
|
|
39
|
+
return failure instanceof TypeError ? 'retry' : 'drop';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The delay before try `attempts + 1`: doubling from 1 s, capped at 60 s, with jitter so tabs and players spread out. */
|
|
43
|
+
export function backoffMs(attempts: number, random: () => number = Math.random): number {
|
|
44
|
+
const ceiling = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** Math.max(0, attempts - 1));
|
|
45
|
+
return Math.round(ceiling / 2 + (ceiling / 2) * random());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A failure's message, for reports. */
|
|
49
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: whatever a replay handler threw
|
|
50
|
+
export function failureMessage(failure: unknown): string {
|
|
51
|
+
return failure instanceof Error ? failure.message : String(failure);
|
|
52
|
+
}
|
|
@@ -9,7 +9,7 @@ import { errorFor, HubError, readErrorFields, UNSUPPORTED_BY_HUB } from '../erro
|
|
|
9
9
|
import { isRecord } from '../storage/json-guards';
|
|
10
10
|
import { HUB_CLIENT_VERSION } from '../version/client-version.gen';
|
|
11
11
|
import type { EndpointFields, EndpointName, EndpointReply } from './endpoint-types';
|
|
12
|
-
import { ROUTES } from './routes.gen';
|
|
12
|
+
import { OFFLINE_OP_HEADER, ROUTES } from './routes.gen';
|
|
13
13
|
|
|
14
14
|
/** The header that tells the hub which client version is calling. */
|
|
15
15
|
export const CLIENT_VERSION_HEADER = 'x-hub-client';
|
|
@@ -38,6 +38,12 @@ export interface RestClient {
|
|
|
38
38
|
readonly apiBase: string;
|
|
39
39
|
/** Call an endpoint; resolves with its reply body, rejects with a {@link HubError}. */
|
|
40
40
|
call<K extends EndpointName>(name: K, ...fieldsArgument: CallArguments<K>): Promise<EndpointReply<K>>;
|
|
41
|
+
/**
|
|
42
|
+
* Call an endpoint as part of an offline operation: an endpoint marked for
|
|
43
|
+
* offline replay gets `opKey` in the operation header, so the hub runs it
|
|
44
|
+
* once however often it is sent; any other endpoint is called as usual.
|
|
45
|
+
*/
|
|
46
|
+
callOnce<K extends EndpointName>(opKey: string, name: K, ...fieldsArgument: CallArguments<K>): Promise<EndpointReply<K>>;
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
/** One field value as URL text. */
|
|
@@ -126,14 +132,18 @@ async function failureOf(name: EndpointName, response: Response): Promise<HubErr
|
|
|
126
132
|
/** A REST client for one API base. */
|
|
127
133
|
export function createRestClient(options: RestClientOptions): RestClient {
|
|
128
134
|
// eslint-disable-next-line no-restricted-syntax -- trust boundary: the caller's typed fields, walked generically
|
|
129
|
-
async function send<Reply>(name: EndpointName, fields: unknown): Promise<Reply> {
|
|
135
|
+
async function send<Reply>(name: EndpointName, fields: unknown, opKey?: string): Promise<Reply> {
|
|
130
136
|
const planned = planRequest(name, fields);
|
|
131
137
|
let response: Response;
|
|
132
138
|
try {
|
|
133
139
|
response = await options.fetch(options.apiBase + planned.pathAndQuery, {
|
|
134
140
|
method: planned.method,
|
|
135
141
|
credentials: 'same-origin',
|
|
136
|
-
headers: {
|
|
142
|
+
headers: {
|
|
143
|
+
'content-type': 'application/json',
|
|
144
|
+
[CLIENT_VERSION_HEADER]: HUB_CLIENT_VERSION,
|
|
145
|
+
...(opKey !== undefined && ROUTES[name].offline === 'replay' ? { [OFFLINE_OP_HEADER]: opKey } : {}),
|
|
146
|
+
},
|
|
137
147
|
...(planned.bodyText === undefined ? {} : { body: planned.bodyText }),
|
|
138
148
|
});
|
|
139
149
|
} catch (failure) {
|
|
@@ -148,5 +158,6 @@ export function createRestClient(options: RestClientOptions): RestClient {
|
|
|
148
158
|
return {
|
|
149
159
|
apiBase: options.apiBase,
|
|
150
160
|
call: (name, ...fieldsArgument) => send(name, fieldsArgument[0]),
|
|
161
|
+
callOnce: (opKey, name, ...fieldsArgument) => send(name, fieldsArgument[0], opKey),
|
|
151
162
|
};
|
|
152
163
|
}
|
|
@@ -1,90 +1,111 @@
|
|
|
1
1
|
// Generated by tools/hub-client/generate.ts. Do not edit; run `npm run gen:hub-client` in next/.
|
|
2
|
-
/** Every public REST endpoint's method, path (under `/_api`) and
|
|
2
|
+
/** Every public REST endpoint's method, path (under `/_api`), query keys and offline mode (`'replay'` when it may be queued offline), from the contract. */
|
|
3
3
|
export const ROUTES = {
|
|
4
|
-
ensureGuestSession: { method: 'POST', path: '/auth/guest', query: [] },
|
|
5
|
-
signUpWithPassword: { method: 'POST', path: '/auth/signup', query: [] },
|
|
6
|
-
logInWithPassword: { method: 'POST', path: '/auth/login', query: [] },
|
|
7
|
-
logOut: { method: 'POST', path: '/auth/logout', query: [] },
|
|
8
|
-
getCurrentUser: { method: 'GET', path: '/me', query: [] },
|
|
9
|
-
claimGuestAccount: { method: 'POST', path: '/auth/claim', query: [] },
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
4
|
+
ensureGuestSession: { method: 'POST', path: '/auth/guest', query: [], offline: null },
|
|
5
|
+
signUpWithPassword: { method: 'POST', path: '/auth/signup', query: [], offline: null },
|
|
6
|
+
logInWithPassword: { method: 'POST', path: '/auth/login', query: [], offline: null },
|
|
7
|
+
logOut: { method: 'POST', path: '/auth/logout', query: [], offline: null },
|
|
8
|
+
getCurrentUser: { method: 'GET', path: '/me', query: [], offline: null },
|
|
9
|
+
claimGuestAccount: { method: 'POST', path: '/auth/claim', query: [], offline: null },
|
|
10
|
+
getGamePlayerCounts: { method: 'GET', path: '/catalog/players', query: [], offline: null },
|
|
11
|
+
getMyGameActivity: { method: 'GET', path: '/games/:slug/me/activity', query: [], offline: null },
|
|
12
|
+
listGames: { method: 'GET', path: '/games', query: [], offline: null },
|
|
13
|
+
listWhatsNewNotes: { method: 'GET', path: '/changes', query: ['game', 'since'], offline: null },
|
|
14
|
+
listFavoriteGames: { method: 'GET', path: '/favorites', query: [], offline: null },
|
|
15
|
+
setGameFavorite: { method: 'PUT', path: '/favorites/:slug', query: [], offline: 'replay' },
|
|
16
|
+
markWhatsNewSeen: { method: 'POST', path: '/changes/seen', query: [], offline: null },
|
|
17
|
+
getGameStats: { method: 'GET', path: '/games/:slug/stats', query: ['user'], offline: null },
|
|
18
|
+
getGameProfile: { method: 'GET', path: '/games/:slug/profile', query: [], offline: null },
|
|
19
|
+
setGameProfile: { method: 'PUT', path: '/games/:slug/profile', query: [], offline: 'replay' },
|
|
20
|
+
listSaves: { method: 'GET', path: '/games/:slug/saves', query: [], offline: null },
|
|
21
|
+
getSave: { method: 'GET', path: '/games/:slug/saves/:slot', query: [], offline: null },
|
|
22
|
+
writeSave: { method: 'PUT', path: '/games/:slug/saves/:slot', query: [], offline: 'replay' },
|
|
23
|
+
deleteSave: { method: 'DELETE', path: '/games/:slug/saves/:slot', query: [], offline: 'replay' },
|
|
24
|
+
updateGameStat: { method: 'POST', path: '/games/:slug/stats', query: [], offline: 'replay' },
|
|
25
|
+
resetMyGameProgress: { method: 'POST', path: '/games/:slug/reset', query: [], offline: null },
|
|
26
|
+
listMyLeaderboardRecords: { method: 'GET', path: '/me/records', query: ['game'], offline: null },
|
|
27
|
+
getMyOfflineSnapshot: { method: 'GET', path: '/me/snapshot', query: ['top'], offline: null },
|
|
28
|
+
listTopOfEveryLeaderboard: { method: 'GET', path: '/leaderboards', query: ['top'], offline: null },
|
|
29
|
+
listGameLeaderboards: { method: 'GET', path: '/games/:slug/leaderboards', query: [], offline: null },
|
|
30
|
+
getLeaderboard: { method: 'GET', path: '/games/:slug/leaderboards/:key', query: ['limit'], offline: null },
|
|
31
|
+
submitScore: { method: 'POST', path: '/games/:slug/leaderboards/:key/scores', query: [], offline: 'replay' },
|
|
32
|
+
listDailyChallengesForAllGames: { method: 'GET', path: '/daily', query: [], offline: null },
|
|
33
|
+
getGameDailyChallenges: { method: 'GET', path: '/games/:slug/daily', query: [], offline: null },
|
|
34
|
+
startDailyChallenge: { method: 'POST', path: '/games/:slug/daily/:day/start', query: [], offline: null },
|
|
35
|
+
completeDailyChallenge: { method: 'POST', path: '/games/:slug/daily/:day/complete', query: [], offline: 'replay' },
|
|
36
|
+
getMyInventory: { method: 'GET', path: '/games/:slug/inventory', query: [], offline: null },
|
|
37
|
+
grantInventoryItems: { method: 'POST', path: '/games/:slug/inventory/grant', query: [], offline: 'replay' },
|
|
38
|
+
exchangeInventoryItems: { method: 'POST', path: '/games/:slug/inventory/exchange', query: [], offline: 'replay' },
|
|
39
|
+
getPlayerInventory: { method: 'GET', path: '/games/:slug/players/:userId/inventory', query: [], offline: null },
|
|
40
|
+
listTradePartners: { method: 'GET', path: '/games/:slug/trades/players', query: [], offline: null },
|
|
41
|
+
listMyTrades: { method: 'GET', path: '/games/:slug/trades', query: ['box', 'status'], offline: null },
|
|
42
|
+
declineTrade: { method: 'POST', path: '/games/:slug/trades/:id/decline', query: [], offline: null },
|
|
43
|
+
cancelTrade: { method: 'POST', path: '/games/:slug/trades/:id/cancel', query: [], offline: null },
|
|
44
|
+
createTrade: { method: 'POST', path: '/games/:slug/trades', query: [], offline: null },
|
|
45
|
+
acceptTrade: { method: 'POST', path: '/games/:slug/trades/:id/accept', query: [], offline: null },
|
|
46
|
+
counterTradeOffer: { method: 'POST', path: '/games/:slug/trades/:id/counter', query: [], offline: null },
|
|
47
|
+
getSiteAnalytics: { method: 'GET', path: '/analytics', query: [], offline: null },
|
|
48
|
+
reportClientError: { method: 'POST', path: '/client-log', query: [], offline: null },
|
|
49
|
+
recordGameEvent: { method: 'POST', path: '/games/:slug/events', query: [], offline: null },
|
|
50
|
+
getGameAnalytics: { method: 'GET', path: '/games/:slug/analytics', query: [], offline: null },
|
|
51
|
+
getSocialConfig: { method: 'GET', path: '/social/config', query: [], offline: null },
|
|
52
|
+
getOnlineCounts: { method: 'GET', path: '/social/counts', query: [], offline: null },
|
|
53
|
+
getMySocialProfile: { method: 'GET', path: '/social/me', query: [], offline: null },
|
|
54
|
+
listFriendsAndRequests: { method: 'GET', path: '/social/friends', query: [], offline: null },
|
|
55
|
+
findPlayerByName: { method: 'GET', path: '/social/players/by-name/:name', query: [], offline: null },
|
|
56
|
+
getPlayerCard: { method: 'GET', path: '/social/players/:id', query: [], offline: null },
|
|
57
|
+
updateMySocialProfile: { method: 'PUT', path: '/social/me', query: [], offline: null },
|
|
58
|
+
setParentChatLock: { method: 'POST', path: '/social/chat-lock', query: [], offline: null },
|
|
59
|
+
sendFriendRequest: { method: 'POST', path: '/social/friends', query: [], offline: null },
|
|
60
|
+
acceptFriendRequest: { method: 'POST', path: '/social/friends/:id/accept', query: [], offline: null },
|
|
61
|
+
declineFriendRequest: { method: 'POST', path: '/social/friends/:id/decline', query: [], offline: null },
|
|
62
|
+
removeFriend: { method: 'DELETE', path: '/social/friends/:id', query: [], offline: null },
|
|
63
|
+
setFriendNotifications: { method: 'PUT', path: '/social/friends/:id/notify', query: [], offline: null },
|
|
64
|
+
blockPlayer: { method: 'POST', path: '/social/blocks', query: [], offline: null },
|
|
65
|
+
unblockPlayer: { method: 'DELETE', path: '/social/blocks/:id', query: [], offline: null },
|
|
66
|
+
listRecentPlayers: { method: 'GET', path: '/social/recent', query: [], offline: null },
|
|
67
|
+
listConversations: { method: 'GET', path: '/social/conversations', query: [], offline: null },
|
|
68
|
+
listDirectMessages: { method: 'GET', path: '/social/messages/:userId', query: ['before'], offline: null },
|
|
69
|
+
sendDirectMessage: { method: 'POST', path: '/social/messages/:userId', query: [], offline: null },
|
|
70
|
+
markDirectMessagesRead: { method: 'POST', path: '/social/messages/:userId/read', query: [], offline: null },
|
|
71
|
+
getNotificationPreferences: { method: 'GET', path: '/social/notify', query: [], offline: null },
|
|
72
|
+
unsubscribeFromPush: { method: 'DELETE', path: '/social/push', query: [], offline: null },
|
|
73
|
+
setNotificationPreferences: { method: 'PUT', path: '/social/notify', query: [], offline: null },
|
|
74
|
+
subscribeToPush: { method: 'POST', path: '/social/push', query: [], offline: null },
|
|
75
|
+
fileReport: { method: 'POST', path: '/social/reports', query: [], offline: null },
|
|
76
|
+
listPublicSuggestions: { method: 'GET', path: '/suggestions', query: ['sort'], offline: null },
|
|
77
|
+
submitSuggestion: { method: 'POST', path: '/suggestions', query: [], offline: null },
|
|
78
|
+
voteForSuggestion: { method: 'POST', path: '/suggestions/:id/vote', query: [], offline: null },
|
|
79
|
+
removeSuggestionVote: { method: 'DELETE', path: '/suggestions/:id/vote', query: [], offline: null },
|
|
80
|
+
withdrawSuggestion: { method: 'DELETE', path: '/suggestions/:id', query: [], offline: null },
|
|
81
|
+
sendGameInvite: { method: 'POST', path: '/mp/invites', query: [], offline: null },
|
|
82
|
+
acceptGameInvite: { method: 'POST', path: '/mp/invites/:id/accept', query: [], offline: null },
|
|
83
|
+
declineGameInvite: { method: 'POST', path: '/mp/invites/:id/decline', query: [], offline: null },
|
|
84
|
+
createInviteLink: { method: 'POST', path: '/mp/links', query: [], offline: null },
|
|
85
|
+
listInviteLinks: { method: 'GET', path: '/mp/links', query: ['game'], offline: null },
|
|
86
|
+
revokeInviteLink: { method: 'DELETE', path: '/mp/links/:token', query: [], offline: null },
|
|
87
|
+
cancelGameInvite: { method: 'DELETE', path: '/mp/invites/:id', query: [], offline: null },
|
|
88
|
+
redeemLaunchToken: { method: 'GET', path: '/mp/launch/:token', query: [], offline: null },
|
|
89
|
+
openInviteLink: { method: 'POST', path: '/mp/links/:token/open', query: [], offline: null },
|
|
90
|
+
setPartyJoinInfo: { method: 'POST', path: '/mp/parties/:id/join-info', query: [], offline: null },
|
|
91
|
+
leaveParty: { method: 'POST', path: '/mp/parties/:id/leave', query: [], offline: null },
|
|
92
|
+
launchIntoFriendsRoom: { method: 'POST', path: '/mp/join', query: [], offline: null },
|
|
93
|
+
getGameServerTicket: { method: 'POST', path: '/mp/ticket', query: [], offline: null },
|
|
94
|
+
listMyRaceHistory: { method: 'GET', path: '/race/history', query: ['game', 'with', 'before', 'limit'], offline: null },
|
|
95
|
+
listTournaments: { method: 'GET', path: '/tournaments', query: ['game'], offline: null },
|
|
96
|
+
getTournament: { method: 'GET', path: '/tournaments/:id', query: ['key'], offline: null },
|
|
97
|
+
createTournament: { method: 'POST', path: '/tournaments', query: [], offline: null },
|
|
98
|
+
inviteToTournament: { method: 'POST', path: '/tournaments/:id/invites', query: [], offline: null },
|
|
99
|
+
followTournament: { method: 'PUT', path: '/tournaments/:id/follow', query: [], offline: null },
|
|
100
|
+
unfollowTournament: { method: 'DELETE', path: '/tournaments/:id/follow', query: [], offline: null },
|
|
101
|
+
startTournament: { method: 'POST', path: '/tournaments/:id/start', query: [], offline: null },
|
|
102
|
+
cancelTournament: { method: 'DELETE', path: '/tournaments/:id', query: [], offline: null },
|
|
103
|
+
joinTournament: { method: 'POST', path: '/tournaments/:id/join', query: [], offline: null },
|
|
104
|
+
leaveTournament: { method: 'POST', path: '/tournaments/:id/leave', query: [], offline: null },
|
|
105
|
+
launchTournamentMatch: { method: 'POST', path: '/tournaments/:id/matches/:match/launch', query: [], offline: null },
|
|
106
|
+
getPlayOnline: { method: 'GET', path: '/play-online', query: [], offline: null },
|
|
107
|
+
getHubVersion: { method: 'GET', path: '/version', query: [], offline: null },
|
|
90
108
|
} as const;
|
|
109
|
+
|
|
110
|
+
/** The request header that carries an offline operation key (the contract's `OFFLINE_OP_HEADER`). */
|
|
111
|
+
export const OFFLINE_OP_HEADER = 'x-hub-op';
|
|
@@ -72,3 +72,9 @@ export function parseJsonText(text: string): unknown {
|
|
|
72
72
|
return undefined;
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
+
|
|
76
|
+
/** Whether `candidate` is a JSON object (not an array or `null`). */
|
|
77
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: checks a value read from storage
|
|
78
|
+
export function isJsonObject(candidate: unknown): candidate is { readonly [key: string]: Json } {
|
|
79
|
+
return isRecord(candidate) && Object.values(candidate).every(isJson);
|
|
80
|
+
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Generated by tools/hub-client/generate.ts. Do not edit; run `npm run gen:hub-client` in next/.
|
|
2
2
|
/** This client's package version, sent as `x-hub-client` on REST calls and `client` in `hello`. */
|
|
3
|
-
export const HUB_CLIENT_VERSION = '2.0.0-next.
|
|
3
|
+
export const HUB_CLIENT_VERSION = '2.0.0-next.2';
|
|
@@ -113,7 +113,9 @@ export class DailySystem {
|
|
|
113
113
|
/**
|
|
114
114
|
* Record completion of the active challenge with optional metadata.
|
|
115
115
|
* Resolves with the new completion count and rank, or `null` when no
|
|
116
|
-
* challenge is active or the hub
|
|
116
|
+
* challenge is active, the hub refused, or the hub can't be reached: then
|
|
117
|
+
* the completion is queued (`hub:daily-queued`) and replays when it is
|
|
118
|
+
* back (`hub:daily-complete` follows).
|
|
117
119
|
*/
|
|
118
120
|
async complete(meta: DailyCompletionMeta = {}): Promise<DailyCompleteResponse | null> {
|
|
119
121
|
const challenge = this.active;
|
|
@@ -123,6 +125,11 @@ export class DailySystem {
|
|
|
123
125
|
try {
|
|
124
126
|
const completed = await this.host.complete(challenge.day, challenge.token, meta);
|
|
125
127
|
this.active = null;
|
|
128
|
+
if (completed === null) {
|
|
129
|
+
// Queued offline: `hub:daily-complete` follows when it replays.
|
|
130
|
+
this.host.emit('hub:daily-queued', { day: challenge.day, queued: true, meta });
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
126
133
|
this.host.emit('hub:daily-complete', { day: challenge.day, count: completed.count, rank: completed.rank, meta });
|
|
127
134
|
return completed;
|
|
128
135
|
} catch (error) {
|
package/src/daily/types.ts
CHANGED
|
@@ -54,8 +54,8 @@ export interface DailyHost {
|
|
|
54
54
|
all(): Promise<DailyAll>;
|
|
55
55
|
/** `POST /games/:slug/daily/:day/start`. */
|
|
56
56
|
start(day: string): Promise<DailyStartResponse>;
|
|
57
|
-
/** `POST /games/:slug/daily/:day/complete
|
|
58
|
-
complete(day: string, token: string, meta: DailyCompletionMeta): Promise<DailyCompleteResponse>;
|
|
57
|
+
/** `POST /games/:slug/daily/:day/complete`; `null` when it was queued offline, to replay when the hub is back. */
|
|
58
|
+
complete(day: string, token: string, meta: DailyCompletionMeta): Promise<DailyCompleteResponse | null>;
|
|
59
59
|
/** Fire a `hub:*` window event (the menu listens). */
|
|
60
60
|
emit(name: string, detail: DailyEventDetail): void;
|
|
61
61
|
}
|
|
@@ -64,4 +64,5 @@ export interface DailyHost {
|
|
|
64
64
|
export type DailyEventDetail =
|
|
65
65
|
| { day: string; seed: number; alreadyCompleted: boolean }
|
|
66
66
|
| { day: string; count: number; rank: number | null; meta: DailyCompletionMeta }
|
|
67
|
+
| { day: string; queued: true; meta: DailyCompletionMeta }
|
|
67
68
|
| { day: string; error: string };
|
package/src/doctor/checks.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
-
import { GamePackageJson, type HubVersion } from '@diffenderfer-games/hub-contract';
|
|
7
|
+
import { GAME_LABELS, GamePackageJson, splitGameLabels, type HubVersion } from '@diffenderfer-games/hub-contract';
|
|
8
8
|
import { clientSupport } from '../core/version/hub-version';
|
|
9
9
|
import { isHubUnreachable, type HubUnreachable } from './hub-version-fetch';
|
|
10
10
|
|
|
@@ -68,7 +68,15 @@ export function checkVendoredCopies(gameDir: string): Finding[] {
|
|
|
68
68
|
export function checkManifest(packageJson: unknown): Finding[] {
|
|
69
69
|
const parsed = GamePackageJson.safeParse(packageJson);
|
|
70
70
|
if (parsed.success) {
|
|
71
|
-
|
|
71
|
+
const unknownLabels = splitGameLabels(parsed.data.game.labels ?? []).unknown;
|
|
72
|
+
return [
|
|
73
|
+
{ level: 'info', check: 'manifest', message: `the game.* block is valid (${parsed.data.game.type})` },
|
|
74
|
+
...unknownLabels.map((label): Finding => ({
|
|
75
|
+
level: 'warning',
|
|
76
|
+
check: 'manifest',
|
|
77
|
+
message: `game.labels: "${label}" is not a known label (the catalog ignores it); use any of ${GAME_LABELS.join(', ')}`,
|
|
78
|
+
})),
|
|
79
|
+
];
|
|
72
80
|
}
|
|
73
81
|
return parsed.error.issues.map((issue) => ({
|
|
74
82
|
level: 'error',
|
package/src/index.ts
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* {@link createHub} for others. Every call rejects with a {@link HubError}
|
|
14
14
|
* when it fails, except the fire-and-forget analytics (`recordPlay`,
|
|
15
15
|
* `heartbeat`, `event`). When the hub can't be reached, reads come from an
|
|
16
|
-
* offline cache and writes are queued until it is back
|
|
16
|
+
* offline cache and writes are queued in an outbox until it is back; games
|
|
17
|
+
* queue their own operations with `hub.offline` (docs: "Offline and replay").
|
|
17
18
|
*
|
|
18
19
|
* The page-wide namespaces hang off `hub` as lazy getters (`hub.input`,
|
|
19
20
|
* `hub.daily`, `hub.social`, `hub.mp`, `hub.rooms`, `hub.race`), and each has
|
|
@@ -36,6 +37,10 @@ export type { AuthResponse, ProfileUpdate, StatResult } from './core/hub-progres
|
|
|
36
37
|
export type { GrantBundle, TradeBundle, TradeId, TradeListOptions, TradeNoteOptions } from './core/hub-economy';
|
|
37
38
|
export type { HubMenuControl } from './core/menu-control';
|
|
38
39
|
export type { QueueOp, QueueOpInput, QueuedScoreOptions, ProfilePatch } from './core/offline/queue';
|
|
40
|
+
export { OfflineApi } from './core/offline/offline-api';
|
|
41
|
+
export { OUTBOX_FULL_CODE, type OfflineEventDetails, type OfflineEventName, type OfflineListener, type OfflineOp } from './core/offline/outbox';
|
|
42
|
+
export type { OfflineHandler, OfflineKindDefinition, ReplayContext } from './core/offline/outbox-kinds';
|
|
43
|
+
export { OUTBOX_MAX_CHARS, OUTBOX_MAX_ENTRIES } from './core/offline/outbox-store';
|
|
39
44
|
export type { BufferedEvent } from './core/offline/events';
|
|
40
45
|
export type { EndpointFields, EndpointName, EndpointReply } from './core/rest/endpoint-types';
|
|
41
46
|
export { CLIENT_VERSION_HEADER } from './core/rest/rest-client';
|
|
@@ -132,13 +132,20 @@ export class InputRegistry {
|
|
|
132
132
|
return { value: raw, raw, isDown: this.wentDown(name), isUp: this.wentUp(name) };
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
/**
|
|
135
|
+
/**
|
|
136
|
+
* A named axis (or an ad-hoc negative/positive pair) as -1..1: its `x`, or
|
|
137
|
+
* its `y` when it declares only `y` (a vertical one-axis control, like a
|
|
138
|
+
* paddle).
|
|
139
|
+
*/
|
|
136
140
|
axis(name: string, positive?: string): number {
|
|
137
141
|
if (positive !== undefined) {
|
|
138
142
|
return clampUnit(this.value(positive)) - clampUnit(this.value(name));
|
|
139
143
|
}
|
|
140
144
|
const axis = this.axes.get(name);
|
|
141
|
-
|
|
145
|
+
if (axis === undefined) {
|
|
146
|
+
return this.value(name);
|
|
147
|
+
}
|
|
148
|
+
return readAxisSide(axis.x ?? axis.y, (input) => this.value(input));
|
|
142
149
|
}
|
|
143
150
|
|
|
144
151
|
/** A named axis pair as a vector clamped to the unit circle. */
|
package/src/mp.ts
CHANGED
|
@@ -6,5 +6,5 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export { multiplayerForwarder as mp } from './realtime/multiplayer-forwarder';
|
|
8
8
|
export type * from './realtime/multiplayer-types';
|
|
9
|
-
export type { InviteCreated } from './realtime/wire-types';
|
|
9
|
+
export type { InviteCreated, InviteLink } from './realtime/wire-types';
|
|
10
10
|
export type { Invite, JoinInfo, Launch, LaunchTicket, Party } from '@diffenderfer-games/hub-contract';
|