@diffenderfer-games/hub-contract 2.0.0-next.1 → 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.
package/src/api/mp.ts CHANGED
@@ -9,9 +9,11 @@ import { z } from 'zod';
9
9
  import { Millis, Ok, Slug, UserIdParam } from '../common';
10
10
  import { ErrorBody } from '../errors';
11
11
  import { requireActive, requireClaimed } from '../middleware';
12
+ import { InviteLink, InviteLinkCreate, InviteLinkParams, InviteLinkQuery } from './mp/invite-links';
12
13
  import { GameMode, Invite, JoinInfo, Launch, LaunchTicket, OpaqueId } from './mp/parties';
13
14
 
14
15
  export * from './mp/chat';
16
+ export * from './mp/invite-links';
15
17
  export * from './mp/parties';
16
18
  export * from './mp/races';
17
19
  export * from './mp/rooms';
@@ -60,6 +62,24 @@ export const mpEndpoints = {
60
62
  errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
61
63
  doc: { summary: 'Decline an invite addressed to you', tags: ['mp'] },
62
64
  },
65
+ createInviteLink: {
66
+ method: 'POST', path: '/mp/links', body: InviteLinkCreate, response: InviteLink,
67
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody, 429: ErrorBody },
68
+ doc: {
69
+ summary: "An invite link to the room I'm in (reused while it lives): anyone who opens it joins, or watches when it's full",
70
+ tags: ['mp'],
71
+ },
72
+ },
73
+ listInviteLinks: {
74
+ method: 'GET', path: '/mp/links', query: InviteLinkQuery, response: z.object({ links: z.array(InviteLink) }),
75
+ errors: { 401: ErrorBody, 403: ErrorBody },
76
+ doc: { summary: 'My live invite links in a game, plus the links to rooms I host', tags: ['mp'] },
77
+ },
78
+ revokeInviteLink: {
79
+ method: 'DELETE', path: '/mp/links/:token', params: InviteLinkParams, response: Ok,
80
+ errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
81
+ doc: { summary: "Revoke an invite link (its creator, or the host of the room it opens)", tags: ['mp'] },
82
+ },
63
83
  cancelGameInvite: {
64
84
  method: 'DELETE', path: '/mp/invites/:id', params: IdParams, response: Ok,
65
85
  errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
@@ -73,6 +93,11 @@ export const mpEndpoints = {
73
93
  errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
74
94
  doc: { summary: 'Redeem a launch token (single use, bound to the signed-in user)', tags: ['mp'] },
75
95
  },
96
+ openInviteLink: {
97
+ method: 'POST', path: '/mp/links/:token/open', params: InviteLinkParams, response: LaunchTicket,
98
+ errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody, 429: ErrorBody },
99
+ doc: { summary: 'Open an invite link: a join launch when a seat is free, else a watch launch', tags: ['mp'] },
100
+ },
76
101
  setPartyJoinInfo: {
77
102
  method: 'POST', path: '/mp/parties/:id/join-info', params: IdParams,
78
103
  body: z.object({ info: JoinInfo }),
package/src/api/notify.ts CHANGED
@@ -19,7 +19,10 @@ export const NotifyPrefs = z.object({
19
19
  friendOnlineAll: z.boolean(),
20
20
  /** Friends whose bell is on, ascending. */
21
21
  friendOnline: z.array(UserId),
22
- /** Games followed for "someone's waiting in a lobby", sorted. */
22
+ /**
23
+ * Games followed for "someone wants to play" (the game sheet's notify toggle):
24
+ * an open public lobby or race, or a tournament opening, sorted.
25
+ */
23
26
  lobbyOpen: z.array(Slug),
24
27
  });
25
28
 
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Offline replay metadata: which public endpoints a client may queue while
3
+ * offline and send later, and how the hub makes a replay land exactly once.
4
+ *
5
+ * A replayable call carries a client-made operation key in the
6
+ * {@link OFFLINE_OP_HEADER} header. The hub remembers the reply to each key
7
+ * for {@link OFFLINE_DEDUPE_TTL_MS}; a second request with the same key gets
8
+ * that reply again without running the endpoint twice. Endpoints left out
9
+ * are online-only (social, multiplayer, trades) or replay on their own terms
10
+ * (analytics events are buffered and sent at least once, without a key).
11
+ */
12
+ import { z } from 'zod';
13
+
14
+ /** The request header that carries a replayable call's operation key. */
15
+ export const OFFLINE_OP_HEADER = 'x-hub-op';
16
+
17
+ /** An operation key: letters, digits and `_ . : -`, 1 to 100 characters. */
18
+ export const OfflineOpKey = z.string().regex(/^[A-Za-z0-9_.:-]{1,100}$/);
19
+
20
+ /** How long the hub remembers an operation key's reply: 7 days. */
21
+ export const OFFLINE_DEDUPE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
22
+
23
+ /** How an endpoint behaves offline: `replay` means it may be queued and sent later, deduplicated by its operation key. */
24
+ export type OfflineMode = 'replay';
25
+
26
+ /** The public endpoints that support offline replay, by name. */
27
+ export const OFFLINE_REPLAY_ENDPOINTS = {
28
+ writeSave: 'replay',
29
+ deleteSave: 'replay',
30
+ updateGameStat: 'replay',
31
+ setGameProfile: 'replay',
32
+ submitScore: 'replay',
33
+ completeDailyChallenge: 'replay',
34
+ grantInventoryItems: 'replay',
35
+ exchangeInventoryItems: 'replay',
36
+ setGameFavorite: 'replay',
37
+ } as const satisfies Readonly<Record<string, OfflineMode>>;
38
+
39
+ /** The name of an endpoint that supports offline replay. */
40
+ export type OfflineReplayEndpoint = keyof typeof OFFLINE_REPLAY_ENDPOINTS;
41
+
42
+ /** Whether endpoint `name` supports offline replay. */
43
+ export function isOfflineReplayEndpoint(name: string): name is OfflineReplayEndpoint {
44
+ return Object.hasOwn(OFFLINE_REPLAY_ENDPOINTS, name);
45
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The tournament endpoints (batch-2026-10 R15). Anyone may look at open
3
+ * tournaments; a closed one answers 404 to everyone it wasn't shared with.
4
+ * Creating, inviting and following need a claimed account; guests may sign
5
+ * up for open tournaments (and for closed ones they were invited to) and
6
+ * play their matches.
7
+ */
8
+ import { z } from 'zod';
9
+ import { IdParam, Ok, Slug, UserId } from '../../common';
10
+ import { ErrorBody } from '../../errors';
11
+ import { requireActive, requireClaimed, session } from '../../middleware';
12
+ import { TOURNAMENT_LIMITS } from '../../limits';
13
+ import { LaunchTicket } from '../mp/parties';
14
+ import { TournamentCreate, TournamentDetail, TournamentSummary } from './shapes';
15
+
16
+ /** `:id` of a tournament. */
17
+ const TournamentParams = z.object({ id: IdParam });
18
+
19
+ /** `:id` and `:match` of a tournament match. */
20
+ const MatchParams = z.object({ id: IdParam, match: z.string().regex(/^m\d{1,4}$/) });
21
+
22
+ /** The invite key from a shared link (`?hub_key=`), letting its opener into a closed tournament. */
23
+ const InviteKey = z.string().regex(/^[A-Za-z0-9_-]{8,64}$/);
24
+
25
+ /** `GET /tournaments` filters. */
26
+ export const TournamentListQuery = z.object({
27
+ /** Only tournaments in this game. */
28
+ game: z.union([z.literal(''), Slug]).optional(),
29
+ });
30
+
31
+ /** The tournament endpoints. */
32
+ export const tournamentEndpoints = {
33
+ ...session.group({
34
+ listTournaments: {
35
+ method: 'GET', path: '/tournaments', query: TournamentListQuery,
36
+ response: z.object({ tournaments: z.array(TournamentSummary) }),
37
+ doc: { summary: 'Open tournaments plus the ones I am in, invited to or follow, soonest first', tags: ['tournaments'] },
38
+ },
39
+ getTournament: {
40
+ method: 'GET', path: '/tournaments/:id', params: TournamentParams,
41
+ query: z.object({ key: InviteKey.optional() }),
42
+ response: TournamentDetail,
43
+ errors: { 404: ErrorBody },
44
+ doc: { summary: 'A tournament with its players and bracket (404 for a closed one not shared with me)', tags: ['tournaments'] },
45
+ },
46
+ }),
47
+ ...requireClaimed.group({
48
+ createTournament: {
49
+ method: 'POST', path: '/tournaments', body: TournamentCreate, response: TournamentDetail,
50
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 422: ErrorBody, 429: ErrorBody },
51
+ doc: { summary: 'Create a tournament (I am signed up to it); the title goes through the content filter', tags: ['tournaments'] },
52
+ },
53
+ inviteToTournament: {
54
+ method: 'POST', path: '/tournaments/:id/invites', params: TournamentParams,
55
+ body: z.object({ userIds: z.array(UserId).min(1).max(TOURNAMENT_LIMITS.invitesMax) }),
56
+ response: z.object({ invited: z.number().int().nonnegative() }),
57
+ errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody, 429: ErrorBody },
58
+ doc: { summary: 'Creator: invite friends (others are skipped without saying why)', tags: ['tournaments'] },
59
+ },
60
+ followTournament: {
61
+ method: 'PUT', path: '/tournaments/:id/follow', params: TournamentParams,
62
+ body: z.object({ leadMinutes: z.number().int().min(0).max(TOURNAMENT_LIMITS.leadMinutesMax).nullish() }),
63
+ response: TournamentSummary,
64
+ errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
65
+ doc: { summary: 'Follow a tournament (hear when it opens, starts and ends); leadMinutes: remind me that long before a scheduled start (absent: no reminder)', tags: ['tournaments'] },
66
+ },
67
+ unfollowTournament: {
68
+ method: 'DELETE', path: '/tournaments/:id/follow', params: TournamentParams, response: Ok,
69
+ errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
70
+ doc: { summary: 'Stop following a tournament', tags: ['tournaments'] },
71
+ },
72
+ startTournament: {
73
+ method: 'POST', path: '/tournaments/:id/start', params: TournamentParams, response: TournamentDetail,
74
+ errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody },
75
+ doc: { summary: 'Creator: start now with whoever signed up (at least two)', tags: ['tournaments'] },
76
+ },
77
+ cancelTournament: {
78
+ method: 'DELETE', path: '/tournaments/:id', params: TournamentParams, response: Ok,
79
+ errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody },
80
+ doc: { summary: 'Creator (or an admin): cancel a tournament that has not finished', tags: ['tournaments'] },
81
+ },
82
+ }),
83
+ ...requireActive.group({
84
+ joinTournament: {
85
+ method: 'POST', path: '/tournaments/:id/join', params: TournamentParams,
86
+ body: z.object({ key: InviteKey.optional() }),
87
+ response: TournamentDetail,
88
+ errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody, 429: ErrorBody },
89
+ doc: { summary: 'Sign up (open tournaments, or closed ones I was invited to or have the link for)', tags: ['tournaments'] },
90
+ },
91
+ leaveTournament: {
92
+ method: 'POST', path: '/tournaments/:id/leave', params: TournamentParams, response: TournamentDetail,
93
+ errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody },
94
+ doc: { summary: 'Drop out: before the start my place is freed; after it I forfeit what is left', tags: ['tournaments'] },
95
+ },
96
+ launchTournamentMatch: {
97
+ method: 'POST', path: '/tournaments/:id/matches/:match/launch', params: MatchParams, response: LaunchTicket,
98
+ errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody },
99
+ doc: { summary: 'A launch into my ready match: host for the better seed, guest for the other', tags: ['tournaments'] },
100
+ },
101
+ }),
102
+ };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * "Play online" (batch-2026-10 R15): everything the viewer could play with
3
+ * someone, now and later, for the home page card and its modal. Public
4
+ * lobbies and races waiting for a player (joined through
5
+ * `POST /mp/join { userId, fromLobbyOpen: true }`), invites to the viewer,
6
+ * the tournaments they are in or follow (with a ready match first), and open
7
+ * tournaments still taking sign-ups.
8
+ */
9
+ import { z } from 'zod';
10
+ import { Millis, Slug } from '../../common';
11
+ import { PlayerLite } from '../../entities';
12
+ import { session } from '../../middleware';
13
+ import { GameMode, Invite } from '../mp/parties';
14
+ import { TournamentSummary } from './shapes';
15
+
16
+ /** A public lobby or race waiting for players right now. */
17
+ export const OpenLobbyEntry = z.object({
18
+ /** Who is waiting (join them with `POST /mp/join`). */
19
+ host: PlayerLite,
20
+ game: Slug,
21
+ gameTitle: z.string(),
22
+ /** `race` for a race waiting for racers, otherwise the lobby's mode when it has one. */
23
+ mode: GameMode.optional(),
24
+ openSeats: z.number().int().positive(),
25
+ /** When they started waiting, epoch ms. */
26
+ since: Millis,
27
+ });
28
+
29
+ /** A public lobby or race waiting for players. */
30
+ export type OpenLobbyEntry = z.infer<typeof OpenLobbyEntry>;
31
+
32
+ /** The counts on the home card. */
33
+ export const PlayOnlineCounts = z.object({
34
+ /** Lobbies to join now, plus my matches that are ready. */
35
+ now: z.number().int().nonnegative(),
36
+ /** Game invites and tournament invites waiting for me. */
37
+ invites: z.number().int().nonnegative(),
38
+ /** Tournaments I signed up for or follow, not over. */
39
+ mine: z.number().int().nonnegative(),
40
+ /** Open tournaments still taking sign-ups. */
41
+ upcoming: z.number().int().nonnegative(),
42
+ });
43
+
44
+ /** The counts on the home card. */
45
+ export type PlayOnlineCounts = z.infer<typeof PlayOnlineCounts>;
46
+
47
+ /** Everything playable online for the viewer. */
48
+ export const PlayOnline = z.object({
49
+ counts: PlayOnlineCounts,
50
+ lobbies: z.array(OpenLobbyEntry),
51
+ gameInvites: z.array(Invite),
52
+ /** Closed tournaments I'm invited to and haven't joined. */
53
+ tournamentInvites: z.array(TournamentSummary),
54
+ /** Mine: a ready match first, then the soonest. */
55
+ mine: z.array(TournamentSummary),
56
+ /** Open, taking sign-ups, not mine. */
57
+ upcoming: z.array(TournamentSummary),
58
+ });
59
+
60
+ /** Everything playable online for the viewer. */
61
+ export type PlayOnline = z.infer<typeof PlayOnline>;
62
+
63
+ /** The play-online endpoint. Signed-out visitors get the open tournaments only. */
64
+ export const playOnlineEndpoints = {
65
+ ...session.group({
66
+ getPlayOnline: {
67
+ method: 'GET', path: '/play-online', response: PlayOnline,
68
+ doc: { summary: 'Lobbies to join now, invites, my tournaments and upcoming open ones (the home page card and modal)', tags: ['tournaments'] },
69
+ },
70
+ }),
71
+ };
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Tournaments (batch-2026-10 R15): brackets of 1v1 matches in one game or a
3
+ * set of games. Single elimination, or double/triple elimination with
4
+ * redemption brackets; seeded by rank in the game or at random; started at a
5
+ * scheduled time or once enough players sign up. Open tournaments are
6
+ * listed for anyone; closed ones only for the players invited.
7
+ *
8
+ * A match is an ordinary launch into the game (`host` for the better seed,
9
+ * `guest` for the other, sharing a two-player party), carrying
10
+ * {@link TournamentLaunch}; the game reports the winner the way it already
11
+ * ends a match.
12
+ */
13
+ import { z } from 'zod';
14
+ import { Millis, Slug, UserId } from '../../common';
15
+ import { PlayerLite } from '../../entities';
16
+ import { TOURNAMENT_LIMITS } from '../../limits';
17
+
18
+ /** A tournament's id. */
19
+ export const TournamentId = z.number().int().positive();
20
+
21
+ /** `open`: listed, anyone can sign up or follow. `closed`: only invited players see it. */
22
+ export const TournamentVisibility = z.enum(['open', 'closed']);
23
+
24
+ /** How the bracket is seeded: by each player's wins in the tournament's games, or at random. */
25
+ export const TournamentSeeding = z.enum(['rank', 'random']);
26
+
27
+ /** Which game each match is: the one game, one random pick from the set for the whole tournament, or a random pick per match. */
28
+ export const TournamentGamePick = z.enum(['fixed', 'random-once', 'random-each']);
29
+
30
+ /** When a tournament starts: at a set time (with whoever signed up), or as soon as `minPlayers` sign up. */
31
+ export const TournamentStart = z.discriminatedUnion('kind', [
32
+ z.object({ kind: z.literal('scheduled'), at: Millis }),
33
+ z.object({ kind: z.literal('filled') }),
34
+ ]);
35
+
36
+ /** When a tournament starts. */
37
+ export type TournamentStart = z.infer<typeof TournamentStart>;
38
+
39
+ /** `signup` → `running` → `done`; or `cancelled` (by its creator, or too few players at the start). */
40
+ export const TournamentPhase = z.enum(['signup', 'running', 'done', 'cancelled']);
41
+
42
+ /** A tournament's phase. */
43
+ export type TournamentPhase = z.infer<typeof TournamentPhase>;
44
+
45
+ /** Where a match sits: the winners bracket, a redemption bracket (players with losses), or the final ladder. */
46
+ export const TournamentStage = z.enum(['winners', 'redemption', 'final']);
47
+
48
+ /** How a match was decided. */
49
+ export const TournamentDecision = z.enum(['played', 'forfeit', 'bye', 'timeout', 'withdrawn']);
50
+
51
+ /** How a match was decided. */
52
+ export type TournamentDecision = z.infer<typeof TournamentDecision>;
53
+
54
+ /** A player count within the tournament limits. */
55
+ const PlayerCount = z.number().int().min(TOURNAMENT_LIMITS.playersMin).max(TOURNAMENT_LIMITS.playersMax);
56
+
57
+ /** `POST /tournaments`. Everything but `games` has a default: the quick "public 1v1, single elimination". */
58
+ export const TournamentCreate = z.object({
59
+ /** Shown to everyone who can see the tournament; checked by the content filter. Default: made from the game's title. */
60
+ title: z.string().trim().min(1).max(TOURNAMENT_LIMITS.titleMax).optional(),
61
+ /** The game, or the set to pick from; each must support tournaments. */
62
+ games: z.array(Slug).min(1).max(TOURNAMENT_LIMITS.gamesMax),
63
+ pick: TournamentGamePick.default('fixed'),
64
+ /** Lives per player: 1 single (default), 2 double, 3 triple elimination. */
65
+ lives: z.number().int().min(1).max(TOURNAMENT_LIMITS.livesMax).default(1),
66
+ seeding: TournamentSeeding.default('random'),
67
+ visibility: TournamentVisibility.default('open'),
68
+ start: TournamentStart.default({ kind: 'filled' }),
69
+ /** Players needed to start (a `filled` start), or to go ahead at the scheduled time. Default 2. */
70
+ minPlayers: PlayerCount.default(2),
71
+ /** Most players who can sign up. Default 2. */
72
+ maxPlayers: PlayerCount.default(2),
73
+ /** Minutes a ready match waits for its players before the absent side forfeits. */
74
+ readyMinutes: z.number().int().min(1).max(TOURNAMENT_LIMITS.readyMinutesMax).default(TOURNAMENT_LIMITS.readyMinutesDefault),
75
+ /** Friends to invite now (closed tournaments, or a nudge for open ones). */
76
+ invite: z.array(UserId).max(TOURNAMENT_LIMITS.invitesMax).optional(),
77
+ });
78
+
79
+ /** What creating a tournament takes. */
80
+ export type TournamentCreate = z.output<typeof TournamentCreate>;
81
+
82
+ /** One entrant, as the bracket shows them. */
83
+ export const TournamentPlayer = z.object({
84
+ user: PlayerLite,
85
+ /** 1 is the top seed; `null` until the bracket is drawn. */
86
+ seed: z.number().int().positive().nullable(),
87
+ wins: z.number().int().nonnegative(),
88
+ losses: z.number().int().nonnegative(),
89
+ /** Knocked out (lost every life, or withdrew). */
90
+ out: z.boolean(),
91
+ });
92
+
93
+ /** One entrant. */
94
+ export type TournamentPlayer = z.infer<typeof TournamentPlayer>;
95
+
96
+ /** One match in the bracket. Sides are user ids (see the detail's `players`). */
97
+ export const TournamentMatch = z.object({
98
+ id: z.string(),
99
+ stage: TournamentStage,
100
+ /** Redemption bracket number (losses), 0 elsewhere. */
101
+ level: z.number().int().nonnegative(),
102
+ round: z.number().int().positive(),
103
+ slot: z.number().int().nonnegative(),
104
+ /** `null` while unknown, or the empty side of a bye. */
105
+ a: UserId.nullable(),
106
+ b: UserId.nullable(),
107
+ status: z.enum(['waiting', 'ready', 'done']),
108
+ winnerId: UserId.nullable(),
109
+ decision: TournamentDecision.nullable(),
110
+ /** The game this match is played in, once known. */
111
+ game: Slug.nullable(),
112
+ /** Ready matches: when the absent side forfeits, epoch ms. */
113
+ deadlineAt: Millis.nullable(),
114
+ /** Whether the match has started in the game. */
115
+ started: z.boolean(),
116
+ });
117
+
118
+ /** One match. */
119
+ export type TournamentMatch = z.infer<typeof TournamentMatch>;
120
+
121
+ /** A ready match of mine: who against, in which game, and until when. */
122
+ export const MyTournamentMatch = z.object({
123
+ id: z.string(),
124
+ opponent: PlayerLite.nullable(),
125
+ game: Slug,
126
+ gameTitle: z.string(),
127
+ deadlineAt: Millis,
128
+ started: z.boolean(),
129
+ });
130
+
131
+ /** A ready match of mine. */
132
+ export type MyTournamentMatch = z.infer<typeof MyTournamentMatch>;
133
+
134
+ /** The viewer's own relation to a tournament. */
135
+ export const TournamentMe = z.object({
136
+ joined: z.boolean(),
137
+ /** Invited and not yet signed up. */
138
+ invited: z.boolean(),
139
+ following: z.boolean(),
140
+ /** Minutes before a scheduled start to remind me; `null` for no reminder. */
141
+ leadMinutes: z.number().int().nonnegative().nullable(),
142
+ isCreator: z.boolean(),
143
+ /** Still in (signed up and not knocked out). */
144
+ stillIn: z.boolean(),
145
+ nextMatch: MyTournamentMatch.nullable(),
146
+ });
147
+
148
+ /** A tournament in lists. */
149
+ export const TournamentSummary = z.object({
150
+ id: TournamentId,
151
+ title: z.string(),
152
+ creator: PlayerLite.nullable(),
153
+ visibility: TournamentVisibility,
154
+ phase: TournamentPhase,
155
+ /** The game set (one game for `fixed`). */
156
+ games: z.array(Slug),
157
+ pick: TournamentGamePick,
158
+ /** The game every match is in, once known (`fixed`, or `random-once` after the start); `null` for `random-each`. */
159
+ game: Slug.nullable(),
160
+ /** A title per game slug. */
161
+ gameTitles: z.record(Slug, z.string()),
162
+ lives: z.number().int().min(1),
163
+ seeding: TournamentSeeding,
164
+ start: TournamentStart,
165
+ minPlayers: z.number().int(),
166
+ maxPlayers: z.number().int(),
167
+ readyMinutes: z.number().int(),
168
+ /** How many have signed up. */
169
+ entrants: z.number().int().nonnegative(),
170
+ createdAt: Millis,
171
+ startedAt: Millis.nullable(),
172
+ endedAt: Millis.nullable(),
173
+ champion: PlayerLite.nullable(),
174
+ me: TournamentMe,
175
+ });
176
+
177
+ /** A tournament in lists. */
178
+ export type TournamentSummary = z.infer<typeof TournamentSummary>;
179
+
180
+ /** A tournament with its players and bracket. */
181
+ export const TournamentDetail = TournamentSummary.extend({
182
+ players: z.array(TournamentPlayer),
183
+ matches: z.array(TournamentMatch),
184
+ /** The creator's link that lets friends of friends in (closed tournaments, creator only). */
185
+ inviteUrl: z.string().nullable(),
186
+ });
187
+
188
+ /** A tournament with its bracket. */
189
+ export type TournamentDetail = z.infer<typeof TournamentDetail>;
190
+
191
+ /** What a launch into a tournament match carries (`launch.tournament`). */
192
+ export const TournamentLaunch = z.object({
193
+ id: TournamentId,
194
+ title: z.string(),
195
+ /** The match id within the tournament; own-server games send it back with the result. */
196
+ match: z.string(),
197
+ /** Who to play. */
198
+ opponent: PlayerLite.nullable(),
199
+ });
200
+
201
+ /** What a launch into a tournament match carries. */
202
+ export type TournamentLaunch = z.infer<typeof TournamentLaunch>;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Tournaments and "Play online" (batch-2026-10 R15), split by topic under
3
+ * `tournaments/`.
4
+ */
5
+ export * from './tournaments/shapes';
6
+ export * from './tournaments/endpoints';
7
+ export * from './tournaments/play-online';
@@ -1,4 +1,4 @@
1
- /** Error codes from invites, parties, hub rooms, races and the socket envelope. */
1
+ /** Error codes from invites, invite links, parties, hub rooms, races, tournaments and the socket envelope. */
2
2
  export const MULTIPLAYER_CODES = [
3
3
  // Socket envelope.
4
4
  'unknown_op',
@@ -18,6 +18,10 @@ export const MULTIPLAYER_CODES = [
18
18
  'bad_info',
19
19
  'launch_invalid',
20
20
  'not_joinable',
21
+ // Invite links.
22
+ 'link_invalid',
23
+ 'link_room_gone',
24
+ 'too_many_links',
21
25
  // Group chat.
22
26
  'bad_quick',
23
27
  // Hub rooms.
@@ -40,4 +44,13 @@ export const MULTIPLAYER_CODES = [
40
44
  'too_fast',
41
45
  'bad_stats',
42
46
  'bad_param',
47
+ // Tournaments.
48
+ 'tournament_not_found',
49
+ 'tournament_closed',
50
+ 'tournament_full',
51
+ 'tournament_over',
52
+ 'not_creator',
53
+ 'not_entered',
54
+ 'cannot_join',
55
+ 'match_not_ready',
43
56
  ] as const;
package/src/limits.ts CHANGED
@@ -98,3 +98,28 @@ export const MANIFEST_LIMITS = {
98
98
  /** Largest `game.race.minMs`, in ms (10 minutes). */
99
99
  raceMinMsMax: 600_000,
100
100
  } as const;
101
+
102
+ /** What a tournament may be (batch-2026-10 R15). The server enforces each one. */
103
+ export const TOURNAMENT_LIMITS = {
104
+ /** Longest title, in characters. */
105
+ titleMax: 48,
106
+ /** Fewest players. */
107
+ playersMin: 2,
108
+ /** Most players. */
109
+ playersMax: 64,
110
+ /** Most games in the set a tournament picks from. */
111
+ gamesMax: 8,
112
+ /** Lives per player: 1 single, 2 double, 3 triple elimination. */
113
+ livesMax: 3,
114
+ /** The default and the longest window to join a ready match before forfeiting, in minutes. */
115
+ readyMinutesDefault: 10,
116
+ readyMinutesMax: 60,
117
+ /** The longest "remind me before it starts", in minutes (one day). */
118
+ leadMinutesMax: 1440,
119
+ /** The default reminder for players who signed up for a scheduled tournament, in minutes. */
120
+ leadMinutesDefault: 10,
121
+ /** The furthest ahead a tournament may be scheduled, in days. */
122
+ scheduleDaysMax: 30,
123
+ /** Most friends invited in one call. */
124
+ invitesMax: 50,
125
+ } as const;
@@ -11,6 +11,7 @@ import { MANIFEST_LIMITS } from '../limits';
11
11
  import { GameChanges } from './changes';
12
12
  import { GameControls } from './controls';
13
13
  import { AppFilePath, Command, Trimmed, orFalse } from './fields';
14
+ import { GameLabels } from './labels';
14
15
  import { GameLeaderboardDecls } from './leaderboards';
15
16
  import { GameMultiplayer, StaticGameMultiplayer } from './multiplayer';
16
17
  import { GameRace } from './race';
@@ -63,6 +64,12 @@ const commonFields = {
63
64
  menuPosition: MenuPosition.optional(),
64
65
  /** Push the menu button away from its corner, px: a number (vertical) or `{ x, y }`. */
65
66
  menuOffset: MenuOffset.optional(),
67
+ /**
68
+ * What kind of game it is, from the fixed vocabulary in `labels.ts` (genre, look,
69
+ * where it plays, how many play), for the catalog's label filter. Unknown labels
70
+ * are a warning, not an error.
71
+ */
72
+ labels: GameLabels.optional(),
66
73
  /** `true` opts into the shared daily challenges (`hub.daily.define(...)`). */
67
74
  dailyChallenges: z.boolean().optional(),
68
75
  /** Boards to create up front, so they show before the first score. */
@@ -7,6 +7,7 @@ export * from './controls';
7
7
  export { AppFilePath, COMMAND_PLACEHOLDERS, Command, LobbySuffix } from './fields';
8
8
  export * from './game';
9
9
  export * from './inject';
10
+ export * from './labels';
10
11
  export * from './leaderboards';
11
12
  export * from './multiplayer';
12
13
  export * from './race';