@diffenderfer-games/hub-contract 0.0.0-stage → 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.
Files changed (76) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +26 -3
  4. package/dist/index.d.ts +8454 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +4855 -0
  7. package/dist/index.js.map +1 -0
  8. package/package.json +53 -6
  9. package/src/api/analytics.ts +81 -0
  10. package/src/api/auth.ts +77 -0
  11. package/src/api/catalog.ts +171 -0
  12. package/src/api/daily.ts +122 -0
  13. package/src/api/economy/bundle.ts +69 -0
  14. package/src/api/economy/inventory.ts +57 -0
  15. package/src/api/economy/trades.ts +154 -0
  16. package/src/api/economy.ts +15 -0
  17. package/src/api/index.ts +85 -0
  18. package/src/api/leaderboards.ts +151 -0
  19. package/src/api/messages.ts +142 -0
  20. package/src/api/mp/chat.ts +36 -0
  21. package/src/api/mp/parties.ts +108 -0
  22. package/src/api/mp/races.ts +211 -0
  23. package/src/api/mp/rooms.ts +90 -0
  24. package/src/api/mp/snapshot.ts +17 -0
  25. package/src/api/mp.ts +110 -0
  26. package/src/api/notify.ts +111 -0
  27. package/src/api/progress/game-data.ts +189 -0
  28. package/src/api/progress.ts +59 -0
  29. package/src/api/race.ts +47 -0
  30. package/src/api/reports.ts +208 -0
  31. package/src/api/social/config.ts +47 -0
  32. package/src/api/social/endpoints.ts +179 -0
  33. package/src/api/social/people.ts +118 -0
  34. package/src/api/social/presence.ts +95 -0
  35. package/src/api/social.ts +9 -0
  36. package/src/api/version.ts +40 -0
  37. package/src/coerce.ts +50 -0
  38. package/src/common.ts +42 -0
  39. package/src/entities.ts +62 -0
  40. package/src/errors/codes/core.ts +10 -0
  41. package/src/errors/codes/multiplayer.ts +43 -0
  42. package/src/errors/codes/play.ts +5 -0
  43. package/src/errors/codes/social.ts +31 -0
  44. package/src/errors/index.ts +88 -0
  45. package/src/index.ts +16 -0
  46. package/src/limits.ts +100 -0
  47. package/src/logic/daily.ts +54 -0
  48. package/src/logic/index.ts +8 -0
  49. package/src/logic/presence.ts +62 -0
  50. package/src/logic/race.ts +169 -0
  51. package/src/logic/time.ts +81 -0
  52. package/src/manifest/changes.ts +55 -0
  53. package/src/manifest/controls.ts +42 -0
  54. package/src/manifest/fields.ts +89 -0
  55. package/src/manifest/game.ts +132 -0
  56. package/src/manifest/index.ts +12 -0
  57. package/src/manifest/inject.ts +31 -0
  58. package/src/manifest/leaderboards.ts +29 -0
  59. package/src/manifest/multiplayer.ts +71 -0
  60. package/src/manifest/race.ts +93 -0
  61. package/src/middleware.ts +47 -0
  62. package/src/realtime/define.ts +52 -0
  63. package/src/realtime/envelope.ts +67 -0
  64. package/src/realtime/events/multiplayer.ts +8 -0
  65. package/src/realtime/events/parties.ts +31 -0
  66. package/src/realtime/events/races.ts +27 -0
  67. package/src/realtime/events/rooms.ts +48 -0
  68. package/src/realtime/events/social.ts +81 -0
  69. package/src/realtime/index.ts +24 -0
  70. package/src/realtime/ops/empty.ts +8 -0
  71. package/src/realtime/ops/hello.ts +40 -0
  72. package/src/realtime/ops/multiplayer.ts +8 -0
  73. package/src/realtime/ops/parties.ts +13 -0
  74. package/src/realtime/ops/races.ts +99 -0
  75. package/src/realtime/ops/rooms.ts +122 -0
  76. package/src/realtime/ops/social.ts +58 -0
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Wording for race UIs (HUD, result card, history): stat values, result
3
+ * labels and the "how it ended" line, identical on every screen. Copy rule
4
+ * (multiplayer.md §10): no violent words; racers "finish", "are out", "give
5
+ * up" or "drop out".
6
+ */
7
+ import type { RaceSummary } from '../api/catalog';
8
+ import type { RaceOutcome, RaceParams, RacerResult, RacerState, RaceResult, RaceStats, RaceStatValue } from '../api/mp/races';
9
+ import type { RaceStatFormat } from '../manifest/race';
10
+ import { raceClock } from './time';
11
+
12
+ /** One declared race stat (key, title, display format). */
13
+ export type RaceStatSpec = RaceSummary['stats'][number];
14
+
15
+ /** A stat value for display, per its declared format. Unknown or absent values show as `'–'`. */
16
+ export function formatStat(value: RaceStatValue | undefined, format: RaceStatFormat): string {
17
+ if (value === undefined) {
18
+ return '–';
19
+ }
20
+ switch (format) {
21
+ case 'percent':
22
+ return typeof value === 'number' ? `${String(Math.round(Math.max(0, Math.min(1, value)) * 100))}%` : '–';
23
+ case 'time':
24
+ return typeof value === 'number' ? raceClock(value) : '–';
25
+ case 'bool':
26
+ return typeof value === 'boolean' ? (value ? '✓' : '✗') : '–';
27
+ case 'color':
28
+ return typeof value === 'string' ? '●' : '–';
29
+ case 'int':
30
+ if (typeof value === 'number') {
31
+ return String(Math.round(value));
32
+ }
33
+ return typeof value === 'boolean' ? (value ? '✓' : '✗') : '–';
34
+ }
35
+ }
36
+
37
+ /** The declared stats with their values (the HUD shows at most `max`). */
38
+ export function shownStats(spec: RaceSummary | null, stats: RaceStats, max = 3): { spec: RaceStatSpec; value: RaceStatValue | undefined }[] {
39
+ return spec ? spec.stats.slice(0, max).map((s) => ({ spec: s, value: stats[s.key] })) : [];
40
+ }
41
+
42
+ /** "Board 4x4 · Level Hard" from the manifest's option titles; unknown values are skipped. */
43
+ export function paramsText(spec: RaceSummary | null, params: RaceParams): string {
44
+ if (!spec) {
45
+ return '';
46
+ }
47
+ const parts: string[] = [];
48
+ for (const param of spec.params) {
49
+ const matchingOption = param.options.find((option) => option.value === params[param.key]);
50
+ if (matchingOption) {
51
+ parts.push(param.title === matchingOption.title ? matchingOption.title : `${param.title} ${matchingOption.title}`);
52
+ }
53
+ }
54
+ return parts.join(' · ');
55
+ }
56
+
57
+ /** A racer's live state as a short tag (`''` while racing and connected). */
58
+ export function racerStateTag(state: RacerState, isConnected: boolean): string {
59
+ switch (state) {
60
+ case 'finished':
61
+ return '🏁 Finished';
62
+ case 'lost':
63
+ return 'Out';
64
+ case 'forfeit':
65
+ return 'Gave up';
66
+ case 'disconnected':
67
+ return 'Dropped out';
68
+ case 'racing':
69
+ return isConnected ? '' : 'Reconnecting…';
70
+ }
71
+ }
72
+
73
+ const RESULT_LABEL: Readonly<Record<RacerResult, string>> = {
74
+ won: '🏆 Won', beaten: "Didn't finish", lost: 'Out', forfeit: 'Gave up', disconnected: 'Dropped out', timeout: "Time's up",
75
+ };
76
+
77
+ /** A racer's line in the result card and history. */
78
+ export function resultLabel(racerResult: RacerResult): string {
79
+ return RESULT_LABEL[racerResult];
80
+ }
81
+
82
+ /** The result card's title from my point of view (`me` is `null` when watching). */
83
+ export function resultTitle(result: RaceResult, me: number | null): string {
84
+ const winner = result.players.find((player) => player.userId === result.winnerId);
85
+ if (!winner) {
86
+ return "Time's up!";
87
+ }
88
+ return winner.userId === me ? 'You won! 🏆' : `${winner.name} won`;
89
+ }
90
+
91
+ /** Names joined for a sentence: "Sam", "Sam and Ali", "Sam, Ali and Jo". */
92
+ export function nameList(names: readonly string[]): string {
93
+ const last = names.at(-1);
94
+ if (names.length <= 1 || last === undefined) {
95
+ return names[0] ?? 'Everyone';
96
+ }
97
+ return `${names.slice(0, -1).join(', ')} and ${last}`;
98
+ }
99
+
100
+ /** How the race ended, in one friendly sentence, from my point of view. */
101
+ export function outcomeLine(result: RaceResult, me: number | null): string {
102
+ const winner = result.players.find((player) => player.userId === result.winnerId);
103
+ if (!winner) {
104
+ return 'Nobody finished before the time ran out.';
105
+ }
106
+ const mine = result.players.find((player) => player.userId === me);
107
+ const others = result.players.filter((player) => player.userId !== result.winnerId);
108
+ const namesWithOutcome = (outcome: RaceOutcome): string[] => others.filter((player) => player.result === outcome).map((player) => player.name);
109
+ const didIWin = winner.userId === me;
110
+ if (result.outcome === 'finished') {
111
+ return `${didIWin ? 'You' : winner.name} finished first in ${raceClock(winner.ms)}.`;
112
+ }
113
+ if (mine && !didIWin) {
114
+ if (mine.result === 'forfeit') {
115
+ return 'You gave up.';
116
+ }
117
+ if (mine.result === 'lost') {
118
+ return "You're out — better luck next race!";
119
+ }
120
+ if (mine.result === 'disconnected') {
121
+ return 'Your connection dropped.';
122
+ }
123
+ }
124
+ const lost = namesWithOutcome('lost');
125
+ const gaveUp = namesWithOutcome('forfeit');
126
+ const dropped = namesWithOutcome('disconnected');
127
+ const bits: string[] = [];
128
+ if (gaveUp.length > 0) {
129
+ bits.push(`${nameList(gaveUp)} gave up`);
130
+ }
131
+ if (lost.length > 0) {
132
+ bits.push(`${nameList(lost)} ${lost.length === 1 ? 'is' : 'are'} out`);
133
+ }
134
+ if (dropped.length > 0) {
135
+ bits.push(`${nameList(dropped)} dropped out`);
136
+ }
137
+ const why = bits.length > 0 ? bits.join(', ') : 'Everyone else is out';
138
+ return `${why.charAt(0).toUpperCase()}${why.slice(1)} — ${didIWin ? 'you win' : `${winner.name} wins`}!`;
139
+ }
140
+
141
+ const HISTORY_VERB: Readonly<Record<RacerResult, string>> = {
142
+ won: 'Won', beaten: 'Lost', lost: 'Out', forfeit: 'Gave up', disconnected: 'Dropped out', timeout: "Time's up",
143
+ };
144
+
145
+ /** My race's headline verb in the history ("Won vs Sam", "Lost vs Sam"). */
146
+ export function historyVerb(racerResult: RacerResult): string {
147
+ return HISTORY_VERB[racerResult];
148
+ }
149
+
150
+ /** True when a racer's time means something (they finished or dropped out themselves). */
151
+ export function hasOwnTime(racerResult: RacerResult): boolean {
152
+ return racerResult !== 'beaten' && racerResult !== 'timeout';
153
+ }
154
+
155
+ /** The history badge for my result. */
156
+ export function historyBadge(racerResult: RacerResult | undefined): string {
157
+ switch (racerResult) {
158
+ case 'won':
159
+ return '🏆';
160
+ case 'forfeit':
161
+ return '🏳️';
162
+ case 'disconnected':
163
+ return '📶';
164
+ case 'timeout':
165
+ return '⏱️';
166
+ default:
167
+ return '🏁';
168
+ }
169
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Time and count wording shared by every hub UI (the social drawer, the menu,
3
+ * the home page, race cards). One copy, so "3h ago" reads the same everywhere
4
+ * (plan §8.4: the menu and the catalog page each had their own).
5
+ */
6
+
7
+ const padTwoDigits = (count: number): string => String(count).padStart(2, '0');
8
+
9
+ /**
10
+ * `m:ss` (or `h:mm:ss`) elapsed since `since`.
11
+ * @example elapsed(0, 252_000) // "4:12"
12
+ */
13
+ export function elapsed(since: number, now: number): string {
14
+ const total = Math.max(0, Math.floor((now - since) / 1000));
15
+ const hours = Math.floor(total / 3600);
16
+ const minutes = Math.floor((total % 3600) / 60);
17
+ const paddedSeconds = padTwoDigits(total % 60);
18
+ return hours ? `${String(hours)}:${padTwoDigits(minutes)}:${paddedSeconds}` : `${String(minutes)}:${paddedSeconds}`;
19
+ }
20
+
21
+ /**
22
+ * "just now", "5m ago", "3h ago", "2d ago", then a short date (in the
23
+ * viewer's locale) after a week.
24
+ */
25
+ export function relativeTime(then: number, now: number): string {
26
+ const seconds = Math.max(0, Math.floor((now - then) / 1000));
27
+ if (seconds < 60) {
28
+ return 'just now';
29
+ }
30
+ if (seconds < 3600) {
31
+ return `${String(Math.floor(seconds / 60))}m ago`;
32
+ }
33
+ if (seconds < 86400) {
34
+ return `${String(Math.floor(seconds / 3600))}h ago`;
35
+ }
36
+ if (seconds < 7 * 86400) {
37
+ return `${String(Math.floor(seconds / 86400))}d ago`;
38
+ }
39
+ return new Date(then).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
40
+ }
41
+
42
+ /**
43
+ * A seconds countdown for mutes and rate limits: "42s" or "3:05".
44
+ * @param ms - time left, in ms
45
+ */
46
+ export function countdown(ms: number): string {
47
+ const seconds = Math.max(0, Math.ceil(ms / 1000));
48
+ return seconds < 60 ? `${String(seconds)}s` : `${String(Math.floor(seconds / 60))}:${padTwoDigits(seconds % 60)}`;
49
+ }
50
+
51
+ /** Badge text: `''` for 0, the number up to 9, then `'9+'`. */
52
+ export function badgeText(count: number): string {
53
+ if (count <= 0) {
54
+ return '';
55
+ }
56
+ return count > 9 ? '9+' : String(count);
57
+ }
58
+
59
+ /**
60
+ * When a suspension or mute ends, for the "can't use online play" notice.
61
+ * `''` when there is no end (0, or a permanent suspension far in the future).
62
+ */
63
+ export function untilText(until: number): string {
64
+ if (!until || until > 8e15) {
65
+ return '';
66
+ }
67
+ return new Date(until).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
68
+ }
69
+
70
+ /**
71
+ * A race clock: `83_400` → `"1:23.4"`; past an hour `"1:02:03"`.
72
+ * @param ms - elapsed race time, in ms
73
+ */
74
+ export function raceClock(ms: number): string {
75
+ const tenths = Math.max(0, Math.floor(ms / 100));
76
+ const seconds = Math.floor(tenths / 10);
77
+ const hours = Math.floor(seconds / 3600);
78
+ const minutes = Math.floor((seconds % 3600) / 60);
79
+ const paddedSeconds = padTwoDigits(seconds % 60);
80
+ return hours ? `${String(hours)}:${padTwoDigits(minutes)}:${paddedSeconds}` : `${String(minutes)}:${paddedSeconds}.${String(tenths % 10)}`;
81
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `game.changes`: player-facing "What's new" notes (docs/hub.md "What's new").
3
+ */
4
+ import { z } from 'zod';
5
+ import { Key } from '../common';
6
+ import { MANIFEST_LIMITS } from '../limits';
7
+ import { trimmedText } from './fields';
8
+
9
+ /** The text of one note: plain words for a player, at most 280 characters. */
10
+ export const ChangeText = trimmedText(MANIFEST_LIMITS.changeTextMax);
11
+
12
+ /**
13
+ * One note: a string (identified by its text, so rewording re-announces it)
14
+ * or `{ id, text }` (identified by `id`, so it can be reworded).
15
+ */
16
+ export const ChangeEntry = z.union([
17
+ ChangeText,
18
+ z.strictObject({
19
+ /** A stable id: letters, digits and `_ . : -`, 1 to 64 characters. */
20
+ id: Key,
21
+ /** The note. */
22
+ text: ChangeText,
23
+ }),
24
+ ]);
25
+
26
+ /** One "What's new" note. */
27
+ export type ChangeEntry = z.output<typeof ChangeEntry>;
28
+
29
+ /**
30
+ * The form a string note is identified by: lowercase, whitespace collapsed,
31
+ * trailing punctuation dropped. Two notes with the same form are duplicates.
32
+ */
33
+ export function normalizeChangeText(text: string): string {
34
+ return text.toLowerCase().replace(/\s+/g, ' ').trim().replace(/[\s.!?…]+$/u, '');
35
+ }
36
+
37
+ /** The identity the hub stores a note under. */
38
+ function noteIdentity(note: ChangeEntry): string {
39
+ return typeof note === 'string' ? `h:${normalizeChangeText(note)}` : `id:${note.id}`;
40
+ }
41
+
42
+ /** `game.changes`: at most 200 notes, no duplicates. Newest last. */
43
+ export const GameChanges = z
44
+ .array(ChangeEntry)
45
+ .max(MANIFEST_LIMITS.changesMax)
46
+ .superRefine((notes, ctx) => {
47
+ const seen = new Set<string>();
48
+ notes.forEach((note, i) => {
49
+ const id = noteIdentity(note);
50
+ if (seen.has(id)) {
51
+ ctx.addIssue({ code: 'custom', message: 'duplicate note (same id, or the same text ignoring case and trailing punctuation)', path: [i] });
52
+ }
53
+ seen.add(id);
54
+ });
55
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `game.controls`: the gamepad-to-keyboard map zero-config games use, so a
3
+ * controller or arcade stick works in a game that never calls
4
+ * `hub.input.define()` (docs/hub.md "Mapping controls per game").
5
+ */
6
+ import { z } from 'zod';
7
+ import { SEAT_LIMITS } from '../limits';
8
+
9
+ /** A `KeyboardEvent.code`, such as `ArrowUp`, `Space`, `KeyZ` or `ShiftLeft`. */
10
+ export const KeyCode = z.string().regex(/^[A-Za-z0-9]{1,32}$/, 'must be a KeyboardEvent.code such as ArrowUp, Space or KeyZ');
11
+
12
+ /** A standard-gamepad button index, `"0"` to `"19"`. */
13
+ export const ButtonIndex = z.string().regex(/^(?:1?[0-9])$/, 'must be a gamepad button index 0-19');
14
+
15
+ /** One player's map. Unset directions and buttons keep the defaults. */
16
+ export const ControlMap = z.strictObject({
17
+ /** Key pressed for stick/d-pad up. Default `ArrowUp`. */
18
+ up: KeyCode.optional(),
19
+ /** Key pressed for stick/d-pad down. Default `ArrowDown`. */
20
+ down: KeyCode.optional(),
21
+ /** Key pressed for stick/d-pad left. Default `ArrowLeft`. */
22
+ left: KeyCode.optional(),
23
+ /** Key pressed for stick/d-pad right. Default `ArrowRight`. */
24
+ right: KeyCode.optional(),
25
+ /**
26
+ * Button index → key. Defaults: `0` Space, `1` Enter, `2` KeyZ, `3` KeyX,
27
+ * `8` Escape, `9` Enter.
28
+ */
29
+ buttons: z.record(ButtonIndex, KeyCode).optional(),
30
+ });
31
+
32
+ /** A gamepad-to-keyboard map. */
33
+ export type ControlMap = z.infer<typeof ControlMap>;
34
+
35
+ /** `game.controls`: one map for every pad, or one per player in `players`. */
36
+ export const GameControls = ControlMap.extend({
37
+ /** Per-player maps, by gamepad order; pads past the end use the first. Overrides the shared map. */
38
+ players: z.array(ControlMap).min(1).max(SEAT_LIMITS.maxSeats).optional(),
39
+ });
40
+
41
+ /** `game.controls`. */
42
+ export type GameControls = z.infer<typeof GameControls>;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Field rules shared by several parts of the `game.*` manifest. Each rule is
3
+ * defined once here.
4
+ */
5
+ import { z } from 'zod';
6
+ import { MANIFEST_LIMITS, SEAT_LIMITS } from '../limits';
7
+
8
+ /**
9
+ * A non-empty string with no leading or trailing whitespace. Used for fields
10
+ * the hub reads verbatim (commands, paths, titles), where stray spaces are a
11
+ * mistake rather than something the hub would trim away.
12
+ */
13
+ export const Trimmed = z.string().regex(/^\S(?:[\s\S]*\S)?$/, 'must be non-empty, with no leading or trailing spaces');
14
+
15
+ /** A label the hub trims and shows to players, at most `max` characters. */
16
+ export function trimmedText(max: number) {
17
+ return z.string().trim().min(1).max(max);
18
+ }
19
+
20
+ /**
21
+ * A URL suffix relative to `/<slug>/` that invites and Join open, such as
22
+ * `?lobby=1` or `play/online`. Not absolute, no `..`, no `//`.
23
+ */
24
+ export const LobbySuffix = z
25
+ .string()
26
+ .trim()
27
+ .regex(new RegExp(`^[A-Za-z0-9_./?=&#%-]{0,${MANIFEST_LIMITS.lobbyMax}}$`), 'may only use letters, digits and _ . / ? = & # % -')
28
+ .refine((s) => !s.startsWith('/') && !s.includes('..') && !s.includes('//'), 'must be a relative URL suffix (no leading /, no .., no //)');
29
+
30
+ /**
31
+ * A `[min, max]` player range: whole numbers, `lowest <= min <= max <= 8`.
32
+ *
33
+ * @param lowest - the smallest `min` allowed (1 for multiplayer, 2 for races)
34
+ */
35
+ export function playerRange(lowest: number) {
36
+ const seatCount = z.number().int().min(lowest).max(SEAT_LIMITS.maxSeats);
37
+ return z.tuple([seatCount, seatCount]).refine(([min, max]) => min <= max, `must be [min, max] with ${lowest} <= min <= max <= ${SEAT_LIMITS.maxSeats}`);
38
+ }
39
+
40
+ /**
41
+ * An optional block that may also be written as `false` ("off"). `false`
42
+ * parses to `undefined`, the same as leaving the field out.
43
+ *
44
+ * (A `z.union` with `z.literal(false)` would work too, but it hides the
45
+ * block's own field errors behind a bare "Invalid input".)
46
+ */
47
+ export function orFalse<T extends z.ZodType>(block: T) {
48
+ return z.preprocess((v: false | z.input<T>) => (v === false ? undefined : v), block.optional()).optional();
49
+ }
50
+
51
+ /** True when no two items share a `key`. */
52
+ export function hasUniqueKeys(items: readonly { readonly key: string }[]): boolean {
53
+ return new Set(items.map((i) => i.key)).size === items.length;
54
+ }
55
+
56
+ /** The placeholders a `build` or `start` command may use. */
57
+ export const COMMAND_PLACEHOLDERS = ['PORT', 'GAME_PATH', 'GAME_SLUG'] as const;
58
+
59
+ const PLACEHOLDER = /\{\{(\w+)\}\}/g;
60
+ const KNOWN_PLACEHOLDERS: ReadonlySet<string> = new Set(COMMAND_PLACEHOLDERS);
61
+
62
+ /**
63
+ * A shell command the host runs in the app folder. It may use the
64
+ * placeholders `{{PORT}}`, `{{GAME_PATH}}` and `{{GAME_SLUG}}`; any other
65
+ * `{{NAME}}` fails the build.
66
+ */
67
+ export const Command = Trimmed.refine(
68
+ (cmd) => [...cmd.matchAll(PLACEHOLDER)].every((m) => KNOWN_PLACEHOLDERS.has(m[1] ?? '')),
69
+ `may only use the placeholders ${COMMAND_PLACEHOLDERS.map((p) => `{{${p}}}`).join(', ')}`,
70
+ );
71
+
72
+ /** True when a relative path names something strictly inside its folder. */
73
+ function staysInside(path: string): boolean {
74
+ let depth = 0;
75
+ for (const part of path.split(/[\\/]/)) {
76
+ if (part === '..') {
77
+ depth -= 1;
78
+ if (depth < 0) {
79
+ return false;
80
+ }
81
+ } else if (part !== '' && part !== '.') {
82
+ depth += 1;
83
+ }
84
+ }
85
+ return depth > 0;
86
+ }
87
+
88
+ /** A file path relative to the app folder that stays inside it, e.g. `cover.png`. */
89
+ export const AppFilePath = Trimmed.refine(staysInside, 'must be a file inside the app folder');
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The `game` block of an app's `package.json`: how the host builds, serves
3
+ * and shows the game (HANDOFF.md "The game block").
4
+ *
5
+ * This is the **strict** authoring schema `hub-doctor` runs in a game: a
6
+ * mistake fails the parse. The host stays lenient at boot (a bad field falls
7
+ * back to its default with a warning), so one typo never takes a game down.
8
+ */
9
+ import { z } from 'zod';
10
+ import { MANIFEST_LIMITS } from '../limits';
11
+ import { GameChanges } from './changes';
12
+ import { GameControls } from './controls';
13
+ import { AppFilePath, Command, Trimmed, orFalse } from './fields';
14
+ import { GameLeaderboardDecls } from './leaderboards';
15
+ import { GameMultiplayer, StaticGameMultiplayer } from './multiplayer';
16
+ import { GameRace } from './race';
17
+
18
+ /** The corner the injected hub menu button sits in. Default `top-left`. */
19
+ export const MenuPosition = z.enum(['top-left', 'top-right', 'bottom-left', 'bottom-right']);
20
+
21
+ /** A menu button corner. */
22
+ export type MenuPosition = z.infer<typeof MenuPosition>;
23
+
24
+ /** Pixels to push the menu button away from its corner, 0 to 600. */
25
+ const OffsetPx = z.number().int().min(0).max(MANIFEST_LIMITS.menuOffsetMax);
26
+
27
+ /** The menu offset as the host sends it: both axes, in px. */
28
+ export const MenuOffsetXY = z.strictObject({
29
+ /** Inward from the left or right edge, px. */
30
+ x: OffsetPx,
31
+ /** Down from a top corner or up from a bottom one, px. */
32
+ y: OffsetPx,
33
+ });
34
+
35
+ /**
36
+ * `game.menuOffset`: how far to push the menu button away from its corner so
37
+ * it clears the game's own bars. A number moves it vertically only.
38
+ */
39
+ export const MenuOffset = z.union([OffsetPx, MenuOffsetXY.partial()]);
40
+
41
+ /** Fields both app types share. */
42
+ const commonFields = {
43
+ /** Shown on the catalog card and in the menu. */
44
+ title: Trimmed,
45
+ /** Catalog, search and link-preview text. Falls back to `package.json` `description`. */
46
+ description: Trimmed.optional(),
47
+ /**
48
+ * Credit shown on the tile, e.g. `"Mason & Phil"` (split into authors for the
49
+ * catalog filter). Falls back to `package.json` `author`.
50
+ */
51
+ author: z.string().trim().min(1).optional(),
52
+ /** Runs at boot when the source changed. May use `{{PORT}}`, `{{GAME_PATH}}`, `{{GAME_SLUG}}`. */
53
+ build: Command.optional(),
54
+ /** Tile and preview image, relative to the project root (not `serveDir`); must stay inside it. */
55
+ image: AppFilePath.optional(),
56
+ /** `true` leaves the app out of the catalog entirely. */
57
+ hidden: z.boolean().optional(),
58
+ /** `false` opts out of the injected hub menu and SDK. */
59
+ hub: z.boolean().optional(),
60
+ /** `false` opts out of the PWA install tags and service worker. */
61
+ pwa: z.boolean().optional(),
62
+ /** The corner the hub menu button sits in. Default `top-left`. */
63
+ menuPosition: MenuPosition.optional(),
64
+ /** Push the menu button away from its corner, px: a number (vertical) or `{ x, y }`. */
65
+ menuOffset: MenuOffset.optional(),
66
+ /** `true` opts into the shared daily challenges (`hub.daily.define(...)`). */
67
+ dailyChallenges: z.boolean().optional(),
68
+ /** Boards to create up front, so they show before the first score. */
69
+ leaderboards: GameLeaderboardDecls.optional(),
70
+ /** The gamepad-to-keyboard map for games that never call `hub.input.define()`. */
71
+ controls: GameControls.optional(),
72
+ /** Turns a single-player game into a race target; `true` for all defaults, `false` for off. */
73
+ race: GameRace,
74
+ /** Player-facing "What's new" notes, one per change, newest last. Keep it updated. */
75
+ changes: GameChanges.optional(),
76
+ };
77
+
78
+ /** A static app: the build emits a folder the host serves. */
79
+ export const StaticGame = z.strictObject({
80
+ /** `static`: no running process. */
81
+ type: z.literal('static'),
82
+ /** The folder served at `/<slug>/`; must exist after `build`. */
83
+ serveDir: Trimmed,
84
+ /** Makes the game an online invite/join target (`hub-rooms` only); `false` for off. */
85
+ multiplayer: orFalse(StaticGameMultiplayer),
86
+ ...commonFields,
87
+ });
88
+
89
+ /** A process app: the host runs a long-lived server and proxies to it. */
90
+ export const ProcessGame = z.strictObject({
91
+ /** `process`: the host starts `start` and proxies `/<slug>/` to it. */
92
+ type: z.literal('process'),
93
+ /** The long-lived server command; must listen on the assigned port (`{{PORT}}` or `PORT`). */
94
+ start: Command,
95
+ /** The path the host polls until it answers below 500. Default `/`. */
96
+ healthPath: Trimmed.startsWith('/').optional(),
97
+ /** Makes the game an online invite/join target; `false` for off. */
98
+ multiplayer: orFalse(GameMultiplayer),
99
+ ...commonFields,
100
+ });
101
+
102
+ /** The `game` block of a game's `package.json`. */
103
+ export const GameManifest = z.discriminatedUnion('type', [StaticGame, ProcessGame]);
104
+
105
+ /** A parsed `game` block (defaults applied). */
106
+ export type GameManifest = z.output<typeof GameManifest>;
107
+
108
+ /** The `game` block as a game writes it. */
109
+ export type GameManifestInput = z.input<typeof GameManifest>;
110
+
111
+ /** A game's `package.json`: the `game` block plus the npm fields the host falls back to. */
112
+ export const GamePackageJson = z.looseObject({
113
+ /** The npm name; the title fallback. */
114
+ name: z.string().optional(),
115
+ /** The description fallback. */
116
+ description: z.string().optional(),
117
+ /** The author fallback: npm's string or `{ name }` form. */
118
+ author: z.union([z.string(), z.looseObject({ name: z.string().optional() })]).optional(),
119
+ /** How the host treats the app. */
120
+ game: GameManifest,
121
+ });
122
+
123
+ /**
124
+ * The host repo's own `game` block: only its "What's new" notes (shown as
125
+ * "diffenderfer.games"). It is not an app, so it has no `type`.
126
+ */
127
+ export const HostManifest = z.strictObject({
128
+ /** The host's display name. */
129
+ title: Trimmed.optional(),
130
+ /** The host's own notes (menu, friends, home page). */
131
+ changes: GameChanges.optional(),
132
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The `package.json` `game.*` manifest and the injected `window.__HUB__`
3
+ * config.
4
+ */
5
+ export * from './changes';
6
+ export * from './controls';
7
+ export { AppFilePath, COMMAND_PLACEHOLDERS, Command, LobbySuffix } from './fields';
8
+ export * from './game';
9
+ export * from './inject';
10
+ export * from './leaderboards';
11
+ export * from './multiplayer';
12
+ export * from './race';
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `window.__HUB__`: the small config object the host writes into every page
3
+ * it injects (game pages, and the catalog page). The SDK and the menu read it
4
+ * to find the game's slug, the API base and the menu placement.
5
+ *
6
+ * The same inline script may also set `window.__hubDailyDeep` (a
7
+ * `?daily=YYYY-MM-DD` deep link captured before game code runs) and
8
+ * `window.__hubInstall` (the PWA install prompt); those are not part of this
9
+ * object.
10
+ */
11
+ import { z } from 'zod';
12
+ import { Slug } from '../common';
13
+ import { GameControls } from './controls';
14
+ import { MenuOffsetXY, MenuPosition } from './game';
15
+
16
+ /** The injected `window.__HUB__` config. */
17
+ export const HubInject = z.strictObject({
18
+ /** The game's slug. Missing on the catalog page. */
19
+ slug: Slug.optional(),
20
+ /** Where the REST API lives. Always `/_api` today. */
21
+ apiBase: z.literal('/_api'),
22
+ /** The menu button's corner (from `game.menuPosition`). Missing on the catalog page. */
23
+ menu: MenuPosition.optional(),
24
+ /** The menu button's offset from its corner (from `game.menuOffset`); only sent when not 0,0. */
25
+ menuOffset: MenuOffsetXY.optional(),
26
+ /** The game's `game.controls`, as written; only sent when set. */
27
+ controls: GameControls.optional(),
28
+ });
29
+
30
+ /** The injected `window.__HUB__` config. */
31
+ export type HubInject = z.infer<typeof HubInject>;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `game.leaderboards`: boards declared up front, so they show on the catalog
3
+ * before the first score.
4
+ */
5
+ import { z } from 'zod';
6
+ import { SortDir } from '../api/leaderboards';
7
+ import { Key } from '../common';
8
+ import { MANIFEST_LIMITS } from '../limits';
9
+ import { Trimmed, hasUniqueKeys } from './fields';
10
+
11
+ /** One declared leaderboard. */
12
+ export const LeaderboardDecl = z.strictObject({
13
+ /** The board key `hub.submitScore(key, score)` uses: letters, digits and `_ . : -`, 1 to 64. */
14
+ key: Key,
15
+ /** What players see, at most 80 characters. Defaults to the key. */
16
+ title: Trimmed.max(MANIFEST_LIMITS.leaderboardTitleMax).optional(),
17
+ /** Which scores rank first. Default `desc`. */
18
+ sort: SortDir.default('desc'),
19
+ /** List players whose best is 0. Default `false`. */
20
+ showZeros: z.boolean().optional(),
21
+ /**
22
+ * `server`: only the game's own server may submit (trusted multiplayer
23
+ * results); the client endpoint refuses. Default `client`.
24
+ */
25
+ source: z.enum(['client', 'server']).default('client'),
26
+ });
27
+
28
+ /** `game.leaderboards`: unique keys. */
29
+ export const GameLeaderboardDecls = z.array(LeaderboardDecl).refine(hasUniqueKeys, 'leaderboard keys must be unique');