@diffenderfer-games/hub-contract 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 (81) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +21 -21
  3. package/README.md +26 -26
  4. package/dist/index.d.ts +2683 -39
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +862 -18
  7. package/dist/index.js.map +1 -1
  8. package/package.json +53 -53
  9. package/src/api/analytics.ts +81 -81
  10. package/src/api/auth.ts +77 -77
  11. package/src/api/catalog-activity.ts +66 -0
  12. package/src/api/catalog.ts +177 -171
  13. package/src/api/daily.ts +122 -122
  14. package/src/api/economy/bundle.ts +69 -69
  15. package/src/api/index.ts +92 -85
  16. package/src/api/leaderboards.ts +151 -151
  17. package/src/api/messages.ts +142 -142
  18. package/src/api/mp/chat.ts +36 -36
  19. package/src/api/mp/invite-links.ts +42 -0
  20. package/src/api/mp/parties.ts +5 -0
  21. package/src/api/mp/races.ts +211 -211
  22. package/src/api/mp/rooms.ts +122 -90
  23. package/src/api/mp/snapshot.ts +17 -17
  24. package/src/api/mp.ts +135 -110
  25. package/src/api/notify.ts +114 -111
  26. package/src/api/offline.ts +45 -0
  27. package/src/api/progress/game-data.ts +189 -189
  28. package/src/api/progress.ts +59 -59
  29. package/src/api/race.ts +47 -47
  30. package/src/api/reports.ts +208 -208
  31. package/src/api/social/config.ts +47 -47
  32. package/src/api/social/endpoints.ts +179 -179
  33. package/src/api/social/people.ts +118 -118
  34. package/src/api/social/presence.ts +95 -95
  35. package/src/api/social.ts +9 -9
  36. package/src/api/tournaments/endpoints.ts +102 -0
  37. package/src/api/tournaments/play-online.ts +71 -0
  38. package/src/api/tournaments/shapes.ts +202 -0
  39. package/src/api/tournaments.ts +7 -0
  40. package/src/api/version.ts +40 -40
  41. package/src/coerce.ts +50 -50
  42. package/src/common.ts +42 -42
  43. package/src/entities.ts +62 -62
  44. package/src/errors/codes/core.ts +10 -10
  45. package/src/errors/codes/multiplayer.ts +56 -43
  46. package/src/errors/codes/play.ts +5 -5
  47. package/src/errors/codes/social.ts +31 -31
  48. package/src/errors/index.ts +88 -88
  49. package/src/index.ts +16 -16
  50. package/src/limits.ts +125 -100
  51. package/src/logic/daily.ts +54 -54
  52. package/src/logic/index.ts +8 -8
  53. package/src/logic/presence.ts +62 -62
  54. package/src/logic/time.ts +81 -81
  55. package/src/manifest/changes.ts +55 -55
  56. package/src/manifest/controls.ts +42 -42
  57. package/src/manifest/fields.ts +89 -89
  58. package/src/manifest/game.ts +139 -132
  59. package/src/manifest/index.ts +13 -12
  60. package/src/manifest/inject.ts +31 -31
  61. package/src/manifest/labels.ts +99 -0
  62. package/src/manifest/leaderboards.ts +29 -29
  63. package/src/manifest/multiplayer.ts +79 -71
  64. package/src/manifest/race.ts +93 -93
  65. package/src/middleware.ts +47 -47
  66. package/src/realtime/define.ts +52 -52
  67. package/src/realtime/envelope.ts +67 -67
  68. package/src/realtime/events/multiplayer.ts +9 -8
  69. package/src/realtime/events/parties.ts +31 -31
  70. package/src/realtime/events/races.ts +27 -27
  71. package/src/realtime/events/rooms.ts +52 -48
  72. package/src/realtime/events/social.ts +81 -81
  73. package/src/realtime/events/tournaments.ts +12 -0
  74. package/src/realtime/index.ts +24 -24
  75. package/src/realtime/ops/empty.ts +8 -8
  76. package/src/realtime/ops/hello.ts +40 -40
  77. package/src/realtime/ops/multiplayer.ts +8 -8
  78. package/src/realtime/ops/parties.ts +13 -13
  79. package/src/realtime/ops/races.ts +99 -99
  80. package/src/realtime/ops/rooms.ts +22 -8
  81. package/src/realtime/ops/social.ts +58 -58
@@ -1,62 +1,62 @@
1
- /**
2
- * How a friend's presence reads in every list: the status dot, label, game,
3
- * detail and elapsed time (plan §5.2 of multiplayer.md: "consistent everywhere").
4
- */
5
- import type { Presence } from '../api/social/presence';
6
- import { elapsed, relativeTime } from './time';
7
-
8
- /** The pieces of a status line, so renderers can bold the game title. */
9
- export interface StatusParts {
10
- /** A coloured dot emoji. */
11
- readonly dot: string;
12
- /** 'Playing', 'In lobby', 'Away', 'Online', 'Last seen 3h ago'… */
13
- readonly label: string;
14
- /** The game title (rendered bold), or `''`. */
15
- readonly game: string;
16
- /** The game-provided detail, or `''`. */
17
- readonly detail: string;
18
- /** Elapsed time while playing (`'4:12'`), or `''`. */
19
- readonly elapsed: string;
20
- readonly online: boolean;
21
- /** Their presence offers Join. */
22
- readonly joinable: boolean;
23
- /** Their presence offers Watch. */
24
- readonly watchable: boolean;
25
- }
26
-
27
- /** Status line pieces for a friend; `p` is `null` when they are offline. */
28
- export function statusParts(presence: Presence | null, now: number, lastSeenAt = 0): StatusParts {
29
- const base = { game: '', detail: '', elapsed: '', online: true, joinable: false, watchable: false };
30
- if (!presence) {
31
- return { ...base, online: false, dot: '⚪', label: lastSeenAt ? `Last seen ${relativeTime(lastSeenAt, now)}` : 'Offline' };
32
- }
33
- const game = presence.gameTitle ?? '';
34
- const detail = presence.detail ?? '';
35
- const hasRoom = presence.room !== undefined && presence.room !== '';
36
- const flags = { joinable: presence.joinable === true && hasRoom, watchable: presence.watchable === true && hasRoom };
37
- switch (presence.kind) {
38
- case 'playing':
39
- return { ...base, ...flags, dot: '🟢', label: 'Playing', game, detail, elapsed: elapsed(presence.since, now) };
40
- case 'watching':
41
- return { ...base, ...flags, dot: '🟢', label: 'Watching', game, detail };
42
- case 'lobby':
43
- return { ...base, ...flags, dot: '🟠', label: 'In lobby', game, detail };
44
- case 'away':
45
- return { ...base, dot: '🟡', label: 'Away', game };
46
- case 'menu':
47
- return { ...base, dot: '🟢', label: 'Online', game };
48
- }
49
- }
50
-
51
- /** The status line as one string, e.g. "🟢 Playing 8 Ball Pool · Table vs Sam · 4:12". */
52
- export function statusText(parts: StatusParts): string {
53
- const head = parts.label === 'Playing' || parts.label === 'Watching'
54
- ? [parts.game ? `${parts.label} ${parts.game}` : parts.label]
55
- : [parts.label, parts.game];
56
- return `${parts.dot} ${[...head, parts.detail, parts.elapsed].filter(Boolean).join(' · ')}`;
57
- }
58
-
59
- /** The drawer header: "👥 3 friends online · 41 playing now". */
60
- export function headerLine(friendsOnline: number, online: number): string {
61
- return `👥 ${String(friendsOnline)} friend${friendsOnline === 1 ? '' : 's'} online · ${String(online)} playing now`;
62
- }
1
+ /**
2
+ * How a friend's presence reads in every list: the status dot, label, game,
3
+ * detail and elapsed time (plan §5.2 of multiplayer.md: "consistent everywhere").
4
+ */
5
+ import type { Presence } from '../api/social/presence';
6
+ import { elapsed, relativeTime } from './time';
7
+
8
+ /** The pieces of a status line, so renderers can bold the game title. */
9
+ export interface StatusParts {
10
+ /** A coloured dot emoji. */
11
+ readonly dot: string;
12
+ /** 'Playing', 'In lobby', 'Away', 'Online', 'Last seen 3h ago'… */
13
+ readonly label: string;
14
+ /** The game title (rendered bold), or `''`. */
15
+ readonly game: string;
16
+ /** The game-provided detail, or `''`. */
17
+ readonly detail: string;
18
+ /** Elapsed time while playing (`'4:12'`), or `''`. */
19
+ readonly elapsed: string;
20
+ readonly online: boolean;
21
+ /** Their presence offers Join. */
22
+ readonly joinable: boolean;
23
+ /** Their presence offers Watch. */
24
+ readonly watchable: boolean;
25
+ }
26
+
27
+ /** Status line pieces for a friend; `p` is `null` when they are offline. */
28
+ export function statusParts(presence: Presence | null, now: number, lastSeenAt = 0): StatusParts {
29
+ const base = { game: '', detail: '', elapsed: '', online: true, joinable: false, watchable: false };
30
+ if (!presence) {
31
+ return { ...base, online: false, dot: '⚪', label: lastSeenAt ? `Last seen ${relativeTime(lastSeenAt, now)}` : 'Offline' };
32
+ }
33
+ const game = presence.gameTitle ?? '';
34
+ const detail = presence.detail ?? '';
35
+ const hasRoom = presence.room !== undefined && presence.room !== '';
36
+ const flags = { joinable: presence.joinable === true && hasRoom, watchable: presence.watchable === true && hasRoom };
37
+ switch (presence.kind) {
38
+ case 'playing':
39
+ return { ...base, ...flags, dot: '🟢', label: 'Playing', game, detail, elapsed: elapsed(presence.since, now) };
40
+ case 'watching':
41
+ return { ...base, ...flags, dot: '🟢', label: 'Watching', game, detail };
42
+ case 'lobby':
43
+ return { ...base, ...flags, dot: '🟠', label: 'In lobby', game, detail };
44
+ case 'away':
45
+ return { ...base, dot: '🟡', label: 'Away', game };
46
+ case 'menu':
47
+ return { ...base, dot: '🟢', label: 'Online', game };
48
+ }
49
+ }
50
+
51
+ /** The status line as one string, e.g. "🟢 Playing 8 Ball Pool · Table vs Sam · 4:12". */
52
+ export function statusText(parts: StatusParts): string {
53
+ const head = parts.label === 'Playing' || parts.label === 'Watching'
54
+ ? [parts.game ? `${parts.label} ${parts.game}` : parts.label]
55
+ : [parts.label, parts.game];
56
+ return `${parts.dot} ${[...head, parts.detail, parts.elapsed].filter(Boolean).join(' · ')}`;
57
+ }
58
+
59
+ /** The drawer header: "👥 3 friends online · 41 playing now". */
60
+ export function headerLine(friendsOnline: number, online: number): string {
61
+ return `👥 ${String(friendsOnline)} friend${friendsOnline === 1 ? '' : 's'} online · ${String(online)} playing now`;
62
+ }
package/src/logic/time.ts CHANGED
@@ -1,81 +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
- }
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
+ }
@@ -1,55 +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
- });
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
+ });
@@ -1,42 +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>;
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>;