@diffenderfer-games/hub 2.0.0-next.0 → 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.
Files changed (154) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +21 -21
  3. package/README.md +206 -139
  4. package/dist/auto.d.ts +6 -6
  5. package/dist/auto.js +2 -2
  6. package/dist/doctor/cli.mjs +62 -25
  7. package/dist/doctor/cli.mjs.map +1 -1
  8. package/dist/index.d.ts +6 -6
  9. package/dist/index.js +2 -2
  10. package/dist/mp.d.ts +3 -3
  11. package/dist/race.d.ts +1 -1
  12. package/dist/rooms.d.ts +3 -3
  13. package/dist/shared/bridge.js +9 -3
  14. package/dist/shared/bridge.js.map +1 -1
  15. package/dist/shared/client-version.gen.js +1 -1
  16. package/dist/shared/daily.d.ts +10 -4
  17. package/dist/shared/daily.d.ts.map +1 -1
  18. package/dist/shared/daily.js +12 -2
  19. package/dist/shared/daily.js.map +1 -1
  20. package/dist/shared/globals.js +8 -3
  21. package/dist/shared/globals.js.map +1 -1
  22. package/dist/shared/index.d.ts +314 -35
  23. package/dist/shared/index.d.ts.map +1 -1
  24. package/dist/shared/input.js +7 -2
  25. package/dist/shared/input.js.map +1 -1
  26. package/dist/shared/latest-state.js +1 -1
  27. package/dist/shared/mp.js +10 -2
  28. package/dist/shared/mp.js.map +1 -1
  29. package/dist/shared/multiplayer-types.d.ts +15 -2
  30. package/dist/shared/multiplayer-types.d.ts.map +1 -1
  31. package/dist/shared/race-types.d.ts +1 -1
  32. package/dist/shared/race.js +1 -1
  33. package/dist/shared/room-types.d.ts +28 -3
  34. package/dist/shared/room-types.d.ts.map +1 -1
  35. package/dist/shared/rooms.d.ts.map +1 -1
  36. package/dist/shared/rooms.js +10 -2
  37. package/dist/shared/rooms.js.map +1 -1
  38. package/dist/shared/runtime-opener.js +1 -1
  39. package/dist/shared/social.d.ts +23 -8
  40. package/dist/shared/social.d.ts.map +1 -1
  41. package/dist/shared/social.js +6 -1
  42. package/dist/shared/social.js.map +1 -1
  43. package/dist/shared/src.js +1588 -388
  44. package/dist/shared/src.js.map +1 -1
  45. package/dist/shared/wire-types.d.ts +6 -2
  46. package/dist/shared/wire-types.d.ts.map +1 -1
  47. package/dist/social.d.ts +5 -5
  48. package/dist/social.js +1 -1
  49. package/package.json +89 -89
  50. package/src/auto.ts +39 -39
  51. package/src/core/connection.ts +394 -299
  52. package/src/core/errors.ts +147 -147
  53. package/src/core/game-data.ts +40 -38
  54. package/src/core/hub-economy.ts +216 -179
  55. package/src/core/hub-progress.ts +202 -205
  56. package/src/core/hub.ts +133 -131
  57. package/src/core/menu-control.ts +61 -61
  58. package/src/core/offline/events.ts +50 -50
  59. package/src/core/offline/favorites.ts +21 -21
  60. package/src/core/offline/hub-kinds/daily-kind.ts +68 -0
  61. package/src/core/offline/hub-kinds/economy-kinds.ts +86 -0
  62. package/src/core/offline/hub-kinds/game-fields.ts +2 -0
  63. package/src/core/offline/hub-kinds/index.ts +35 -0
  64. package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
  65. package/src/core/offline/hub-kinds/progress-kinds.ts +148 -0
  66. package/src/core/offline/kind-adapters.ts +48 -0
  67. package/src/core/offline/local-inventory.ts +64 -0
  68. package/src/core/offline/local-store.ts +259 -261
  69. package/src/core/offline/offline-api.ts +65 -0
  70. package/src/core/offline/outbox-entry.ts +59 -0
  71. package/src/core/offline/outbox-kinds.ts +78 -0
  72. package/src/core/offline/outbox-merge.ts +39 -0
  73. package/src/core/offline/outbox-replayer.ts +141 -0
  74. package/src/core/offline/outbox-store.ts +85 -0
  75. package/src/core/offline/outbox.ts +232 -0
  76. package/src/core/offline/queue.ts +126 -171
  77. package/src/core/offline/replay-lease.ts +87 -0
  78. package/src/core/offline/replay-policy.ts +52 -0
  79. package/src/core/page.ts +74 -74
  80. package/src/core/rest/endpoint-types.ts +22 -22
  81. package/src/core/rest/rest-client.ts +163 -152
  82. package/src/core/rest/routes.gen.ts +108 -87
  83. package/src/core/storage/json-guards.ts +80 -74
  84. package/src/core/storage/key-value.ts +66 -66
  85. package/src/core/ticket.ts +43 -43
  86. package/src/core/types.ts +134 -134
  87. package/src/core/version/client-version.gen.ts +1 -1
  88. package/src/core/version/hub-version.ts +62 -62
  89. package/src/core/version/semver.ts +97 -97
  90. package/src/daily/daily-system.ts +168 -161
  91. package/src/daily/page-daily.ts +36 -36
  92. package/src/daily/types.ts +68 -67
  93. package/src/daily.ts +10 -10
  94. package/src/doctor/checks.ts +86 -76
  95. package/src/doctor/cli.ts +98 -106
  96. package/src/doctor/hub-version-fetch.ts +43 -0
  97. package/src/doctor/tsconfig.json +12 -12
  98. package/src/globals.ts +115 -110
  99. package/src/index.ts +55 -50
  100. package/src/input/device-state.ts +146 -146
  101. package/src/input/edges.ts +55 -55
  102. package/src/input/gamepad.ts +61 -61
  103. package/src/input/input-events.ts +32 -32
  104. package/src/input/input-registry.ts +171 -164
  105. package/src/input/input-system.ts +393 -393
  106. package/src/input/legacy-controls.ts +64 -64
  107. package/src/input/nav-state.ts +191 -191
  108. package/src/input/navigator.ts +171 -171
  109. package/src/input/on-screen-controls.ts +101 -101
  110. package/src/input/page-control.ts +43 -43
  111. package/src/input/page-input.ts +193 -193
  112. package/src/input/player-remaps.ts +77 -77
  113. package/src/input/source-values.ts +116 -116
  114. package/src/input/spatial.ts +47 -47
  115. package/src/input/types.ts +331 -331
  116. package/src/input/zero-config.ts +177 -177
  117. package/src/input.ts +22 -22
  118. package/src/mp.ts +10 -10
  119. package/src/overlay/controls-panel.ts +163 -163
  120. package/src/overlay/dom.ts +46 -46
  121. package/src/overlay/layout-clusters.ts +162 -162
  122. package/src/overlay/layout-geometry.ts +136 -136
  123. package/src/overlay/layout.ts +203 -203
  124. package/src/overlay/listener-scope.ts +23 -23
  125. package/src/overlay/on-screen-keyboard.ts +137 -137
  126. package/src/overlay/overlay.ts +208 -208
  127. package/src/overlay/styles.ts +9 -9
  128. package/src/overlay/virtual-controls.ts +262 -262
  129. package/src/race.ts +10 -10
  130. package/src/realtime/bridge.ts +126 -121
  131. package/src/realtime/cross-bundle.ts +29 -29
  132. package/src/realtime/fallback-notice.ts +48 -48
  133. package/src/realtime/hub-link.ts +65 -65
  134. package/src/realtime/latest-state.ts +33 -33
  135. package/src/realtime/multiplayer-forwarder.ts +40 -28
  136. package/src/realtime/multiplayer-types.ts +66 -52
  137. package/src/realtime/page-runtime.ts +60 -60
  138. package/src/realtime/race-forwarder.ts +59 -59
  139. package/src/realtime/race-types.ts +132 -132
  140. package/src/realtime/room-types.ts +151 -125
  141. package/src/realtime/rooms-forwarder.ts +91 -79
  142. package/src/realtime/runtime-api.ts +60 -49
  143. package/src/realtime/runtime-opener.ts +61 -61
  144. package/src/realtime/social-forwarder.ts +77 -72
  145. package/src/realtime/social-types.ts +86 -78
  146. package/src/realtime/types.ts +53 -53
  147. package/src/realtime/wire-types.ts +79 -72
  148. package/src/rooms.ts +10 -10
  149. package/src/social.ts +18 -18
  150. package/src/types.ts +13 -13
  151. package/src/uistack/page-stack.ts +45 -45
  152. package/src/uistack/play-suspension.ts +82 -82
  153. package/src/uistack/types.ts +76 -76
  154. package/src/uistack/ui-stack.ts +144 -144
@@ -1,36 +1,36 @@
1
- /**
2
- * The page's one daily-challenge system (plan §4.4): the first bundle to
3
- * create it wins, so the menu starts days on the instance the game defined.
4
- */
5
- import { HUB_CLIENT_VERSION } from '../core/version/client-version.gen';
6
- import { claimSingleton } from '../globals';
7
- import { hasMethods } from '../realtime/cross-bundle';
8
- import { DailySystem } from './daily-system';
9
- import type { DailyHost } from './types';
10
-
11
- /** The cross-bundle shape number of the `daily` singleton. Bump only on an incompatible change. */
12
- export const DAILY_SINGLETON_API = 1;
13
-
14
- /** Whether `candidate` is some bundle's daily system (v1 copies included). */
15
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: the instance may come from another bundle
16
- export function isDailySystem(candidate: unknown): candidate is DailySystem {
17
- return hasMethods(candidate, ['define', 'startAndPlay', 'complete', 'cancel', 'status', 'all']);
18
- }
19
-
20
- let claimedDaily: DailySystem | null = null;
21
-
22
- /**
23
- * The page-wide daily system, created with `host` if no bundle has made one
24
- * yet. On a shape mismatch the registered one is kept and this bundle gets a
25
- * private instance. The answer is kept for this bundle.
26
- */
27
- export function getDaily(host: DailyHost): DailySystem {
28
- claimedDaily ??= claimSingleton({
29
- name: 'daily',
30
- version: HUB_CLIENT_VERSION,
31
- api: DAILY_SINGLETON_API,
32
- create: () => new DailySystem(host),
33
- isInstance: isDailySystem,
34
- });
35
- return claimedDaily;
36
- }
1
+ /**
2
+ * The page's one daily-challenge system (plan §4.4): the first bundle to
3
+ * create it wins, so the menu starts days on the instance the game defined.
4
+ */
5
+ import { HUB_CLIENT_VERSION } from '../core/version/client-version.gen';
6
+ import { claimSingleton } from '../globals';
7
+ import { hasMethods } from '../realtime/cross-bundle';
8
+ import { DailySystem } from './daily-system';
9
+ import type { DailyHost } from './types';
10
+
11
+ /** The cross-bundle shape number of the `daily` singleton. Bump only on an incompatible change. */
12
+ export const DAILY_SINGLETON_API = 1;
13
+
14
+ /** Whether `candidate` is some bundle's daily system (v1 copies included). */
15
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: the instance may come from another bundle
16
+ export function isDailySystem(candidate: unknown): candidate is DailySystem {
17
+ return hasMethods(candidate, ['define', 'startAndPlay', 'complete', 'cancel', 'status', 'all']);
18
+ }
19
+
20
+ let claimedDaily: DailySystem | null = null;
21
+
22
+ /**
23
+ * The page-wide daily system, created with `host` if no bundle has made one
24
+ * yet. On a shape mismatch the registered one is kept and this bundle gets a
25
+ * private instance. The answer is kept for this bundle.
26
+ */
27
+ export function getDaily(host: DailyHost): DailySystem {
28
+ claimedDaily ??= claimSingleton({
29
+ name: 'daily',
30
+ version: HUB_CLIENT_VERSION,
31
+ api: DAILY_SINGLETON_API,
32
+ create: () => new DailySystem(host),
33
+ isInstance: isDailySystem,
34
+ });
35
+ return claimedDaily;
36
+ }
@@ -1,67 +1,68 @@
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`; `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
+ /** 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; queued: true; meta: DailyCompletionMeta }
68
+ | { 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';
@@ -1,76 +1,86 @@
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
- /** How serious a finding is: `error` fails the run, `warning` and `info` only report. */
11
- export type FindingLevel = 'error' | 'warning' | 'info';
12
-
13
- /** One thing the doctor found. */
14
- export interface Finding {
15
- /** How serious it is. */
16
- readonly level: FindingLevel;
17
- /** Which check found it: `version`, `vendored` or `manifest`. */
18
- readonly check: 'version' | 'vendored' | 'manifest';
19
- /** What to tell the developer, with the fix. */
20
- readonly message: string;
21
- }
22
-
23
- /** The fix for an old or unsupported client. */
24
- const UPDATE_FIX = 'run `npm update @diffenderfer-games/hub`';
25
-
26
- /**
27
- * The installed client against the hub's `/_api/version`: outside
28
- * `supported` is an error, `deprecated` a warning. `null` (a hub from before
29
- * versioning) and an unreachable hub are only reported.
30
- */
31
- export function checkClientVersion(installedVersion: string, hubVersion: HubVersion | null | Error): Finding[] {
32
- if (hubVersion instanceof Error) {
33
- return [{ level: 'warning', check: 'version', message: `could not read the hub's version (${hubVersion.message}); the client version was not checked` }];
34
- }
35
- switch (clientSupport(hubVersion, installedVersion)) {
36
- case 'unsupported':
37
- return [{ level: 'error', check: 'version', message: `@diffenderfer-games/hub ${installedVersion} is not supported by the hub (supported: ${hubVersion?.supported ?? '?'}): ${UPDATE_FIX}` }];
38
- case 'deprecated':
39
- return [{ level: 'warning', check: 'version', message: `@diffenderfer-games/hub ${installedVersion} is deprecated (deprecated: ${hubVersion?.deprecated ?? '?'}): ${UPDATE_FIX}` }];
40
- case 'unknown':
41
- return [{ level: 'info', check: 'version', message: 'the hub predates versioning (no /_api/version); the client version was not checked' }];
42
- case 'supported':
43
- return [{ level: 'info', check: 'version', message: `@diffenderfer-games/hub ${installedVersion} is supported (hub ${hubVersion?.server ?? '?'})` }];
44
- }
45
- }
46
-
47
- /** Leftovers of the v1 vendored client, relative to the game's folder. */
48
- const VENDORED_LEFTOVERS = [
49
- 'src/hub/hub.ts',
50
- 'src/hub/input.ts',
51
- 'client/src/hub/hub.ts',
52
- 'scripts/sync-hub.mjs',
53
- ] as const;
54
-
55
- /** A stale vendored `src/hub/` copy or its sync script (the package replaces both). */
56
- export function checkVendoredCopies(gameDir: string): Finding[] {
57
- return VENDORED_LEFTOVERS.filter((leftover) => existsSync(join(gameDir, leftover))).map((leftover) => ({
58
- level: 'warning',
59
- check: 'vendored',
60
- message: `${leftover} is a vendored copy of the v1 client: import from '@diffenderfer-games/hub' instead and delete it`,
61
- }));
62
- }
63
-
64
- /** The `package.json` `game.*` block against the contract's manifest schema. */
65
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: the game's package.json, parsed by GamePackageJson next
66
- export function checkManifest(packageJson: unknown): Finding[] {
67
- const parsed = GamePackageJson.safeParse(packageJson);
68
- if (parsed.success) {
69
- return [{ level: 'info', check: 'manifest', message: `the game.* block is valid (${parsed.data.game.type})` }];
70
- }
71
- return parsed.error.issues.map((issue) => ({
72
- level: 'error',
73
- check: 'manifest',
74
- message: `${issue.path.length === 0 ? '(package.json)' : issue.path.join('.')}: ${issue.message}`,
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 { GAME_LABELS, GamePackageJson, splitGameLabels, 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
+ 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
+ ];
80
+ }
81
+ return parsed.error.issues.map((issue) => ({
82
+ level: 'error',
83
+ check: 'manifest',
84
+ message: `${issue.path.length === 0 ? '(package.json)' : issue.path.join('.')}: ${issue.message}`,
85
+ }));
86
+ }
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 { HubVersion } from '@diffenderfer-games/hub-contract';
22
- import { HUB_CLIENT_VERSION } from '../core/version/client-version.gen';
23
- import { checkClientVersion, checkManifest, checkVendoredCopies, type Finding } from './checks';
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
- readonly isOffline: boolean;
32
- readonly isJson: boolean;
33
- }
34
-
35
- function readOptions(): DoctorOptions {
36
- const { values } = parseArgs({
37
- options: {
38
- dir: { type: 'string' },
39
- hub: { type: 'string' },
40
- offline: { type: 'boolean', default: false },
41
- json: { type: 'boolean', default: false },
42
- },
43
- });
44
- return { gameDir: resolve(values.dir ?? '.'), hubUrl: values.hub ?? DEFAULT_HUB_URL, isOffline: values.offline, isJson: values.json };
45
- }
46
-
47
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: a JSON file from disk, parsed by the caller's schema
48
- function readJsonFile(path: string): unknown {
49
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: JSON.parse's result, parsed by the caller's schema
50
- const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'));
51
- return parsed;
52
- }
53
-
54
- /** The client version the game has installed (its `node_modules` copy), else this doctor's own. */
55
- function installedClientVersion(gameDir: string): string {
56
- try {
57
- const installed = readJsonFile(join(gameDir, 'node_modules', '@diffenderfer-games', 'hub', 'package.json'));
58
- if (typeof installed === 'object' && installed !== null && 'version' in installed && typeof installed.version === 'string') {
59
- return installed.version;
60
- }
61
- } catch {
62
- // Not installed in the game: report this doctor's own version.
63
- }
64
- return HUB_CLIENT_VERSION;
65
- }
66
-
67
- /** The hub's `/_api/version`: `null` for a hub without it, an `Error` when unreachable. */
68
- async function fetchHubVersion(hubUrl: string): Promise<HubVersion | null | Error> {
69
- try {
70
- const response = await fetch(new URL('/_api/version', hubUrl));
71
- if (response.status === 404) {
72
- return null;
73
- }
74
- if (!response.ok) {
75
- return new Error(`HTTP ${String(response.status)}`);
76
- }
77
- return HubVersion.parse(await response.json());
78
- } catch (failure) {
79
- return failure instanceof Error ? failure : new Error(String(failure));
80
- }
81
- }
82
-
83
- async function runDoctor(options: DoctorOptions): Promise<Finding[]> {
84
- const findings: Finding[] = [];
85
- if (!options.isOffline) {
86
- findings.push(...checkClientVersion(installedClientVersion(options.gameDir), await fetchHubVersion(options.hubUrl)));
87
- }
88
- findings.push(...checkVendoredCopies(options.gameDir));
89
- try {
90
- findings.push(...checkManifest(readJsonFile(join(options.gameDir, 'package.json'))));
91
- } catch (failure) {
92
- findings.push({ level: 'error', check: 'manifest', message: `could not read package.json: ${failure instanceof Error ? failure.message : String(failure)}` });
93
- }
94
- return findings;
95
- }
96
-
97
- const options = readOptions();
98
- const findings = await runDoctor(options);
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;