@diffenderfer-games/hub 2.0.0-next.0 → 2.0.0-next.1
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 +9 -0
- package/LICENSE +21 -21
- package/README.md +191 -139
- package/dist/doctor/cli.mjs +49 -19
- package/dist/doctor/cli.mjs.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.js +1 -1
- package/dist/shared/globals.js +8 -3
- package/dist/shared/globals.js.map +1 -1
- package/dist/shared/index.d.ts +1 -1
- package/package.json +89 -89
- package/src/auto.ts +39 -39
- package/src/core/connection.ts +299 -299
- package/src/core/errors.ts +147 -147
- package/src/core/game-data.ts +38 -38
- package/src/core/hub-economy.ts +179 -179
- package/src/core/hub-progress.ts +205 -205
- package/src/core/hub.ts +131 -131
- package/src/core/menu-control.ts +61 -61
- package/src/core/offline/events.ts +50 -50
- package/src/core/offline/favorites.ts +21 -21
- package/src/core/offline/local-store.ts +261 -261
- package/src/core/offline/queue.ts +171 -171
- package/src/core/page.ts +74 -74
- package/src/core/rest/endpoint-types.ts +22 -22
- package/src/core/rest/rest-client.ts +152 -152
- package/src/core/storage/json-guards.ts +74 -74
- package/src/core/storage/key-value.ts +66 -66
- package/src/core/ticket.ts +43 -43
- package/src/core/types.ts +134 -134
- package/src/core/version/client-version.gen.ts +1 -1
- package/src/core/version/hub-version.ts +62 -62
- package/src/core/version/semver.ts +97 -97
- package/src/daily/daily-system.ts +161 -161
- package/src/daily/page-daily.ts +36 -36
- package/src/daily/types.ts +67 -67
- package/src/daily.ts +10 -10
- package/src/doctor/checks.ts +78 -76
- package/src/doctor/cli.ts +98 -106
- package/src/doctor/hub-version-fetch.ts +43 -0
- package/src/doctor/tsconfig.json +12 -12
- package/src/globals.ts +115 -110
- package/src/index.ts +50 -50
- package/src/input/device-state.ts +146 -146
- package/src/input/edges.ts +55 -55
- package/src/input/gamepad.ts +61 -61
- package/src/input/input-events.ts +32 -32
- package/src/input/input-registry.ts +164 -164
- package/src/input/input-system.ts +393 -393
- package/src/input/legacy-controls.ts +64 -64
- package/src/input/nav-state.ts +191 -191
- package/src/input/navigator.ts +171 -171
- package/src/input/on-screen-controls.ts +101 -101
- package/src/input/page-control.ts +43 -43
- package/src/input/page-input.ts +193 -193
- package/src/input/player-remaps.ts +77 -77
- package/src/input/source-values.ts +116 -116
- package/src/input/spatial.ts +47 -47
- package/src/input/types.ts +331 -331
- package/src/input/zero-config.ts +177 -177
- package/src/input.ts +22 -22
- package/src/mp.ts +10 -10
- package/src/overlay/controls-panel.ts +163 -163
- package/src/overlay/dom.ts +46 -46
- package/src/overlay/layout-clusters.ts +162 -162
- package/src/overlay/layout-geometry.ts +136 -136
- package/src/overlay/layout.ts +203 -203
- package/src/overlay/listener-scope.ts +23 -23
- package/src/overlay/on-screen-keyboard.ts +137 -137
- package/src/overlay/overlay.ts +208 -208
- package/src/overlay/styles.ts +9 -9
- package/src/overlay/virtual-controls.ts +262 -262
- package/src/race.ts +10 -10
- package/src/realtime/bridge.ts +121 -121
- package/src/realtime/cross-bundle.ts +29 -29
- package/src/realtime/fallback-notice.ts +48 -48
- package/src/realtime/hub-link.ts +65 -65
- package/src/realtime/latest-state.ts +33 -33
- package/src/realtime/multiplayer-forwarder.ts +28 -28
- package/src/realtime/multiplayer-types.ts +52 -52
- package/src/realtime/page-runtime.ts +60 -60
- package/src/realtime/race-forwarder.ts +59 -59
- package/src/realtime/race-types.ts +132 -132
- package/src/realtime/room-types.ts +125 -125
- package/src/realtime/rooms-forwarder.ts +79 -79
- package/src/realtime/runtime-api.ts +49 -49
- package/src/realtime/runtime-opener.ts +61 -61
- package/src/realtime/social-forwarder.ts +72 -72
- package/src/realtime/social-types.ts +78 -78
- package/src/realtime/types.ts +53 -53
- package/src/realtime/wire-types.ts +72 -72
- package/src/rooms.ts +10 -10
- package/src/social.ts +18 -18
- package/src/types.ts +13 -13
- package/src/uistack/page-stack.ts +45 -45
- package/src/uistack/play-suspension.ts +82 -82
- package/src/uistack/types.ts +76 -76
- package/src/uistack/ui-stack.ts +144 -144
package/src/daily/types.ts
CHANGED
|
@@ -1,67 +1,67 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Daily challenge shapes: what a game's `play` receives, and the hub's
|
|
3
|
-
* answers (typed from the contract's daily endpoints).
|
|
4
|
-
*/
|
|
5
|
-
import type { dailyEndpoints, Json } from '@diffenderfer-games/hub-contract';
|
|
6
|
-
import type { SchemaOutput } from '../realtime/wire-types';
|
|
7
|
-
|
|
8
|
-
/** The response type of a daily endpoint. */
|
|
9
|
-
type DailyResponse<Name extends keyof typeof dailyEndpoints> = SchemaOutput<(typeof dailyEndpoints)[Name]['cfg']['response']>;
|
|
10
|
-
|
|
11
|
-
/** This game's daily window and the player's completions (`GET /games/:slug/daily`). */
|
|
12
|
-
export type DailyStatus = DailyResponse<'getGameDailyChallenges'>;
|
|
13
|
-
|
|
14
|
-
/** Every daily game and the global standings (`GET /daily`). */
|
|
15
|
-
export type DailyAll = DailyResponse<'listDailyChallengesForAllGames'>;
|
|
16
|
-
|
|
17
|
-
/** One game's row in {@link DailyAll}. */
|
|
18
|
-
export type DailyGameStatus = DailyAll['games'][number];
|
|
19
|
-
|
|
20
|
-
/** The answer to starting a day: its seed and the signed token to present on completion. */
|
|
21
|
-
export type DailyStartResponse = DailyResponse<'startDailyChallenge'>;
|
|
22
|
-
|
|
23
|
-
/** The answer to completing a day: the player's new completion count and rank. */
|
|
24
|
-
export type DailyCompleteResponse = DailyResponse<'completeDailyChallenge'>;
|
|
25
|
-
|
|
26
|
-
/** The challenge a game's `play` receives: set up and start exactly this day (derive the level from `seed`). */
|
|
27
|
-
export interface DailyChallenge {
|
|
28
|
-
/** The day, `YYYY-MM-DD`. */
|
|
29
|
-
day: string;
|
|
30
|
-
/** Deterministic per game and day. */
|
|
31
|
-
seed: number;
|
|
32
|
-
/** The signed token `complete` presents. */
|
|
33
|
-
token: string;
|
|
34
|
-
/** The player already completed this day (a replay). */
|
|
35
|
-
alreadyCompleted?: boolean;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** What `hub.daily.define` takes. */
|
|
39
|
-
export interface DailyDefinition {
|
|
40
|
-
/** Start this challenge now. */
|
|
41
|
-
play: (challenge: DailyChallenge) => void;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Metadata a game records with a completion (`{ score, difficulty, timeMs, … }`). */
|
|
45
|
-
export type DailyCompletionMeta = Record<string, Json>;
|
|
46
|
-
|
|
47
|
-
/** What the daily system needs from the hub client: the REST calls and its window events. */
|
|
48
|
-
export interface DailyHost {
|
|
49
|
-
/** This page's game slug. */
|
|
50
|
-
readonly slug: string | null;
|
|
51
|
-
/** `GET /games/:slug/daily`. */
|
|
52
|
-
status(): Promise<DailyStatus>;
|
|
53
|
-
/** `GET /daily`. */
|
|
54
|
-
all(): Promise<DailyAll>;
|
|
55
|
-
/** `POST /games/:slug/daily/:day/start`. */
|
|
56
|
-
start(day: string): Promise<DailyStartResponse>;
|
|
57
|
-
/** `POST /games/:slug/daily/:day/complete`. */
|
|
58
|
-
complete(day: string, token: string, meta: DailyCompletionMeta): Promise<DailyCompleteResponse>;
|
|
59
|
-
/** Fire a `hub:*` window event (the menu listens). */
|
|
60
|
-
emit(name: string, detail: DailyEventDetail): void;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** The detail of the `hub:daily-*` window events. */
|
|
64
|
-
export type DailyEventDetail =
|
|
65
|
-
| { day: string; seed: number; alreadyCompleted: boolean }
|
|
66
|
-
| { day: string; count: number; rank: number | null; meta: DailyCompletionMeta }
|
|
67
|
-
| { day: string; error: string };
|
|
1
|
+
/**
|
|
2
|
+
* Daily challenge shapes: what a game's `play` receives, and the hub's
|
|
3
|
+
* answers (typed from the contract's daily endpoints).
|
|
4
|
+
*/
|
|
5
|
+
import type { dailyEndpoints, Json } from '@diffenderfer-games/hub-contract';
|
|
6
|
+
import type { SchemaOutput } from '../realtime/wire-types';
|
|
7
|
+
|
|
8
|
+
/** The response type of a daily endpoint. */
|
|
9
|
+
type DailyResponse<Name extends keyof typeof dailyEndpoints> = SchemaOutput<(typeof dailyEndpoints)[Name]['cfg']['response']>;
|
|
10
|
+
|
|
11
|
+
/** This game's daily window and the player's completions (`GET /games/:slug/daily`). */
|
|
12
|
+
export type DailyStatus = DailyResponse<'getGameDailyChallenges'>;
|
|
13
|
+
|
|
14
|
+
/** Every daily game and the global standings (`GET /daily`). */
|
|
15
|
+
export type DailyAll = DailyResponse<'listDailyChallengesForAllGames'>;
|
|
16
|
+
|
|
17
|
+
/** One game's row in {@link DailyAll}. */
|
|
18
|
+
export type DailyGameStatus = DailyAll['games'][number];
|
|
19
|
+
|
|
20
|
+
/** The answer to starting a day: its seed and the signed token to present on completion. */
|
|
21
|
+
export type DailyStartResponse = DailyResponse<'startDailyChallenge'>;
|
|
22
|
+
|
|
23
|
+
/** The answer to completing a day: the player's new completion count and rank. */
|
|
24
|
+
export type DailyCompleteResponse = DailyResponse<'completeDailyChallenge'>;
|
|
25
|
+
|
|
26
|
+
/** The challenge a game's `play` receives: set up and start exactly this day (derive the level from `seed`). */
|
|
27
|
+
export interface DailyChallenge {
|
|
28
|
+
/** The day, `YYYY-MM-DD`. */
|
|
29
|
+
day: string;
|
|
30
|
+
/** Deterministic per game and day. */
|
|
31
|
+
seed: number;
|
|
32
|
+
/** The signed token `complete` presents. */
|
|
33
|
+
token: string;
|
|
34
|
+
/** The player already completed this day (a replay). */
|
|
35
|
+
alreadyCompleted?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What `hub.daily.define` takes. */
|
|
39
|
+
export interface DailyDefinition {
|
|
40
|
+
/** Start this challenge now. */
|
|
41
|
+
play: (challenge: DailyChallenge) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Metadata a game records with a completion (`{ score, difficulty, timeMs, … }`). */
|
|
45
|
+
export type DailyCompletionMeta = Record<string, Json>;
|
|
46
|
+
|
|
47
|
+
/** What the daily system needs from the hub client: the REST calls and its window events. */
|
|
48
|
+
export interface DailyHost {
|
|
49
|
+
/** This page's game slug. */
|
|
50
|
+
readonly slug: string | null;
|
|
51
|
+
/** `GET /games/:slug/daily`. */
|
|
52
|
+
status(): Promise<DailyStatus>;
|
|
53
|
+
/** `GET /daily`. */
|
|
54
|
+
all(): Promise<DailyAll>;
|
|
55
|
+
/** `POST /games/:slug/daily/:day/start`. */
|
|
56
|
+
start(day: string): Promise<DailyStartResponse>;
|
|
57
|
+
/** `POST /games/:slug/daily/:day/complete`. */
|
|
58
|
+
complete(day: string, token: string, meta: DailyCompletionMeta): Promise<DailyCompleteResponse>;
|
|
59
|
+
/** Fire a `hub:*` window event (the menu listens). */
|
|
60
|
+
emit(name: string, detail: DailyEventDetail): void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The detail of the `hub:daily-*` window events. */
|
|
64
|
+
export type DailyEventDetail =
|
|
65
|
+
| { day: string; seed: number; alreadyCompleted: boolean }
|
|
66
|
+
| { day: string; count: number; rank: number | null; meta: DailyCompletionMeta }
|
|
67
|
+
| { day: string; error: string };
|
package/src/daily.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `@diffenderfer-games/hub/daily`: daily challenges (`hub.daily`): the
|
|
3
|
-
* {@link DailySystem} and the page-wide {@link getDaily}.
|
|
4
|
-
*
|
|
5
|
-
* @packageDocumentation
|
|
6
|
-
*/
|
|
7
|
-
export { DAILY_START_EVENT, DailySystem } from './daily/daily-system';
|
|
8
|
-
export { DAILY_SINGLETON_API, getDaily, isDailySystem } from './daily/page-daily';
|
|
9
|
-
export type * from './daily/types';
|
|
10
|
-
export type { DailyCompletion, DailyCompletions } from '@diffenderfer-games/hub-contract';
|
|
1
|
+
/**
|
|
2
|
+
* `@diffenderfer-games/hub/daily`: daily challenges (`hub.daily`): the
|
|
3
|
+
* {@link DailySystem} and the page-wide {@link getDaily}.
|
|
4
|
+
*
|
|
5
|
+
* @packageDocumentation
|
|
6
|
+
*/
|
|
7
|
+
export { DAILY_START_EVENT, DailySystem } from './daily/daily-system';
|
|
8
|
+
export { DAILY_SINGLETON_API, getDaily, isDailySystem } from './daily/page-daily';
|
|
9
|
+
export type * from './daily/types';
|
|
10
|
+
export type { DailyCompletion, DailyCompletions } from '@diffenderfer-games/hub-contract';
|
package/src/doctor/checks.ts
CHANGED
|
@@ -1,76 +1,78 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The checks `hub-doctor` runs on a game (plan §4.3.7). Each returns
|
|
3
|
-
* findings; an `error` finding fails the run (and the game's CI).
|
|
4
|
-
*/
|
|
5
|
-
import { existsSync } from 'node:fs';
|
|
6
|
-
import { join } from 'node:path';
|
|
7
|
-
import { GamePackageJson, type HubVersion } from '@diffenderfer-games/hub-contract';
|
|
8
|
-
import { clientSupport } from '../core/version/hub-version';
|
|
9
|
-
|
|
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
|
-
case '
|
|
39
|
-
return [{ level: '
|
|
40
|
-
case '
|
|
41
|
-
return [{ level: '
|
|
42
|
-
case '
|
|
43
|
-
return [{ level: 'info', check: 'version', message:
|
|
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
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The checks `hub-doctor` runs on a game (plan §4.3.7). Each returns
|
|
3
|
+
* findings; an `error` finding fails the run (and the game's CI).
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { GamePackageJson, type HubVersion } from '@diffenderfer-games/hub-contract';
|
|
8
|
+
import { clientSupport } from '../core/version/hub-version';
|
|
9
|
+
import { isHubUnreachable, type HubUnreachable } from './hub-version-fetch';
|
|
10
|
+
|
|
11
|
+
/** How serious a finding is: `error` fails the run, `warning` and `info` only report. */
|
|
12
|
+
export type FindingLevel = 'error' | 'warning' | 'info';
|
|
13
|
+
|
|
14
|
+
/** One thing the doctor found. */
|
|
15
|
+
export interface Finding {
|
|
16
|
+
/** How serious it is. */
|
|
17
|
+
readonly level: FindingLevel;
|
|
18
|
+
/** Which check found it: `version`, `vendored` or `manifest`. */
|
|
19
|
+
readonly check: 'version' | 'vendored' | 'manifest';
|
|
20
|
+
/** What to tell the developer, with the fix. */
|
|
21
|
+
readonly message: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The fix for an old or unsupported client. */
|
|
25
|
+
const UPDATE_FIX = 'run `npm update @diffenderfer-games/hub`';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The installed client against the hub's `/_api/version`: outside
|
|
29
|
+
* `supported` is an error, `deprecated` a warning, `null` (a hub from before
|
|
30
|
+
* versioning) is only reported. An unreachable or slow hub is an error, so a
|
|
31
|
+
* game's CI notices (`--offline` skips the check).
|
|
32
|
+
*/
|
|
33
|
+
export function checkClientVersion(installedVersion: string, hubVersion: HubVersion | null | HubUnreachable): Finding[] {
|
|
34
|
+
if (isHubUnreachable(hubVersion)) {
|
|
35
|
+
return [{ level: 'error', check: 'version', message: `hub unreachable at ${hubVersion.unreachableUrl} (${hubVersion.reason}); check --hub, or pass --offline to skip the version check` }];
|
|
36
|
+
}
|
|
37
|
+
switch (clientSupport(hubVersion, installedVersion)) {
|
|
38
|
+
case 'unsupported':
|
|
39
|
+
return [{ level: 'error', check: 'version', message: `@diffenderfer-games/hub ${installedVersion} is not supported by the hub (supported: ${hubVersion?.supported ?? '?'}): ${UPDATE_FIX}` }];
|
|
40
|
+
case 'deprecated':
|
|
41
|
+
return [{ level: 'warning', check: 'version', message: `@diffenderfer-games/hub ${installedVersion} is deprecated (deprecated: ${hubVersion?.deprecated ?? '?'}): ${UPDATE_FIX}` }];
|
|
42
|
+
case 'unknown':
|
|
43
|
+
return [{ level: 'info', check: 'version', message: 'the hub predates versioning (no /_api/version); the client version was not checked' }];
|
|
44
|
+
case 'supported':
|
|
45
|
+
return [{ level: 'info', check: 'version', message: `@diffenderfer-games/hub ${installedVersion} is supported (hub ${hubVersion?.server ?? '?'})` }];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Leftovers of the v1 vendored client, relative to the game's folder. */
|
|
50
|
+
const VENDORED_LEFTOVERS = [
|
|
51
|
+
'src/hub/hub.ts',
|
|
52
|
+
'src/hub/input.ts',
|
|
53
|
+
'client/src/hub/hub.ts',
|
|
54
|
+
'scripts/sync-hub.mjs',
|
|
55
|
+
] as const;
|
|
56
|
+
|
|
57
|
+
/** A stale vendored `src/hub/` copy or its sync script (the package replaces both). */
|
|
58
|
+
export function checkVendoredCopies(gameDir: string): Finding[] {
|
|
59
|
+
return VENDORED_LEFTOVERS.filter((leftover) => existsSync(join(gameDir, leftover))).map((leftover) => ({
|
|
60
|
+
level: 'warning',
|
|
61
|
+
check: 'vendored',
|
|
62
|
+
message: `${leftover} is a vendored copy of the v1 client: import from '@diffenderfer-games/hub' instead and delete it`,
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The `package.json` `game.*` block against the contract's manifest schema. */
|
|
67
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: the game's package.json, parsed by GamePackageJson next
|
|
68
|
+
export function checkManifest(packageJson: unknown): Finding[] {
|
|
69
|
+
const parsed = GamePackageJson.safeParse(packageJson);
|
|
70
|
+
if (parsed.success) {
|
|
71
|
+
return [{ level: 'info', check: 'manifest', message: `the game.* block is valid (${parsed.data.game.type})` }];
|
|
72
|
+
}
|
|
73
|
+
return parsed.error.issues.map((issue) => ({
|
|
74
|
+
level: 'error',
|
|
75
|
+
check: 'manifest',
|
|
76
|
+
message: `${issue.path.length === 0 ? '(package.json)' : issue.path.join('.')}: ${issue.message}`,
|
|
77
|
+
}));
|
|
78
|
+
}
|
package/src/doctor/cli.ts
CHANGED
|
@@ -1,106 +1,98 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* `hub-doctor`: checks a game against the hub (plan §4.3.7). Run it in a
|
|
4
|
-
* game's folder, in CI and before pushing:
|
|
5
|
-
*
|
|
6
|
-
* ```sh
|
|
7
|
-
* npx hub-doctor [--dir <game folder>] [--hub <url>] [--offline] [--json]
|
|
8
|
-
* ```
|
|
9
|
-
*
|
|
10
|
-
* - the installed `@diffenderfer-games/hub` against the hub's
|
|
11
|
-
* `/_api/version` (`--hub`, default `https://diffenderfer.games`): outside
|
|
12
|
-
* `supported` fails, `deprecated` warns (`--offline` skips this check);
|
|
13
|
-
* - a stale vendored `src/hub/` copy or `scripts/sync-hub.mjs` warns;
|
|
14
|
-
* - the `package.json` `game.*` block must match the contract's schema.
|
|
15
|
-
*
|
|
16
|
-
* Exits 1 when any check fails.
|
|
17
|
-
*/
|
|
18
|
-
import { readFileSync } from 'node:fs';
|
|
19
|
-
import { join, resolve } from 'node:path';
|
|
20
|
-
import { parseArgs } from 'node:util';
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
const DEFAULT_HUB_URL = 'https://diffenderfer.games';
|
|
26
|
-
|
|
27
|
-
/** The command line, parsed. */
|
|
28
|
-
interface DoctorOptions {
|
|
29
|
-
readonly gameDir: string;
|
|
30
|
-
readonly hubUrl: string;
|
|
31
|
-
|
|
32
|
-
readonly
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (options.isJson) {
|
|
100
|
-
process.stdout.write(`${JSON.stringify({ findings }, null, 2)}\n`);
|
|
101
|
-
} else {
|
|
102
|
-
for (const finding of findings) {
|
|
103
|
-
process.stdout.write(`${finding.level.padEnd(7)} ${finding.check}: ${finding.message}\n`);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
process.exitCode = findings.some((finding) => finding.level === 'error') ? 1 : 0;
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `hub-doctor`: checks a game against the hub (plan §4.3.7). Run it in a
|
|
4
|
+
* game's folder, in CI and before pushing:
|
|
5
|
+
*
|
|
6
|
+
* ```sh
|
|
7
|
+
* npx hub-doctor [--dir <game folder>] [--hub <url>] [--timeout <ms>] [--offline] [--json]
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* - the installed `@diffenderfer-games/hub` against the hub's
|
|
11
|
+
* `/_api/version` (`--hub`, default `https://diffenderfer.games`): outside
|
|
12
|
+
* `supported` fails, `deprecated` warns (`--offline` skips this check);
|
|
13
|
+
* - a stale vendored `src/hub/` copy or `scripts/sync-hub.mjs` warns;
|
|
14
|
+
* - the `package.json` `game.*` block must match the contract's schema.
|
|
15
|
+
*
|
|
16
|
+
* Exits 1 when any check fails.
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { join, resolve } from 'node:path';
|
|
20
|
+
import { parseArgs } from 'node:util';
|
|
21
|
+
import { HUB_CLIENT_VERSION } from '../core/version/client-version.gen';
|
|
22
|
+
import { checkClientVersion, checkManifest, checkVendoredCopies, type Finding } from './checks';
|
|
23
|
+
import { DEFAULT_HUB_TIMEOUT_MS, fetchHubVersion } from './hub-version-fetch';
|
|
24
|
+
|
|
25
|
+
const DEFAULT_HUB_URL = 'https://diffenderfer.games';
|
|
26
|
+
|
|
27
|
+
/** The command line, parsed. */
|
|
28
|
+
interface DoctorOptions {
|
|
29
|
+
readonly gameDir: string;
|
|
30
|
+
readonly hubUrl: string;
|
|
31
|
+
/** How long to wait for the hub, ms (`--timeout`). */
|
|
32
|
+
readonly timeoutMs: number;
|
|
33
|
+
readonly isOffline: boolean;
|
|
34
|
+
readonly isJson: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readOptions(): DoctorOptions {
|
|
38
|
+
const { values } = parseArgs({
|
|
39
|
+
options: {
|
|
40
|
+
dir: { type: 'string' },
|
|
41
|
+
hub: { type: 'string' },
|
|
42
|
+
timeout: { type: 'string' },
|
|
43
|
+
offline: { type: 'boolean', default: false },
|
|
44
|
+
json: { type: 'boolean', default: false },
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
const timeoutMs = values.timeout === undefined ? DEFAULT_HUB_TIMEOUT_MS : Number(values.timeout);
|
|
48
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
49
|
+
process.stderr.write('hub-doctor: --timeout must be a positive number of milliseconds\n');
|
|
50
|
+
process.exit(2);
|
|
51
|
+
}
|
|
52
|
+
return { gameDir: resolve(values.dir ?? '.'), hubUrl: values.hub ?? DEFAULT_HUB_URL, timeoutMs, isOffline: values.offline, isJson: values.json };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: a JSON file from disk, parsed by the caller's schema
|
|
56
|
+
function readJsonFile(path: string): unknown {
|
|
57
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: JSON.parse's result, parsed by the caller's schema
|
|
58
|
+
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'));
|
|
59
|
+
return parsed;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The client version the game has installed (its `node_modules` copy), else this doctor's own. */
|
|
63
|
+
function installedClientVersion(gameDir: string): string {
|
|
64
|
+
try {
|
|
65
|
+
const installed = readJsonFile(join(gameDir, 'node_modules', '@diffenderfer-games', 'hub', 'package.json'));
|
|
66
|
+
if (typeof installed === 'object' && installed !== null && 'version' in installed && typeof installed.version === 'string') {
|
|
67
|
+
return installed.version;
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// Not installed in the game: report this doctor's own version.
|
|
71
|
+
}
|
|
72
|
+
return HUB_CLIENT_VERSION;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function runDoctor(options: DoctorOptions): Promise<Finding[]> {
|
|
76
|
+
const findings: Finding[] = [];
|
|
77
|
+
if (!options.isOffline) {
|
|
78
|
+
findings.push(...checkClientVersion(installedClientVersion(options.gameDir), await fetchHubVersion(options.hubUrl, options.timeoutMs)));
|
|
79
|
+
}
|
|
80
|
+
findings.push(...checkVendoredCopies(options.gameDir));
|
|
81
|
+
try {
|
|
82
|
+
findings.push(...checkManifest(readJsonFile(join(options.gameDir, 'package.json'))));
|
|
83
|
+
} catch (failure) {
|
|
84
|
+
findings.push({ level: 'error', check: 'manifest', message: `could not read package.json: ${failure instanceof Error ? failure.message : String(failure)}` });
|
|
85
|
+
}
|
|
86
|
+
return findings;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const options = readOptions();
|
|
90
|
+
const findings = await runDoctor(options);
|
|
91
|
+
if (options.isJson) {
|
|
92
|
+
process.stdout.write(`${JSON.stringify({ findings }, null, 2)}\n`);
|
|
93
|
+
} else {
|
|
94
|
+
for (const finding of findings) {
|
|
95
|
+
process.stdout.write(`${finding.level.padEnd(7)} ${finding.check}: ${finding.message}\n`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
process.exitCode = findings.some((finding) => finding.level === 'error') ? 1 : 0;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the hub's `GET /_api/version` for `hub-doctor`, with a timeout so a
|
|
3
|
+
* hung or unreachable hub fails a game's CI quickly instead of stalling it.
|
|
4
|
+
*/
|
|
5
|
+
import { HubVersion } from '@diffenderfer-games/hub-contract';
|
|
6
|
+
|
|
7
|
+
/** How long the doctor waits for the hub by default, ms. */
|
|
8
|
+
export const DEFAULT_HUB_TIMEOUT_MS = 10_000;
|
|
9
|
+
|
|
10
|
+
/** The hub could not be asked: down, slow, or answering with an error. */
|
|
11
|
+
export interface HubUnreachable {
|
|
12
|
+
/** The URL that was asked. */
|
|
13
|
+
readonly unreachableUrl: string;
|
|
14
|
+
/** Why, for the message (`timed out after 10000 ms`, `HTTP 502`, a network error). */
|
|
15
|
+
readonly reason: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Whether a version lookup failed. */
|
|
19
|
+
export function isHubUnreachable(lookup: HubVersion | null | HubUnreachable): lookup is HubUnreachable {
|
|
20
|
+
return lookup !== null && 'unreachableUrl' in lookup;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The hub's version: `null` for a hub from before versioning (404), a
|
|
25
|
+
* {@link HubUnreachable} when it can't be read within `timeoutMs`.
|
|
26
|
+
*/
|
|
27
|
+
export async function fetchHubVersion(hubUrl: string, timeoutMs: number = DEFAULT_HUB_TIMEOUT_MS): Promise<HubVersion | null | HubUnreachable> {
|
|
28
|
+
const url = new URL('/_api/version', hubUrl).toString();
|
|
29
|
+
try {
|
|
30
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
31
|
+
if (response.status === 404) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
if (!response.ok) {
|
|
35
|
+
return { unreachableUrl: url, reason: `HTTP ${String(response.status)}` };
|
|
36
|
+
}
|
|
37
|
+
return HubVersion.parse(await response.json());
|
|
38
|
+
} catch (failure) {
|
|
39
|
+
const isTimeout = failure instanceof Error && (failure.name === 'TimeoutError' || failure.name === 'AbortError');
|
|
40
|
+
const reason = isTimeout ? `timed out after ${String(timeoutMs)} ms` : failure instanceof Error ? failure.message : String(failure);
|
|
41
|
+
return { unreachableUrl: url, reason };
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/doctor/tsconfig.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
{
|
|
2
|
-
"extends": "../../tsconfig.json",
|
|
3
|
-
"compilerOptions": {
|
|
4
|
-
"types": [
|
|
5
|
-
"node"
|
|
6
|
-
]
|
|
7
|
-
},
|
|
8
|
-
"include": [
|
|
9
|
-
"."
|
|
10
|
-
],
|
|
11
|
-
"exclude": []
|
|
12
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"types": [
|
|
5
|
+
"node"
|
|
6
|
+
]
|
|
7
|
+
},
|
|
8
|
+
"include": [
|
|
9
|
+
"."
|
|
10
|
+
],
|
|
11
|
+
"exclude": []
|
|
12
|
+
}
|