@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
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Invite links (batch-2026-10 R13): a link any room member can share that
3
+ * drops whoever opens it into that room, as a player when a seat is free,
4
+ * otherwise as a spectator. A link is an unguessable token kept by the hub;
5
+ * it expires (24 h) and its creator or the room's host can revoke it. Link
6
+ * joiners get the room's usual chat rules and are not friended.
7
+ */
8
+ import { z } from 'zod';
9
+ import { Millis, Slug, UserId } from '../../common';
10
+ import { GameMode } from './parties';
11
+
12
+ /** An invite link's token (base64url). */
13
+ export const InviteLinkToken = z.string().regex(/^[A-Za-z0-9_-]{16,64}$/);
14
+
15
+ /** A live invite link. */
16
+ export const InviteLink = z.object({
17
+ token: InviteLinkToken,
18
+ /** `/<slug>/<lobby>?hub_link=<token>`, relative to the site origin. */
19
+ url: z.string(),
20
+ game: Slug,
21
+ mode: GameMode.optional(),
22
+ /** The room it opens: a hub room code, or an own-server game's room id from presence. */
23
+ room: z.string(),
24
+ /** Who made it. */
25
+ createdBy: UserId,
26
+ /** Epoch ms. */
27
+ expiresAt: Millis,
28
+ });
29
+
30
+ /** An invite link. */
31
+ export type InviteLink = z.infer<typeof InviteLink>;
32
+
33
+ /** `:token` of an invite link. */
34
+ export const InviteLinkParams = z.object({ token: InviteLinkToken });
35
+
36
+ /** `POST /mp/links` body: a link to the room I'm in, in this game. */
37
+ export const InviteLinkCreate = z.object({ game: Slug });
38
+
39
+ /** `GET /mp/links` query: my links in a game (and the links to rooms I host). */
40
+ export const InviteLinkQuery = z.object({ game: Slug });
41
+
42
+
@@ -7,6 +7,7 @@
7
7
  import { z } from 'zod';
8
8
  import { Millis, Slug, UserId } from '../../common';
9
9
  import { PlayerLite } from '../../entities';
10
+ import { TournamentLaunch } from '../tournaments/shapes';
10
11
 
11
12
  /** A game mode key, as the game's `multiplayer.modes[].key` declares it: `[A-Za-z0-9_-]{1,32}`. */
12
13
  export const GameMode = z.string().regex(/^[A-Za-z0-9_-]{1,32}$/);
@@ -102,6 +103,10 @@ export const Launch = z.object({
102
103
  host: PlayerLite.nullable().optional(),
103
104
  /** Join and watch launches: the friend being joined (`null` if the account is gone). */
104
105
  target: PlayerLite.nullable().optional(),
106
+ /** `link`: opened from an invite link; `rejoin`: taking back a seat I still hold (both are join or watch launches). */
107
+ via: z.enum(['link', 'rejoin']).optional(),
108
+ /** Host and guest launches into a tournament match: which tournament, which match, and who to play. */
109
+ tournament: TournamentLaunch.optional(),
105
110
  });
106
111
 
107
112
  /** A launch. */
@@ -1,211 +1,211 @@
1
- /**
2
- * Race shapes (docs/plans/race.md): params, live stats, racers and decided
3
- * results. Shared by the `race.*` socket ops and events, the race block of
4
- * a room, and `GET /race/history`. Each rule here is defined once.
5
- */
6
- import { z } from 'zod';
7
- import { HexColor, Millis, Slug, UserId } from '../../common';
8
- import { RACE_STATS_LIMITS } from '../../limits';
9
-
10
- /** A race param or stat key: a letter, then up to 23 letters, digits or `_`. */
11
- export const RaceKey = z.string().regex(/^[A-Za-z][A-Za-z0-9_]{0,23}$/);
12
-
13
- /** A race param value, one of the game's declared options: a number, a boolean or a short token. */
14
- export const RaceParamValue = z.union([
15
- z.number(),
16
- z.boolean(),
17
- z.string().regex(/^[A-Za-z0-9_-]{1,24}$/),
18
- ]);
19
-
20
- /**
21
- * The creator's choices, keyed by the manifest's `race.params[].key`. The
22
- * hub keeps only declared keys and option values (anything else becomes the
23
- * param's default), so everyone gets the same puzzle.
24
- */
25
- export const RaceParams = z.record(RaceKey, RaceParamValue);
26
-
27
- /** A race's params. */
28
- export type RaceParams = z.infer<typeof RaceParams>;
29
-
30
- /** Live stat values are clamped to ±1e9. */
31
- const STAT_LIMIT = 1e9;
32
-
33
- /**
34
- * A live stat value: a finite number (clamped to ±1e9), a boolean or a
35
- * `#rrggbb` colour (lower-cased). Never text.
36
- */
37
- export const RaceStatValue = z.union([
38
- z.number().transform((n) => Math.max(-STAT_LIMIT, Math.min(STAT_LIMIT, n))),
39
- z.boolean(),
40
- HexColor.transform((c) => c.toLowerCase()),
41
- ]);
42
-
43
- /** A live stat value, as cleaned by the hub. */
44
- export type RaceStatValue = z.output<typeof RaceStatValue>;
45
-
46
- /**
47
- * A racer's live stats, keyed like the manifest's `race.stats[].key`: at
48
- * most 12 keys and 600 bytes of JSON once cleaned.
49
- */
50
- export const RaceStats = z
51
- .record(RaceKey, RaceStatValue)
52
- .refine((s) => Object.keys(s).length <= RACE_STATS_LIMITS.maxKeys, `at most ${String(RACE_STATS_LIMITS.maxKeys)} stats`)
53
- .refine((s) => JSON.stringify(s).length <= RACE_STATS_LIMITS.maxBytes, 'stats too large');
54
-
55
- /** A racer's stats. */
56
- export type RaceStats = z.infer<typeof RaceStats>;
57
-
58
- /** How far along a racer is, 0 to 1. The hub clamps values outside that range. */
59
- export const RaceProgress = z.number().transform((n) => Math.max(0, Math.min(1, n)));
60
-
61
- /** A race room's phase: waiting for racers → countdown → racing → done (→ countdown on a rematch). */
62
- export const RacePhase = z.enum(['waiting', 'countdown', 'racing', 'done']);
63
-
64
- /** A racer's state while the race runs. */
65
- export const RacerState = z.enum(['racing', 'finished', 'lost', 'forfeit', 'disconnected']);
66
-
67
- /** A value of {@link RacerState}. */
68
- export type RacerState = z.infer<typeof RacerState>;
69
-
70
- /** How a race ended: the winner finished, everyone else lost / gave up / dropped, or time ran out. */
71
- export const RaceOutcome = z.enum(['finished', 'lost', 'forfeit', 'disconnected', 'timeout']);
72
-
73
- /** A value of {@link RaceOutcome}. */
74
- export type RaceOutcome = z.infer<typeof RaceOutcome>;
75
-
76
- /** One racer's result. `beaten`: still racing when someone else won. */
77
- export const RacerResult = z.enum(['won', 'beaten', 'lost', 'forfeit', 'disconnected', 'timeout']);
78
-
79
- /** A value of {@link RacerResult}. */
80
- export type RacerResult = z.infer<typeof RacerResult>;
81
-
82
- /** Fields every live racer line has (room roster and result card). */
83
- const RacerLine = z.object({
84
- userId: UserId,
85
- /** The racer's username when the race began. */
86
- name: z.string(),
87
- /** Avatar token; never empty. */
88
- avatar: z.string(),
89
- stats: RaceStats,
90
- /** 0..1, when the game reports it. */
91
- progress: RaceProgress.optional(),
92
- });
93
-
94
- /** A racer in a race room's roster (RoomInfo.race.players). */
95
- export const Racer = RacerLine.extend({
96
- state: RacerState,
97
- /** Server-measured ms from the go to finishing or dropping out. */
98
- ms: z.number().int().nonnegative().optional(),
99
- });
100
-
101
- /** A racer in a race room. */
102
- export type Racer = z.infer<typeof Racer>;
103
-
104
- /** One racer's line in a {@link RaceResult}. */
105
- export const RaceResultPlayer = RacerLine.extend({
106
- /** 1 for the winner. */
107
- place: z.number().int().positive(),
108
- result: RacerResult,
109
- /** Server-measured ms from the go to finishing or dropping out (the race's end for those still in). */
110
- ms: z.number().int().nonnegative(),
111
- });
112
-
113
- /** Fields a decided race has both live and in the history. */
114
- const DecidedRace = z.object({
115
- game: Slug,
116
- params: RaceParams,
117
- outcome: RaceOutcome,
118
- /** Epoch ms of the go. */
119
- startedAt: Millis,
120
- /** Epoch ms. */
121
- endedAt: Millis,
122
- /** ms from the go to the decision. */
123
- durationMs: z.number().int().nonnegative(),
124
- });
125
-
126
- /** A decided race: the `race.result` event, the `race.finish` ack and RoomInfo.race.result. */
127
- export const RaceResult = DecidedRace.extend({
128
- /** The race's id in the history; `null` if recording it failed. */
129
- id: z.number().int().positive().nullable(),
130
- /** The room code. */
131
- room: z.string(),
132
- /** 1 for the room's first race, +1 per rematch. */
133
- round: z.number().int().positive(),
134
- seed: z.number().int().positive(),
135
- /** `null` when nobody won (time ran out, or everyone dropped out). */
136
- winnerId: UserId.nullable(),
137
- /** In finishing order (place 1 first). */
138
- players: z.array(RaceResultPlayer),
139
- });
140
-
141
- /** A decided race. */
142
- export type RaceResult = z.infer<typeof RaceResult>;
143
-
144
- /** The referee's view of a race room (RoomInfo.race). */
145
- export const RaceState = z.object({
146
- phase: RacePhase,
147
- /** 1 for the first race in the room, +1 per rematch; 0 while waiting for the first. */
148
- round: z.number().int().nonnegative(),
149
- params: RaceParams,
150
- /** The countdown can start once this many racers are seated. */
151
- minPlayers: z.number().int().positive(),
152
- /** Everyone racing this round (empty while waiting for the first). */
153
- players: z.array(Racer),
154
- /** Who asked for a rematch since the last race. */
155
- rematch: z.array(UserId),
156
- /** Present once the countdown starts (never before, so nobody can pre-solve). */
157
- seed: z.number().int().positive().optional(),
158
- /** Epoch ms of the go (server clock); present with `seed`. */
159
- startAt: Millis.optional(),
160
- /** Present once the round is decided. */
161
- result: RaceResult.optional(),
162
- });
163
-
164
- /** A race room's referee state. */
165
- export type RaceState = z.infer<typeof RaceState>;
166
-
167
- /** The countdown began (`race.start`): build the puzzle from `seed` + `params`. */
168
- export const RaceStart = z.object({
169
- roomId: z.string(),
170
- round: z.number().int().positive(),
171
- seed: z.number().int().positive(),
172
- params: RaceParams,
173
- /** Epoch ms of the go (server clock). */
174
- startAt: Millis,
175
- /** ms until the go. */
176
- startsIn: z.number().int().nonnegative(),
177
- /** The racers' user ids. */
178
- players: z.array(UserId),
179
- });
180
-
181
- /** A racer in a history record. Suspended opponents are `hidden`: id 0, name "A player", no avatar. */
182
- export const RaceRecordPlayer = z.object({
183
- /** 0 when hidden. */
184
- userId: z.number().int().nonnegative(),
185
- username: z.string(),
186
- /** Avatar token; `''` when hidden. */
187
- avatar: z.string(),
188
- guest: z.boolean(),
189
- place: z.number().int().positive(),
190
- result: RacerResult,
191
- /** Server-measured ms from the go to finishing or dropping out. */
192
- ms: z.number().int().nonnegative().optional(),
193
- progress: RaceProgress.optional(),
194
- stats: RaceStats,
195
- hidden: z.literal(true).optional(),
196
- });
197
-
198
- /** One past race the player was in (`GET /race/history`). */
199
- export const RaceRecord = DecidedRace.extend({
200
- id: z.number().int().positive(),
201
- gameTitle: z.string(),
202
- /** `null` when nobody won; 0 when the winner is hidden (suspended). */
203
- winnerId: z.number().int().nonnegative().nullable(),
204
- /** The signed-in player's own line (`null` only if their row is missing). */
205
- me: RaceRecordPlayer.nullable(),
206
- /** In place order. */
207
- players: z.array(RaceRecordPlayer),
208
- });
209
-
210
- /** A past race. */
211
- export type RaceRecord = z.infer<typeof RaceRecord>;
1
+ /**
2
+ * Race shapes (docs/plans/race.md): params, live stats, racers and decided
3
+ * results. Shared by the `race.*` socket ops and events, the race block of
4
+ * a room, and `GET /race/history`. Each rule here is defined once.
5
+ */
6
+ import { z } from 'zod';
7
+ import { HexColor, Millis, Slug, UserId } from '../../common';
8
+ import { RACE_STATS_LIMITS } from '../../limits';
9
+
10
+ /** A race param or stat key: a letter, then up to 23 letters, digits or `_`. */
11
+ export const RaceKey = z.string().regex(/^[A-Za-z][A-Za-z0-9_]{0,23}$/);
12
+
13
+ /** A race param value, one of the game's declared options: a number, a boolean or a short token. */
14
+ export const RaceParamValue = z.union([
15
+ z.number(),
16
+ z.boolean(),
17
+ z.string().regex(/^[A-Za-z0-9_-]{1,24}$/),
18
+ ]);
19
+
20
+ /**
21
+ * The creator's choices, keyed by the manifest's `race.params[].key`. The
22
+ * hub keeps only declared keys and option values (anything else becomes the
23
+ * param's default), so everyone gets the same puzzle.
24
+ */
25
+ export const RaceParams = z.record(RaceKey, RaceParamValue);
26
+
27
+ /** A race's params. */
28
+ export type RaceParams = z.infer<typeof RaceParams>;
29
+
30
+ /** Live stat values are clamped to ±1e9. */
31
+ const STAT_LIMIT = 1e9;
32
+
33
+ /**
34
+ * A live stat value: a finite number (clamped to ±1e9), a boolean or a
35
+ * `#rrggbb` colour (lower-cased). Never text.
36
+ */
37
+ export const RaceStatValue = z.union([
38
+ z.number().transform((n) => Math.max(-STAT_LIMIT, Math.min(STAT_LIMIT, n))),
39
+ z.boolean(),
40
+ HexColor.transform((c) => c.toLowerCase()),
41
+ ]);
42
+
43
+ /** A live stat value, as cleaned by the hub. */
44
+ export type RaceStatValue = z.output<typeof RaceStatValue>;
45
+
46
+ /**
47
+ * A racer's live stats, keyed like the manifest's `race.stats[].key`: at
48
+ * most 12 keys and 600 bytes of JSON once cleaned.
49
+ */
50
+ export const RaceStats = z
51
+ .record(RaceKey, RaceStatValue)
52
+ .refine((s) => Object.keys(s).length <= RACE_STATS_LIMITS.maxKeys, `at most ${String(RACE_STATS_LIMITS.maxKeys)} stats`)
53
+ .refine((s) => JSON.stringify(s).length <= RACE_STATS_LIMITS.maxBytes, 'stats too large');
54
+
55
+ /** A racer's stats. */
56
+ export type RaceStats = z.infer<typeof RaceStats>;
57
+
58
+ /** How far along a racer is, 0 to 1. The hub clamps values outside that range. */
59
+ export const RaceProgress = z.number().transform((n) => Math.max(0, Math.min(1, n)));
60
+
61
+ /** A race room's phase: waiting for racers → countdown → racing → done (→ countdown on a rematch). */
62
+ export const RacePhase = z.enum(['waiting', 'countdown', 'racing', 'done']);
63
+
64
+ /** A racer's state while the race runs. */
65
+ export const RacerState = z.enum(['racing', 'finished', 'lost', 'forfeit', 'disconnected']);
66
+
67
+ /** A value of {@link RacerState}. */
68
+ export type RacerState = z.infer<typeof RacerState>;
69
+
70
+ /** How a race ended: the winner finished, everyone else lost / gave up / dropped, or time ran out. */
71
+ export const RaceOutcome = z.enum(['finished', 'lost', 'forfeit', 'disconnected', 'timeout']);
72
+
73
+ /** A value of {@link RaceOutcome}. */
74
+ export type RaceOutcome = z.infer<typeof RaceOutcome>;
75
+
76
+ /** One racer's result. `beaten`: still racing when someone else won. */
77
+ export const RacerResult = z.enum(['won', 'beaten', 'lost', 'forfeit', 'disconnected', 'timeout']);
78
+
79
+ /** A value of {@link RacerResult}. */
80
+ export type RacerResult = z.infer<typeof RacerResult>;
81
+
82
+ /** Fields every live racer line has (room roster and result card). */
83
+ const RacerLine = z.object({
84
+ userId: UserId,
85
+ /** The racer's username when the race began. */
86
+ name: z.string(),
87
+ /** Avatar token; never empty. */
88
+ avatar: z.string(),
89
+ stats: RaceStats,
90
+ /** 0..1, when the game reports it. */
91
+ progress: RaceProgress.optional(),
92
+ });
93
+
94
+ /** A racer in a race room's roster (RoomInfo.race.players). */
95
+ export const Racer = RacerLine.extend({
96
+ state: RacerState,
97
+ /** Server-measured ms from the go to finishing or dropping out. */
98
+ ms: z.number().int().nonnegative().optional(),
99
+ });
100
+
101
+ /** A racer in a race room. */
102
+ export type Racer = z.infer<typeof Racer>;
103
+
104
+ /** One racer's line in a {@link RaceResult}. */
105
+ export const RaceResultPlayer = RacerLine.extend({
106
+ /** 1 for the winner. */
107
+ place: z.number().int().positive(),
108
+ result: RacerResult,
109
+ /** Server-measured ms from the go to finishing or dropping out (the race's end for those still in). */
110
+ ms: z.number().int().nonnegative(),
111
+ });
112
+
113
+ /** Fields a decided race has both live and in the history. */
114
+ const DecidedRace = z.object({
115
+ game: Slug,
116
+ params: RaceParams,
117
+ outcome: RaceOutcome,
118
+ /** Epoch ms of the go. */
119
+ startedAt: Millis,
120
+ /** Epoch ms. */
121
+ endedAt: Millis,
122
+ /** ms from the go to the decision. */
123
+ durationMs: z.number().int().nonnegative(),
124
+ });
125
+
126
+ /** A decided race: the `race.result` event, the `race.finish` ack and RoomInfo.race.result. */
127
+ export const RaceResult = DecidedRace.extend({
128
+ /** The race's id in the history; `null` if recording it failed. */
129
+ id: z.number().int().positive().nullable(),
130
+ /** The room code. */
131
+ room: z.string(),
132
+ /** 1 for the room's first race, +1 per rematch. */
133
+ round: z.number().int().positive(),
134
+ seed: z.number().int().positive(),
135
+ /** `null` when nobody won (time ran out, or everyone dropped out). */
136
+ winnerId: UserId.nullable(),
137
+ /** In finishing order (place 1 first). */
138
+ players: z.array(RaceResultPlayer),
139
+ });
140
+
141
+ /** A decided race. */
142
+ export type RaceResult = z.infer<typeof RaceResult>;
143
+
144
+ /** The referee's view of a race room (RoomInfo.race). */
145
+ export const RaceState = z.object({
146
+ phase: RacePhase,
147
+ /** 1 for the first race in the room, +1 per rematch; 0 while waiting for the first. */
148
+ round: z.number().int().nonnegative(),
149
+ params: RaceParams,
150
+ /** The countdown can start once this many racers are seated. */
151
+ minPlayers: z.number().int().positive(),
152
+ /** Everyone racing this round (empty while waiting for the first). */
153
+ players: z.array(Racer),
154
+ /** Who asked for a rematch since the last race. */
155
+ rematch: z.array(UserId),
156
+ /** Present once the countdown starts (never before, so nobody can pre-solve). */
157
+ seed: z.number().int().positive().optional(),
158
+ /** Epoch ms of the go (server clock); present with `seed`. */
159
+ startAt: Millis.optional(),
160
+ /** Present once the round is decided. */
161
+ result: RaceResult.optional(),
162
+ });
163
+
164
+ /** A race room's referee state. */
165
+ export type RaceState = z.infer<typeof RaceState>;
166
+
167
+ /** The countdown began (`race.start`): build the puzzle from `seed` + `params`. */
168
+ export const RaceStart = z.object({
169
+ roomId: z.string(),
170
+ round: z.number().int().positive(),
171
+ seed: z.number().int().positive(),
172
+ params: RaceParams,
173
+ /** Epoch ms of the go (server clock). */
174
+ startAt: Millis,
175
+ /** ms until the go. */
176
+ startsIn: z.number().int().nonnegative(),
177
+ /** The racers' user ids. */
178
+ players: z.array(UserId),
179
+ });
180
+
181
+ /** A racer in a history record. Suspended opponents are `hidden`: id 0, name "A player", no avatar. */
182
+ export const RaceRecordPlayer = z.object({
183
+ /** 0 when hidden. */
184
+ userId: z.number().int().nonnegative(),
185
+ username: z.string(),
186
+ /** Avatar token; `''` when hidden. */
187
+ avatar: z.string(),
188
+ guest: z.boolean(),
189
+ place: z.number().int().positive(),
190
+ result: RacerResult,
191
+ /** Server-measured ms from the go to finishing or dropping out. */
192
+ ms: z.number().int().nonnegative().optional(),
193
+ progress: RaceProgress.optional(),
194
+ stats: RaceStats,
195
+ hidden: z.literal(true).optional(),
196
+ });
197
+
198
+ /** One past race the player was in (`GET /race/history`). */
199
+ export const RaceRecord = DecidedRace.extend({
200
+ id: z.number().int().positive(),
201
+ gameTitle: z.string(),
202
+ /** `null` when nobody won; 0 when the winner is hidden (suspended). */
203
+ winnerId: z.number().int().nonnegative().nullable(),
204
+ /** The signed-in player's own line (`null` only if their row is missing). */
205
+ me: RaceRecordPlayer.nullable(),
206
+ /** In place order. */
207
+ players: z.array(RaceRecordPlayer),
208
+ });
209
+
210
+ /** A past race. */
211
+ export type RaceRecord = z.infer<typeof RaceRecord>;