@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/dist/index.js CHANGED
@@ -96,7 +96,7 @@ const CORE_CODES = [
96
96
  ];
97
97
  //#endregion
98
98
  //#region src/errors/codes/multiplayer.ts
99
- /** Error codes from invites, parties, hub rooms, races and the socket envelope. */
99
+ /** Error codes from invites, invite links, parties, hub rooms, races, tournaments and the socket envelope. */
100
100
  const MULTIPLAYER_CODES = [
101
101
  "unknown_op",
102
102
  "hello_required",
@@ -113,6 +113,9 @@ const MULTIPLAYER_CODES = [
113
113
  "bad_info",
114
114
  "launch_invalid",
115
115
  "not_joinable",
116
+ "link_invalid",
117
+ "link_room_gone",
118
+ "too_many_links",
116
119
  "bad_quick",
117
120
  "room_not_found",
118
121
  "not_in_room",
@@ -131,7 +134,15 @@ const MULTIPLAYER_CODES = [
131
134
  "race_over",
132
135
  "too_fast",
133
136
  "bad_stats",
134
- "bad_param"
137
+ "bad_param",
138
+ "tournament_not_found",
139
+ "tournament_closed",
140
+ "tournament_full",
141
+ "tournament_over",
142
+ "not_creator",
143
+ "not_entered",
144
+ "cannot_join",
145
+ "match_not_ready"
135
146
  ];
136
147
  //#endregion
137
148
  //#region src/errors/codes/play.ts
@@ -605,6 +616,30 @@ const MANIFEST_LIMITS = {
605
616
  /** Largest `game.race.minMs`, in ms (10 minutes). */
606
617
  raceMinMsMax: 6e5
607
618
  };
619
+ /** What a tournament may be (batch-2026-10 R15). The server enforces each one. */
620
+ const TOURNAMENT_LIMITS = {
621
+ /** Longest title, in characters. */
622
+ titleMax: 48,
623
+ /** Fewest players. */
624
+ playersMin: 2,
625
+ /** Most players. */
626
+ playersMax: 64,
627
+ /** Most games in the set a tournament picks from. */
628
+ gamesMax: 8,
629
+ /** Lives per player: 1 single, 2 double, 3 triple elimination. */
630
+ livesMax: 3,
631
+ /** The default and the longest window to join a ready match before forfeiting, in minutes. */
632
+ readyMinutesDefault: 10,
633
+ readyMinutesMax: 60,
634
+ /** The longest "remind me before it starts", in minutes (one day). */
635
+ leadMinutesMax: 1440,
636
+ /** The default reminder for players who signed up for a scheduled tournament, in minutes. */
637
+ leadMinutesDefault: 10,
638
+ /** The furthest ahead a tournament may be scheduled, in days. */
639
+ scheduleDaysMax: 30,
640
+ /** Most friends invited in one call. */
641
+ invitesMax: 50
642
+ };
608
643
  //#endregion
609
644
  //#region src/api/mp/races.ts
610
645
  /**
@@ -926,9 +961,65 @@ const RaceBlock = z.strictObject({
926
961
  */
927
962
  const GameRace = z.preprocess((v) => v === true ? {} : v === false ? void 0 : v, RaceBlock.optional()).optional();
928
963
  //#endregion
964
+ //#region src/api/catalog-activity.ts
965
+ /**
966
+ * Who is in each game right now (aggregate head counts only: never who), and
967
+ * the signed-in player's own activity in one game. The catalog's cards, the
968
+ * menu's game list and the game sheet show them.
969
+ */
970
+ /** A non-negative head count. */
971
+ const HeadCount$1 = z.number().int().nonnegative();
972
+ /**
973
+ * How many people have a game open right now: claimed accounts and guests.
974
+ * Counts only, so no player is ever identified.
975
+ */
976
+ const GamePlayerCount = z.object({
977
+ accounts: HeadCount$1,
978
+ guests: HeadCount$1
979
+ });
980
+ /** Head counts per game slug; games nobody has open are absent. */
981
+ const GamePlayerCounts = z.record(Slug, GamePlayerCount);
982
+ /** The caller's activity in one game, within the analytics retention window. */
983
+ const MyGameActivity = z.object({
984
+ /** Times the game was opened. */
985
+ plays: HeadCount$1,
986
+ /** Minutes with the game open and visible (one heartbeat a minute). */
987
+ minutesPlayed: HeadCount$1,
988
+ /** The first and latest event in the window, epoch ms; `null` when there are none. */
989
+ firstPlayedAt: Millis.nullable(),
990
+ lastPlayedAt: Millis.nullable(),
991
+ /** How many days back the counts reach. */
992
+ windowDays: z.number().int().positive()
993
+ });
994
+ /** The activity endpoints (part of the catalog area). */
995
+ const catalogActivityEndpoints = {
996
+ ...session.group({ getGamePlayerCounts: {
997
+ method: "GET",
998
+ path: "/catalog/players",
999
+ response: z.object({ games: GamePlayerCounts }),
1000
+ doc: {
1001
+ summary: "How many accounts and guests have each game open right now (counts only)",
1002
+ tags: ["catalog"]
1003
+ }
1004
+ } }),
1005
+ ...use(requireUser, game).path("/games/:slug").group({ getMyGameActivity: {
1006
+ method: "GET",
1007
+ path: "/me/activity",
1008
+ response: MyGameActivity,
1009
+ errors: {
1010
+ 401: ErrorBody,
1011
+ 404: ErrorBody
1012
+ },
1013
+ doc: {
1014
+ summary: "The caller's plays and time played in this game",
1015
+ tags: ["catalog"]
1016
+ }
1017
+ } })
1018
+ };
1019
+ //#endregion
929
1020
  //#region src/api/catalog.ts
930
1021
  /**
931
- * The game catalog, favorites and What's new notes.
1022
+ * The game catalog, favorites, What's new notes, and who is playing what (counts only).
932
1023
  */
933
1024
  /** A `[min, max]` player count, each 1 to 8. */
934
1025
  const PlayerRange = z.tuple([z.number().int().min(1).max(8), z.number().int().min(1).max(8)]);
@@ -987,7 +1078,9 @@ const GameInfo = z.object({
987
1078
  /** `null` for single-player games. */
988
1079
  multiplayer: MultiplayerSummary.nullable(),
989
1080
  /** `null` unless the game supports races. */
990
- race: RaceSummary.nullable()
1081
+ race: RaceSummary.nullable(),
1082
+ /** Present (and `true`) when tournaments can be played in this game: it races, or declares `multiplayer.tournaments`. */
1083
+ tournaments: z.literal(true).optional()
991
1084
  });
992
1085
  /** `{ games }`: the caller's starred slugs, oldest star first. */
993
1086
  const Favorites = z.object({ games: z.array(Slug) });
@@ -1006,6 +1099,7 @@ const ChangeNote = z.object({
1006
1099
  });
1007
1100
  /** The catalog endpoints. */
1008
1101
  const catalogEndpoints = {
1102
+ ...catalogActivityEndpoints,
1009
1103
  ...session.group({
1010
1104
  listGames: {
1011
1105
  method: "GET",
@@ -1926,6 +2020,182 @@ upTo: requiredId("upTo must be a message id.") }),
1926
2020
  }
1927
2021
  }) };
1928
2022
  //#endregion
2023
+ //#region src/api/tournaments/shapes.ts
2024
+ /**
2025
+ * Tournaments (batch-2026-10 R15): brackets of 1v1 matches in one game or a
2026
+ * set of games. Single elimination, or double/triple elimination with
2027
+ * redemption brackets; seeded by rank in the game or at random; started at a
2028
+ * scheduled time or once enough players sign up. Open tournaments are
2029
+ * listed for anyone; closed ones only for the players invited.
2030
+ *
2031
+ * A match is an ordinary launch into the game (`host` for the better seed,
2032
+ * `guest` for the other, sharing a two-player party), carrying
2033
+ * {@link TournamentLaunch}; the game reports the winner the way it already
2034
+ * ends a match.
2035
+ */
2036
+ /** A tournament's id. */
2037
+ const TournamentId = z.number().int().positive();
2038
+ /** `open`: listed, anyone can sign up or follow. `closed`: only invited players see it. */
2039
+ const TournamentVisibility = z.enum(["open", "closed"]);
2040
+ /** How the bracket is seeded: by each player's wins in the tournament's games, or at random. */
2041
+ const TournamentSeeding = z.enum(["rank", "random"]);
2042
+ /** Which game each match is: the one game, one random pick from the set for the whole tournament, or a random pick per match. */
2043
+ const TournamentGamePick = z.enum([
2044
+ "fixed",
2045
+ "random-once",
2046
+ "random-each"
2047
+ ]);
2048
+ /** When a tournament starts: at a set time (with whoever signed up), or as soon as `minPlayers` sign up. */
2049
+ const TournamentStart = z.discriminatedUnion("kind", [z.object({
2050
+ kind: z.literal("scheduled"),
2051
+ at: Millis
2052
+ }), z.object({ kind: z.literal("filled") })]);
2053
+ /** `signup` → `running` → `done`; or `cancelled` (by its creator, or too few players at the start). */
2054
+ const TournamentPhase = z.enum([
2055
+ "signup",
2056
+ "running",
2057
+ "done",
2058
+ "cancelled"
2059
+ ]);
2060
+ /** Where a match sits: the winners bracket, a redemption bracket (players with losses), or the final ladder. */
2061
+ const TournamentStage = z.enum([
2062
+ "winners",
2063
+ "redemption",
2064
+ "final"
2065
+ ]);
2066
+ /** How a match was decided. */
2067
+ const TournamentDecision = z.enum([
2068
+ "played",
2069
+ "forfeit",
2070
+ "bye",
2071
+ "timeout",
2072
+ "withdrawn"
2073
+ ]);
2074
+ /** A player count within the tournament limits. */
2075
+ const PlayerCount = z.number().int().min(TOURNAMENT_LIMITS.playersMin).max(TOURNAMENT_LIMITS.playersMax);
2076
+ /** `POST /tournaments`. Everything but `games` has a default: the quick "public 1v1, single elimination". */
2077
+ const TournamentCreate = z.object({
2078
+ /** Shown to everyone who can see the tournament; checked by the content filter. Default: made from the game's title. */
2079
+ title: z.string().trim().min(1).max(TOURNAMENT_LIMITS.titleMax).optional(),
2080
+ /** The game, or the set to pick from; each must support tournaments. */
2081
+ games: z.array(Slug).min(1).max(TOURNAMENT_LIMITS.gamesMax),
2082
+ pick: TournamentGamePick.default("fixed"),
2083
+ /** Lives per player: 1 single (default), 2 double, 3 triple elimination. */
2084
+ lives: z.number().int().min(1).max(TOURNAMENT_LIMITS.livesMax).default(1),
2085
+ seeding: TournamentSeeding.default("random"),
2086
+ visibility: TournamentVisibility.default("open"),
2087
+ start: TournamentStart.default({ kind: "filled" }),
2088
+ /** Players needed to start (a `filled` start), or to go ahead at the scheduled time. Default 2. */
2089
+ minPlayers: PlayerCount.default(2),
2090
+ /** Most players who can sign up. Default 2. */
2091
+ maxPlayers: PlayerCount.default(2),
2092
+ /** Minutes a ready match waits for its players before the absent side forfeits. */
2093
+ readyMinutes: z.number().int().min(1).max(TOURNAMENT_LIMITS.readyMinutesMax).default(TOURNAMENT_LIMITS.readyMinutesDefault),
2094
+ /** Friends to invite now (closed tournaments, or a nudge for open ones). */
2095
+ invite: z.array(UserId).max(TOURNAMENT_LIMITS.invitesMax).optional()
2096
+ });
2097
+ /** One entrant, as the bracket shows them. */
2098
+ const TournamentPlayer = z.object({
2099
+ user: PlayerLite,
2100
+ /** 1 is the top seed; `null` until the bracket is drawn. */
2101
+ seed: z.number().int().positive().nullable(),
2102
+ wins: z.number().int().nonnegative(),
2103
+ losses: z.number().int().nonnegative(),
2104
+ /** Knocked out (lost every life, or withdrew). */
2105
+ out: z.boolean()
2106
+ });
2107
+ /** One match in the bracket. Sides are user ids (see the detail's `players`). */
2108
+ const TournamentMatch = z.object({
2109
+ id: z.string(),
2110
+ stage: TournamentStage,
2111
+ /** Redemption bracket number (losses), 0 elsewhere. */
2112
+ level: z.number().int().nonnegative(),
2113
+ round: z.number().int().positive(),
2114
+ slot: z.number().int().nonnegative(),
2115
+ /** `null` while unknown, or the empty side of a bye. */
2116
+ a: UserId.nullable(),
2117
+ b: UserId.nullable(),
2118
+ status: z.enum([
2119
+ "waiting",
2120
+ "ready",
2121
+ "done"
2122
+ ]),
2123
+ winnerId: UserId.nullable(),
2124
+ decision: TournamentDecision.nullable(),
2125
+ /** The game this match is played in, once known. */
2126
+ game: Slug.nullable(),
2127
+ /** Ready matches: when the absent side forfeits, epoch ms. */
2128
+ deadlineAt: Millis.nullable(),
2129
+ /** Whether the match has started in the game. */
2130
+ started: z.boolean()
2131
+ });
2132
+ /** A ready match of mine: who against, in which game, and until when. */
2133
+ const MyTournamentMatch = z.object({
2134
+ id: z.string(),
2135
+ opponent: PlayerLite.nullable(),
2136
+ game: Slug,
2137
+ gameTitle: z.string(),
2138
+ deadlineAt: Millis,
2139
+ started: z.boolean()
2140
+ });
2141
+ /** The viewer's own relation to a tournament. */
2142
+ const TournamentMe = z.object({
2143
+ joined: z.boolean(),
2144
+ /** Invited and not yet signed up. */
2145
+ invited: z.boolean(),
2146
+ following: z.boolean(),
2147
+ /** Minutes before a scheduled start to remind me; `null` for no reminder. */
2148
+ leadMinutes: z.number().int().nonnegative().nullable(),
2149
+ isCreator: z.boolean(),
2150
+ /** Still in (signed up and not knocked out). */
2151
+ stillIn: z.boolean(),
2152
+ nextMatch: MyTournamentMatch.nullable()
2153
+ });
2154
+ /** A tournament in lists. */
2155
+ const TournamentSummary = z.object({
2156
+ id: TournamentId,
2157
+ title: z.string(),
2158
+ creator: PlayerLite.nullable(),
2159
+ visibility: TournamentVisibility,
2160
+ phase: TournamentPhase,
2161
+ /** The game set (one game for `fixed`). */
2162
+ games: z.array(Slug),
2163
+ pick: TournamentGamePick,
2164
+ /** The game every match is in, once known (`fixed`, or `random-once` after the start); `null` for `random-each`. */
2165
+ game: Slug.nullable(),
2166
+ /** A title per game slug. */
2167
+ gameTitles: z.record(Slug, z.string()),
2168
+ lives: z.number().int().min(1),
2169
+ seeding: TournamentSeeding,
2170
+ start: TournamentStart,
2171
+ minPlayers: z.number().int(),
2172
+ maxPlayers: z.number().int(),
2173
+ readyMinutes: z.number().int(),
2174
+ /** How many have signed up. */
2175
+ entrants: z.number().int().nonnegative(),
2176
+ createdAt: Millis,
2177
+ startedAt: Millis.nullable(),
2178
+ endedAt: Millis.nullable(),
2179
+ champion: PlayerLite.nullable(),
2180
+ me: TournamentMe
2181
+ });
2182
+ /** A tournament with its players and bracket. */
2183
+ const TournamentDetail = TournamentSummary.extend({
2184
+ players: z.array(TournamentPlayer),
2185
+ matches: z.array(TournamentMatch),
2186
+ /** The creator's link that lets friends of friends in (closed tournaments, creator only). */
2187
+ inviteUrl: z.string().nullable()
2188
+ });
2189
+ /** What a launch into a tournament match carries (`launch.tournament`). */
2190
+ const TournamentLaunch = z.object({
2191
+ id: TournamentId,
2192
+ title: z.string(),
2193
+ /** The match id within the tournament; own-server games send it back with the result. */
2194
+ match: z.string(),
2195
+ /** Who to play. */
2196
+ opponent: PlayerLite.nullable()
2197
+ });
2198
+ //#endregion
1929
2199
  //#region src/api/mp/parties.ts
1930
2200
  /**
1931
2201
  * Invites, parties and launches (multiplayer-impl.md §5.6): the shapes the
@@ -2010,9 +2280,44 @@ const Launch = z.object({
2010
2280
  /** Guest launches: the party leader (`null` if the account is gone). */
2011
2281
  host: PlayerLite.nullable().optional(),
2012
2282
  /** Join and watch launches: the friend being joined (`null` if the account is gone). */
2013
- target: PlayerLite.nullable().optional()
2283
+ target: PlayerLite.nullable().optional(),
2284
+ /** `link`: opened from an invite link; `rejoin`: taking back a seat I still hold (both are join or watch launches). */
2285
+ via: z.enum(["link", "rejoin"]).optional(),
2286
+ /** Host and guest launches into a tournament match: which tournament, which match, and who to play. */
2287
+ tournament: TournamentLaunch.optional()
2014
2288
  });
2015
2289
  //#endregion
2290
+ //#region src/api/mp/invite-links.ts
2291
+ /**
2292
+ * Invite links (batch-2026-10 R13): a link any room member can share that
2293
+ * drops whoever opens it into that room, as a player when a seat is free,
2294
+ * otherwise as a spectator. A link is an unguessable token kept by the hub;
2295
+ * it expires (24 h) and its creator or the room's host can revoke it. Link
2296
+ * joiners get the room's usual chat rules and are not friended.
2297
+ */
2298
+ /** An invite link's token (base64url). */
2299
+ const InviteLinkToken = z.string().regex(/^[A-Za-z0-9_-]{16,64}$/);
2300
+ /** A live invite link. */
2301
+ const InviteLink = z.object({
2302
+ token: InviteLinkToken,
2303
+ /** `/<slug>/<lobby>?hub_link=<token>`, relative to the site origin. */
2304
+ url: z.string(),
2305
+ game: Slug,
2306
+ mode: GameMode.optional(),
2307
+ /** The room it opens: a hub room code, or an own-server game's room id from presence. */
2308
+ room: z.string(),
2309
+ /** Who made it. */
2310
+ createdBy: UserId,
2311
+ /** Epoch ms. */
2312
+ expiresAt: Millis
2313
+ });
2314
+ /** `:token` of an invite link. */
2315
+ const InviteLinkParams = z.object({ token: InviteLinkToken });
2316
+ /** `POST /mp/links` body: a link to the room I'm in, in this game. */
2317
+ const InviteLinkCreate = z.object({ game: Slug });
2318
+ /** `GET /mp/links` query: my links in a game (and the links to rooms I host). */
2319
+ const InviteLinkQuery = z.object({ game: Slug });
2320
+ //#endregion
2016
2321
  //#region src/api/mp/chat.ts
2017
2322
  /**
2018
2323
  * Group chat in hub rooms and parties (multiplayer.md §4.3, §4.8): what a
@@ -2066,6 +2371,14 @@ const RoomPhase = z.enum([
2066
2371
  * included.
2067
2372
  */
2068
2373
  const RoomModel = z.enum(["host", "relay"]);
2374
+ /**
2375
+ * When the host role moves on mid-game. `grace` (default): only once the
2376
+ * host's seat is freed (they left, or their reconnect grace ran out), so a
2377
+ * host whose browser holds hidden state can come back and carry on. `drop`:
2378
+ * as soon as the host drops, for games whose whole truth is in the shared
2379
+ * state, so the match never waits.
2380
+ */
2381
+ const HostMigration = z.enum(["grace", "drop"]);
2069
2382
  /** A seat in a room, keyed by user (any tab of that user reclaims it). */
2070
2383
  const RoomSeat = z.object({
2071
2384
  userId: UserId,
@@ -2076,6 +2389,8 @@ const RoomSeat = z.object({
2076
2389
  ready: z.boolean(),
2077
2390
  /** `false` while the seat is held for a dropped player's reconnect grace. */
2078
2391
  connected: z.boolean(),
2392
+ /** While the seat is held (`connected: false`): when the grace runs out and the seat is freed (epoch ms). */
2393
+ graceEndsAt: Millis.optional(),
2079
2394
  /** Small per-seat game data (team, colour…), at most 2 KB of JSON. */
2080
2395
  data: Json.optional()
2081
2396
  });
@@ -2127,12 +2442,32 @@ const RoomClosedReason = z.enum([
2127
2442
  "other_tab",
2128
2443
  "removed"
2129
2444
  ]);
2130
- /** Why a seat was freed (`room.abandoned`). */
2445
+ /** Why a seat was freed (`room.abandoned`, `room.seat` `gone`). */
2131
2446
  const SeatGoneReason = z.enum([
2132
2447
  "left",
2133
2448
  "abandoned",
2134
2449
  "kicked"
2135
2450
  ]);
2451
+ /**
2452
+ * What happened to a seat (`room.seat`): `away` (the player dropped; the seat
2453
+ * is held until `graceEndsAt`, so the game can hand it to its computer
2454
+ * player), `back` (they reclaimed it: give control back), `gone` (freed for
2455
+ * good: left, kicked, or the grace ran out).
2456
+ */
2457
+ const SeatStatus = z.enum([
2458
+ "away",
2459
+ "back",
2460
+ "gone"
2461
+ ]);
2462
+ /** A seat change (`room.seat`). */
2463
+ const SeatChange = z.object({
2464
+ userId: UserId,
2465
+ status: SeatStatus,
2466
+ /** `away`: when the held seat is freed (epoch ms). */
2467
+ graceEndsAt: Millis.optional(),
2468
+ /** `gone`: why. */
2469
+ reason: SeatGoneReason.optional()
2470
+ });
2136
2471
  //#endregion
2137
2472
  //#region src/api/mp/snapshot.ts
2138
2473
  /** The multiplayer part of the socket `hello` snapshot (multiplayer-impl.md §4.5). */
@@ -2226,6 +2561,53 @@ const mpEndpoints = {
2226
2561
  tags: ["mp"]
2227
2562
  }
2228
2563
  },
2564
+ createInviteLink: {
2565
+ method: "POST",
2566
+ path: "/mp/links",
2567
+ body: InviteLinkCreate,
2568
+ response: InviteLink,
2569
+ errors: {
2570
+ 400: ErrorBody,
2571
+ 401: ErrorBody,
2572
+ 403: ErrorBody,
2573
+ 404: ErrorBody,
2574
+ 409: ErrorBody,
2575
+ 429: ErrorBody
2576
+ },
2577
+ doc: {
2578
+ 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",
2579
+ tags: ["mp"]
2580
+ }
2581
+ },
2582
+ listInviteLinks: {
2583
+ method: "GET",
2584
+ path: "/mp/links",
2585
+ query: InviteLinkQuery,
2586
+ response: z.object({ links: z.array(InviteLink) }),
2587
+ errors: {
2588
+ 401: ErrorBody,
2589
+ 403: ErrorBody
2590
+ },
2591
+ doc: {
2592
+ summary: "My live invite links in a game, plus the links to rooms I host",
2593
+ tags: ["mp"]
2594
+ }
2595
+ },
2596
+ revokeInviteLink: {
2597
+ method: "DELETE",
2598
+ path: "/mp/links/:token",
2599
+ params: InviteLinkParams,
2600
+ response: Ok,
2601
+ errors: {
2602
+ 401: ErrorBody,
2603
+ 403: ErrorBody,
2604
+ 404: ErrorBody
2605
+ },
2606
+ doc: {
2607
+ summary: "Revoke an invite link (its creator, or the host of the room it opens)",
2608
+ tags: ["mp"]
2609
+ }
2610
+ },
2229
2611
  cancelGameInvite: {
2230
2612
  method: "DELETE",
2231
2613
  path: "/mp/invites/:id",
@@ -2258,6 +2640,23 @@ const mpEndpoints = {
2258
2640
  tags: ["mp"]
2259
2641
  }
2260
2642
  },
2643
+ openInviteLink: {
2644
+ method: "POST",
2645
+ path: "/mp/links/:token/open",
2646
+ params: InviteLinkParams,
2647
+ response: LaunchTicket,
2648
+ errors: {
2649
+ 401: ErrorBody,
2650
+ 403: ErrorBody,
2651
+ 404: ErrorBody,
2652
+ 409: ErrorBody,
2653
+ 429: ErrorBody
2654
+ },
2655
+ doc: {
2656
+ summary: "Open an invite link: a join launch when a seat is free, else a watch launch",
2657
+ tags: ["mp"]
2658
+ }
2659
+ },
2261
2660
  setPartyJoinInfo: {
2262
2661
  method: "POST",
2263
2662
  path: "/mp/parties/:id/join-info",
@@ -2351,7 +2750,10 @@ const NotifyPrefs = z.object({
2351
2750
  friendOnlineAll: z.boolean(),
2352
2751
  /** Friends whose bell is on, ascending. */
2353
2752
  friendOnline: z.array(UserId),
2354
- /** Games followed for "someone's waiting in a lobby", sorted. */
2753
+ /**
2754
+ * Games followed for "someone wants to play" (the game sheet's notify toggle):
2755
+ * an open public lobby or race, or a tournament opening, sorted.
2756
+ */
2355
2757
  lobbyOpen: z.array(Slug)
2356
2758
  });
2357
2759
  /** The preference kinds `PUT /social/notify` can flip. */
@@ -3507,6 +3909,263 @@ players: z.array(RecentPlayer) }),
3507
3909
  })
3508
3910
  };
3509
3911
  //#endregion
3912
+ //#region src/api/tournaments/endpoints.ts
3913
+ /**
3914
+ * The tournament endpoints (batch-2026-10 R15). Anyone may look at open
3915
+ * tournaments; a closed one answers 404 to everyone it wasn't shared with.
3916
+ * Creating, inviting and following need a claimed account; guests may sign
3917
+ * up for open tournaments (and for closed ones they were invited to) and
3918
+ * play their matches.
3919
+ */
3920
+ /** `:id` of a tournament. */
3921
+ const TournamentParams = z.object({ id: IdParam });
3922
+ /** `:id` and `:match` of a tournament match. */
3923
+ const MatchParams = z.object({
3924
+ id: IdParam,
3925
+ match: z.string().regex(/^m\d{1,4}$/)
3926
+ });
3927
+ /** The invite key from a shared link (`?hub_key=`), letting its opener into a closed tournament. */
3928
+ const InviteKey = z.string().regex(/^[A-Za-z0-9_-]{8,64}$/);
3929
+ /** `GET /tournaments` filters. */
3930
+ const TournamentListQuery = z.object({
3931
+ /** Only tournaments in this game. */
3932
+ game: z.union([z.literal(""), Slug]).optional() });
3933
+ /** The tournament endpoints. */
3934
+ const tournamentEndpoints = {
3935
+ ...session.group({
3936
+ listTournaments: {
3937
+ method: "GET",
3938
+ path: "/tournaments",
3939
+ query: TournamentListQuery,
3940
+ response: z.object({ tournaments: z.array(TournamentSummary) }),
3941
+ doc: {
3942
+ summary: "Open tournaments plus the ones I am in, invited to or follow, soonest first",
3943
+ tags: ["tournaments"]
3944
+ }
3945
+ },
3946
+ getTournament: {
3947
+ method: "GET",
3948
+ path: "/tournaments/:id",
3949
+ params: TournamentParams,
3950
+ query: z.object({ key: InviteKey.optional() }),
3951
+ response: TournamentDetail,
3952
+ errors: { 404: ErrorBody },
3953
+ doc: {
3954
+ summary: "A tournament with its players and bracket (404 for a closed one not shared with me)",
3955
+ tags: ["tournaments"]
3956
+ }
3957
+ }
3958
+ }),
3959
+ ...requireClaimed.group({
3960
+ createTournament: {
3961
+ method: "POST",
3962
+ path: "/tournaments",
3963
+ body: TournamentCreate,
3964
+ response: TournamentDetail,
3965
+ errors: {
3966
+ 400: ErrorBody,
3967
+ 401: ErrorBody,
3968
+ 403: ErrorBody,
3969
+ 404: ErrorBody,
3970
+ 422: ErrorBody,
3971
+ 429: ErrorBody
3972
+ },
3973
+ doc: {
3974
+ summary: "Create a tournament (I am signed up to it); the title goes through the content filter",
3975
+ tags: ["tournaments"]
3976
+ }
3977
+ },
3978
+ inviteToTournament: {
3979
+ method: "POST",
3980
+ path: "/tournaments/:id/invites",
3981
+ params: TournamentParams,
3982
+ body: z.object({ userIds: z.array(UserId).min(1).max(TOURNAMENT_LIMITS.invitesMax) }),
3983
+ response: z.object({ invited: z.number().int().nonnegative() }),
3984
+ errors: {
3985
+ 401: ErrorBody,
3986
+ 403: ErrorBody,
3987
+ 404: ErrorBody,
3988
+ 409: ErrorBody,
3989
+ 429: ErrorBody
3990
+ },
3991
+ doc: {
3992
+ summary: "Creator: invite friends (others are skipped without saying why)",
3993
+ tags: ["tournaments"]
3994
+ }
3995
+ },
3996
+ followTournament: {
3997
+ method: "PUT",
3998
+ path: "/tournaments/:id/follow",
3999
+ params: TournamentParams,
4000
+ body: z.object({ leadMinutes: z.number().int().min(0).max(TOURNAMENT_LIMITS.leadMinutesMax).nullish() }),
4001
+ response: TournamentSummary,
4002
+ errors: {
4003
+ 401: ErrorBody,
4004
+ 403: ErrorBody,
4005
+ 404: ErrorBody
4006
+ },
4007
+ doc: {
4008
+ summary: "Follow a tournament (hear when it opens, starts and ends); leadMinutes: remind me that long before a scheduled start (absent: no reminder)",
4009
+ tags: ["tournaments"]
4010
+ }
4011
+ },
4012
+ unfollowTournament: {
4013
+ method: "DELETE",
4014
+ path: "/tournaments/:id/follow",
4015
+ params: TournamentParams,
4016
+ response: Ok,
4017
+ errors: {
4018
+ 401: ErrorBody,
4019
+ 403: ErrorBody,
4020
+ 404: ErrorBody
4021
+ },
4022
+ doc: {
4023
+ summary: "Stop following a tournament",
4024
+ tags: ["tournaments"]
4025
+ }
4026
+ },
4027
+ startTournament: {
4028
+ method: "POST",
4029
+ path: "/tournaments/:id/start",
4030
+ params: TournamentParams,
4031
+ response: TournamentDetail,
4032
+ errors: {
4033
+ 401: ErrorBody,
4034
+ 403: ErrorBody,
4035
+ 404: ErrorBody,
4036
+ 409: ErrorBody
4037
+ },
4038
+ doc: {
4039
+ summary: "Creator: start now with whoever signed up (at least two)",
4040
+ tags: ["tournaments"]
4041
+ }
4042
+ },
4043
+ cancelTournament: {
4044
+ method: "DELETE",
4045
+ path: "/tournaments/:id",
4046
+ params: TournamentParams,
4047
+ response: Ok,
4048
+ errors: {
4049
+ 401: ErrorBody,
4050
+ 403: ErrorBody,
4051
+ 404: ErrorBody,
4052
+ 409: ErrorBody
4053
+ },
4054
+ doc: {
4055
+ summary: "Creator (or an admin): cancel a tournament that has not finished",
4056
+ tags: ["tournaments"]
4057
+ }
4058
+ }
4059
+ }),
4060
+ ...requireActive.group({
4061
+ joinTournament: {
4062
+ method: "POST",
4063
+ path: "/tournaments/:id/join",
4064
+ params: TournamentParams,
4065
+ body: z.object({ key: InviteKey.optional() }),
4066
+ response: TournamentDetail,
4067
+ errors: {
4068
+ 401: ErrorBody,
4069
+ 403: ErrorBody,
4070
+ 404: ErrorBody,
4071
+ 409: ErrorBody,
4072
+ 429: ErrorBody
4073
+ },
4074
+ doc: {
4075
+ summary: "Sign up (open tournaments, or closed ones I was invited to or have the link for)",
4076
+ tags: ["tournaments"]
4077
+ }
4078
+ },
4079
+ leaveTournament: {
4080
+ method: "POST",
4081
+ path: "/tournaments/:id/leave",
4082
+ params: TournamentParams,
4083
+ response: TournamentDetail,
4084
+ errors: {
4085
+ 401: ErrorBody,
4086
+ 403: ErrorBody,
4087
+ 404: ErrorBody,
4088
+ 409: ErrorBody
4089
+ },
4090
+ doc: {
4091
+ summary: "Drop out: before the start my place is freed; after it I forfeit what is left",
4092
+ tags: ["tournaments"]
4093
+ }
4094
+ },
4095
+ launchTournamentMatch: {
4096
+ method: "POST",
4097
+ path: "/tournaments/:id/matches/:match/launch",
4098
+ params: MatchParams,
4099
+ response: LaunchTicket,
4100
+ errors: {
4101
+ 401: ErrorBody,
4102
+ 403: ErrorBody,
4103
+ 404: ErrorBody,
4104
+ 409: ErrorBody
4105
+ },
4106
+ doc: {
4107
+ summary: "A launch into my ready match: host for the better seed, guest for the other",
4108
+ tags: ["tournaments"]
4109
+ }
4110
+ }
4111
+ })
4112
+ };
4113
+ //#endregion
4114
+ //#region src/api/tournaments/play-online.ts
4115
+ /**
4116
+ * "Play online" (batch-2026-10 R15): everything the viewer could play with
4117
+ * someone, now and later, for the home page card and its modal. Public
4118
+ * lobbies and races waiting for a player (joined through
4119
+ * `POST /mp/join { userId, fromLobbyOpen: true }`), invites to the viewer,
4120
+ * the tournaments they are in or follow (with a ready match first), and open
4121
+ * tournaments still taking sign-ups.
4122
+ */
4123
+ /** A public lobby or race waiting for players right now. */
4124
+ const OpenLobbyEntry = z.object({
4125
+ /** Who is waiting (join them with `POST /mp/join`). */
4126
+ host: PlayerLite,
4127
+ game: Slug,
4128
+ gameTitle: z.string(),
4129
+ /** `race` for a race waiting for racers, otherwise the lobby's mode when it has one. */
4130
+ mode: GameMode.optional(),
4131
+ openSeats: z.number().int().positive(),
4132
+ /** When they started waiting, epoch ms. */
4133
+ since: Millis
4134
+ });
4135
+ /** The counts on the home card. */
4136
+ const PlayOnlineCounts = z.object({
4137
+ /** Lobbies to join now, plus my matches that are ready. */
4138
+ now: z.number().int().nonnegative(),
4139
+ /** Game invites and tournament invites waiting for me. */
4140
+ invites: z.number().int().nonnegative(),
4141
+ /** Tournaments I signed up for or follow, not over. */
4142
+ mine: z.number().int().nonnegative(),
4143
+ /** Open tournaments still taking sign-ups. */
4144
+ upcoming: z.number().int().nonnegative()
4145
+ });
4146
+ /** Everything playable online for the viewer. */
4147
+ const PlayOnline = z.object({
4148
+ counts: PlayOnlineCounts,
4149
+ lobbies: z.array(OpenLobbyEntry),
4150
+ gameInvites: z.array(Invite),
4151
+ /** Closed tournaments I'm invited to and haven't joined. */
4152
+ tournamentInvites: z.array(TournamentSummary),
4153
+ /** Mine: a ready match first, then the soonest. */
4154
+ mine: z.array(TournamentSummary),
4155
+ /** Open, taking sign-ups, not mine. */
4156
+ upcoming: z.array(TournamentSummary)
4157
+ });
4158
+ /** The play-online endpoint. Signed-out visitors get the open tournaments only. */
4159
+ const playOnlineEndpoints = { ...session.group({ getPlayOnline: {
4160
+ method: "GET",
4161
+ path: "/play-online",
4162
+ response: PlayOnline,
4163
+ doc: {
4164
+ summary: "Lobbies to join now, invites, my tournaments and upcoming open ones (the home page card and modal)",
4165
+ tags: ["tournaments"]
4166
+ }
4167
+ } }) };
4168
+ //#endregion
3510
4169
  //#region src/api/version.ts
3511
4170
  /**
3512
4171
  * `GET /version`: which client versions this hub serves (plan §4.3.3). Games'
@@ -3541,6 +4200,41 @@ const versionEndpoints = { ...session.group({ getHubVersion: {
3541
4200
  }
3542
4201
  } }) };
3543
4202
  //#endregion
4203
+ //#region src/api/offline.ts
4204
+ /**
4205
+ * Offline replay metadata: which public endpoints a client may queue while
4206
+ * offline and send later, and how the hub makes a replay land exactly once.
4207
+ *
4208
+ * A replayable call carries a client-made operation key in the
4209
+ * {@link OFFLINE_OP_HEADER} header. The hub remembers the reply to each key
4210
+ * for {@link OFFLINE_DEDUPE_TTL_MS}; a second request with the same key gets
4211
+ * that reply again without running the endpoint twice. Endpoints left out
4212
+ * are online-only (social, multiplayer, trades) or replay on their own terms
4213
+ * (analytics events are buffered and sent at least once, without a key).
4214
+ */
4215
+ /** The request header that carries a replayable call's operation key. */
4216
+ const OFFLINE_OP_HEADER = "x-hub-op";
4217
+ /** An operation key: letters, digits and `_ . : -`, 1 to 100 characters. */
4218
+ const OfflineOpKey = z.string().regex(/^[A-Za-z0-9_.:-]{1,100}$/);
4219
+ /** How long the hub remembers an operation key's reply: 7 days. */
4220
+ const OFFLINE_DEDUPE_TTL_MS = 6048e5;
4221
+ /** The public endpoints that support offline replay, by name. */
4222
+ const OFFLINE_REPLAY_ENDPOINTS = {
4223
+ writeSave: "replay",
4224
+ deleteSave: "replay",
4225
+ updateGameStat: "replay",
4226
+ setGameProfile: "replay",
4227
+ submitScore: "replay",
4228
+ completeDailyChallenge: "replay",
4229
+ grantInventoryItems: "replay",
4230
+ exchangeInventoryItems: "replay",
4231
+ setGameFavorite: "replay"
4232
+ };
4233
+ /** Whether endpoint `name` supports offline replay. */
4234
+ function isOfflineReplayEndpoint(name) {
4235
+ return Object.hasOwn(OFFLINE_REPLAY_ENDPOINTS, name);
4236
+ }
4237
+ //#endregion
3544
4238
  //#region src/api/index.ts
3545
4239
  /**
3546
4240
  * The public REST API: every endpoint a game, the menu or the social runtime
@@ -3561,6 +4255,8 @@ const publicEndpoints = {
3561
4255
  ...reportsEndpoints,
3562
4256
  ...mpEndpoints,
3563
4257
  ...raceEndpoints,
4258
+ ...tournamentEndpoints,
4259
+ ...playOnlineEndpoints,
3564
4260
  ...versionEndpoints
3565
4261
  };
3566
4262
  /** The public API spec (paths relative to `/_api`). */
@@ -3662,7 +4358,7 @@ const roomEvents = {
3662
4358
  }),
3663
4359
  "room.closed": event({
3664
4360
  d: z.object({ reason: RoomClosedReason }),
3665
- doc: "The room is gone for this tab (emptied, ended, host left, moved, another tab took the seat, removed)."
4361
+ doc: "The room is gone for this tab (emptied, ended, moved, another tab took the seat, removed). `host_left` is no longer sent: the host role passes on."
3666
4362
  }),
3667
4363
  "room.abandoned": event({
3668
4364
  d: z.object({
@@ -3670,15 +4366,28 @@ const roomEvents = {
3670
4366
  reason: SeatGoneReason
3671
4367
  }),
3672
4368
  doc: "A player left mid-game (or their reconnect grace ran out); the game decides what that means."
4369
+ }),
4370
+ "room.seat": event({
4371
+ d: SeatChange,
4372
+ doc: "A seat went away (held for the grace period), came back (reclaimed) or is gone. The game hands an away seat to its computer player and gives it back on `back`."
3673
4373
  })
3674
4374
  };
3675
4375
  //#endregion
4376
+ //#region src/realtime/events/tournaments.ts
4377
+ /** Tournament events (batch-2026-10 R15). */
4378
+ /** The tournament events, by event name. */
4379
+ const tournamentEvents = { tournament: event({
4380
+ d: z.object({ tournament: TournamentSummary }),
4381
+ doc: "A tournament you are in, follow, created or were invited to changed (sign-ups, the start, a match ready or decided, the end)."
4382
+ }) };
4383
+ //#endregion
3676
4384
  //#region src/realtime/events/multiplayer.ts
3677
4385
  /** The multiplayer events, by event name. */
3678
4386
  const multiplayerEvents = {
3679
4387
  ...partyEvents,
3680
4388
  ...roomEvents,
3681
- ...raceEvents
4389
+ ...raceEvents,
4390
+ ...tournamentEvents
3682
4391
  };
3683
4392
  //#endregion
3684
4393
  //#region src/realtime/events/social.ts
@@ -3687,7 +4396,7 @@ const multiplayerEvents = {
3687
4396
  * and suspension (multiplayer-impl.md §4.6). Every event goes to all of the
3688
4397
  * recipient's tabs.
3689
4398
  */
3690
- /** What kind of thing a notice is about. `renamed`: an admin changed your username. */
4399
+ /** What kind of thing a notice is about. `renamed`: an admin changed your username; `tournament`: a match is ready, a start is near, a forfeit warning. */
3691
4400
  const NoticeKind = z.enum([
3692
4401
  "dm",
3693
4402
  "friend_request",
@@ -3695,7 +4404,8 @@ const NoticeKind = z.enum([
3695
4404
  "lobby_open",
3696
4405
  "invite",
3697
4406
  "suggestion",
3698
- "renamed"
4407
+ "renamed",
4408
+ "tournament"
3699
4409
  ]);
3700
4410
  /** A toast (`ev:notice`), sent instead of a push when the player has a visible tab. */
3701
4411
  const Notice = z.object({
@@ -3944,6 +4654,13 @@ params: RaceParams.optional() }),
3944
4654
  */
3945
4655
  /** A per-room sequence number ack (`room.send`, `room.state`). */
3946
4656
  const SeqAck = z.object({ seq: z.number().int().positive() });
4657
+ /** A join's ack: the room, its state and the history this member may replay. */
4658
+ const RoomJoinAck = z.object({
4659
+ room: RoomInfo,
4660
+ state: RoomState,
4661
+ /** Recent messages this member may see, oldest first. */
4662
+ history: z.array(RoomMessage)
4663
+ });
3947
4664
  /** The room ops, by op name. */
3948
4665
  const roomOps = {
3949
4666
  "room.create": op({
@@ -3956,6 +4673,8 @@ const roomOps = {
3956
4673
  /** Seats; clamped to the game's max (and 8). Defaults to the max. */
3957
4674
  maxPlayers: z.number().optional(),
3958
4675
  model: RoomModel.optional(),
4676
+ /** When the host role moves on mid-game (default `grace`). */
4677
+ hostMigration: HostMigration.optional(),
3959
4678
  /** The caller's party: when they lead it, its join info becomes `{ room: code }`. */
3960
4679
  partyId: OpaqueId.optional()
3961
4680
  }),
@@ -3973,12 +4692,7 @@ const roomOps = {
3973
4692
  }),
3974
4693
  "room.join": op({
3975
4694
  d: RoomTarget,
3976
- ack: z.object({
3977
- room: RoomInfo,
3978
- state: RoomState,
3979
- /** Recent messages this member may see, oldest first. */
3980
- history: z.array(RoomMessage)
3981
- }),
4695
+ ack: RoomJoinAck,
3982
4696
  errors: [
3983
4697
  "room_not_found",
3984
4698
  "banned",
@@ -3989,6 +4703,14 @@ const roomOps = {
3989
4703
  ],
3990
4704
  doc: "Take a seat (lobby only), reclaim your own seat (any phase, any tab), or watch."
3991
4705
  }),
4706
+ "room.reclaim": op({
4707
+ d: z.object({ game: Slug }),
4708
+ ack: z.object({
4709
+ /** The seat I still hold in this game, now reclaimed by this tab; `null` when there is none. */
4710
+ held: RoomJoinAck.nullable() }),
4711
+ errors: [],
4712
+ doc: "Take back the seat I still hold in this game (after a reload or from another tab); the room hears `room.seat` `back`."
4713
+ }),
3992
4714
  "room.leave": op({
3993
4715
  d: NoInput,
3994
4716
  ack: EmptyAck,
@@ -4315,6 +5037,114 @@ const GameControls = ControlMap.extend({
4315
5037
  /** Per-player maps, by gamepad order; pads past the end use the first. Overrides the shared map. */
4316
5038
  players: z.array(ControlMap).min(1).max(SEAT_LIMITS.maxSeats).optional() });
4317
5039
  //#endregion
5040
+ //#region src/manifest/labels.ts
5041
+ /**
5042
+ * `game.labels`: what kind of game it is, from one fixed vocabulary, so the
5043
+ * catalog can filter by them. Labels are grouped (genre, look, where it
5044
+ * plays, how many play); a game lists every label that fits.
5045
+ *
5046
+ * Authoring is lenient: an unknown label is a warning (`hub-doctor`, the
5047
+ * host's boot log), never an error, and the catalog ignores it.
5048
+ */
5049
+ /** The vocabulary, by group. */
5050
+ const GAME_LABEL_GROUPS = {
5051
+ /** What kind of game. */
5052
+ genre: [
5053
+ "cards",
5054
+ "board",
5055
+ "puzzle",
5056
+ "arcade",
5057
+ "platformer",
5058
+ "real-time-strategy",
5059
+ "physics",
5060
+ "typing",
5061
+ "word",
5062
+ "sports",
5063
+ "racing"
5064
+ ],
5065
+ /** How it looks. */
5066
+ form: ["2d", "3d"],
5067
+ /** Where it plays: `phone` (touch controls), `controller` (gamepad support), `desktop` (keyboard or mouse only). */
5068
+ platform: [
5069
+ "phone",
5070
+ "controller",
5071
+ "desktop"
5072
+ ],
5073
+ /** How many play. */
5074
+ play: ["multiplayer", "single-player"]
5075
+ };
5076
+ /** The groups in display order. */
5077
+ const GAME_LABEL_GROUP_ORDER = [
5078
+ "genre",
5079
+ "form",
5080
+ "platform",
5081
+ "play"
5082
+ ];
5083
+ /** Every label, in display order (group by group). */
5084
+ const GAME_LABELS = [
5085
+ ...GAME_LABEL_GROUPS.genre,
5086
+ ...GAME_LABEL_GROUPS.form,
5087
+ ...GAME_LABEL_GROUPS.platform,
5088
+ ...GAME_LABEL_GROUPS.play
5089
+ ];
5090
+ /** One known label. */
5091
+ const GameLabel = z.enum(GAME_LABELS);
5092
+ /** Each label's display name. */
5093
+ const GAME_LABEL_TITLES = {
5094
+ cards: "Cards",
5095
+ board: "Board",
5096
+ puzzle: "Puzzle",
5097
+ arcade: "Arcade",
5098
+ platformer: "Platformer",
5099
+ "real-time-strategy": "Real-time strategy",
5100
+ physics: "Physics",
5101
+ typing: "Typing",
5102
+ word: "Word",
5103
+ sports: "Sports",
5104
+ racing: "Racing",
5105
+ "2d": "2D",
5106
+ "3d": "3D",
5107
+ phone: "Phone",
5108
+ controller: "Controller",
5109
+ desktop: "Desktop only",
5110
+ multiplayer: "Multiplayer",
5111
+ "single-player": "Single player"
5112
+ };
5113
+ /** Each group's display name. */
5114
+ const GAME_LABEL_GROUP_TITLES = {
5115
+ genre: "Genre",
5116
+ form: "Look",
5117
+ platform: "Plays on",
5118
+ play: "Players"
5119
+ };
5120
+ /** Whether `text` is a known label. */
5121
+ function isGameLabel(text) {
5122
+ return GameLabel.safeParse(text).success;
5123
+ }
5124
+ /** The group a label belongs to. */
5125
+ function groupOfLabel(label) {
5126
+ return GAME_LABEL_GROUP_ORDER.find((group) => GAME_LABEL_GROUPS[group].some((member) => member === label)) ?? "genre";
5127
+ }
5128
+ /**
5129
+ * `game.labels` as written: short strings. Unknown ones parse (they are a
5130
+ * warning, see {@link splitGameLabels}).
5131
+ */
5132
+ const GameLabels = z.array(z.string().trim().min(1).max(40)).max(24);
5133
+ /** Known labels (trimmed, lower-cased, de-duplicated, in vocabulary order) and the unknown ones as written. */
5134
+ function splitGameLabels(written) {
5135
+ const known = /* @__PURE__ */ new Set();
5136
+ const unknown = [];
5137
+ for (const text of written) {
5138
+ const candidate = text.trim().toLowerCase();
5139
+ if (isGameLabel(candidate)) known.add(candidate);
5140
+ else unknown.push(text);
5141
+ }
5142
+ return {
5143
+ labels: GAME_LABELS.filter((label) => known.has(label)),
5144
+ unknown
5145
+ };
5146
+ }
5147
+ //#endregion
4318
5148
  //#region src/manifest/leaderboards.ts
4319
5149
  /**
4320
5150
  * `game.leaderboards`: boards declared up front, so they show on the catalog
@@ -4384,6 +5214,14 @@ const GameMultiplayer = z.strictObject({
4384
5214
  spectate: z.boolean().default(false),
4385
5215
  /** Offer quick match. Default `false`. */
4386
5216
  quickMatch: z.boolean().default(false),
5217
+ /**
5218
+ * Tournaments can be played in this game (docs/multiplayer.md §15). Default
5219
+ * `false`. The game must play a 1v1 match from a `host`/`guest` launch that
5220
+ * carries `launch.tournament`, and report its winner: hub rooms end with
5221
+ * `room.end({ winnerId })`, own-server games post results with
5222
+ * `tournament`. Race games need no flag: their races are the matches.
5223
+ */
5224
+ tournaments: z.boolean().default(false),
4387
5225
  /** Modes players can be invited into (at most 16, unique keys). */
4388
5226
  modes: z.array(MultiplayerMode).max(MANIFEST_LIMITS.modesMax).refine(hasUniqueKeys, "mode keys must be unique").default([]),
4389
5227
  /**
@@ -4455,6 +5293,12 @@ const commonFields = {
4455
5293
  menuPosition: MenuPosition.optional(),
4456
5294
  /** Push the menu button away from its corner, px: a number (vertical) or `{ x, y }`. */
4457
5295
  menuOffset: MenuOffset.optional(),
5296
+ /**
5297
+ * What kind of game it is, from the fixed vocabulary in `labels.ts` (genre, look,
5298
+ * where it plays, how many play), for the catalog's label filter. Unknown labels
5299
+ * are a warning, not an error.
5300
+ */
5301
+ labels: GameLabels.optional(),
4458
5302
  /** `true` opts into the shared daily challenges (`hub.daily.define(...)`). */
4459
5303
  dailyChallenges: z.boolean().optional(),
4460
5304
  /** Boards to create up front, so they show before the first score. */
@@ -4850,6 +5694,6 @@ function historyBadge(racerResult) {
4850
5694
  }
4851
5695
  }
4852
5696
  //#endregion
4853
- export { ACCOUNT_LIMITS, AckFrame, AppFilePath, Audience, AuthResult, AuthUser, Board, Bundle, BundleInput, ButtonIndex, CHAT_ERRORS, COMMAND_PLACEHOLDERS, ChangeEntry, ChangeNote, ChangeText, ChatAllowWord, ChatLine, ChatLineEvent, ChatMode, ChatSend, CloseCode, Command, ContentRejected, ControlMap, Conversation, Counts, Credentials, DailyCompletion, DailyCompletions, DayKey, ENVELOPE_ERRORS, ErrAckFrame, ErrorBody, ErrorCode, EvFrame, FriendChange, FriendEntry, FriendRequestInput, FriendRequestStatus, FriendRequests, FriendsList, GameChanges, GameControls, GameInfo, GameLeaderboardDecls, GameLeaderboards, GameManifest, GameMode, GameMultiplayer, GamePackageJson, GameRace, GameRecords, GameSnapshot, GameTicket, HexColor, HostManifest, HubInject, HubVersion, IdParam, Inventory, Invite, InviteCreated, InviteStatus, JoinInfo, Json, Key, KeyCode, Launch, LaunchKind, LaunchTicket, LeaderboardDecl, LeaderboardEntry, LobbySuffix, MANIFEST_LIMITS, MAX_BUNDLE_ITEMS, MAX_ITEM_QTY, MenuOffset, MenuOffsetXY, MenuPosition, Message, MessageId, MessagesPage, Metrics, Millis, ModeKey, MultiplayerMode, MultiplayerSnapshot, MultiplayerSummary, NotifyChange, NotifyPrefKind, NotifyPrefs, Ok, OkAckFrame, OpFrame, OpaqueId, PRE_HELLO_OPS, Party, PlayerCard, PlayerLite, PlayerRange, Presence, PresenceInput, PresenceKind, PresenceMode, PresenceRoom, ProcessGame, Profile, PushSubscriptionJson, QuickId, RACE_STATS_LIMITS, RaceBlock, RaceHistory, RaceHistoryQuery, RaceKey, RaceOption, RaceOutcome, RaceParam, RaceParamValue, RaceParams, RacePhase, RaceProgress, RaceRecord, RaceRecordPlayer, RaceResult, RaceResultPlayer, RaceStart, RaceStat, RaceStatFormat, RaceStatValue, RaceState, RaceStats, RaceSummary, Racer, RacerResult, RacerState, Rank, RecentPlayer, RejectReason, Relation, ReportInput, ReportKind, ReportReason, ReportSnapshot, ReportedMessage, Role, RoomClosedReason, RoomCode, RoomInfo, RoomMessage, RoomModel, RoomPhase, RoomSeat, RoomState, RoomTarget, SEAT_LIMITS, Save, SaveMeta, SeatGoneReason, SendMessageInput, ServerFrame, SessionUser, Slug, SocialConfig, SocialLimits, SocialMe, SocialMeResult, SortDir, StatMode, StaticGame, StaticGameMultiplayer, Stats, Suggestion, SuggestionId, SuggestionStatus, TopQuery, TradeOffer, TradePlayer, TradeStatus, Transport, UI_LIMIT_DEFAULTS, UNSUPPORTED_SUBSCRIPTION, USERNAME_PATTERN, UiLimits, UnreadSummary, UserId, UserIdParam, VersionRange, VoteResult, analyticsEndpoints, authEndpoints, badgeText, bundleInput, catalogEndpoints, countdown, dailyEndpoints, dayKey, economyEndpoints, elapsed, event, events, formatStat, game, hasOwnTime, headerLine, historyBadge, historyVerb, inWindow, leaderboardsEndpoints, messagesEndpoints, mpEndpoints, nameList, normalizeChangeText, notifyEndpoints, op, ops, outcomeLine, paramsText, progressEndpoints, publicApi, publicEndpoints, raceClock, raceEndpoints, racerStateTag, relativeTime, reportsEndpoints, requireActive, requireClaimed, requireTrader, requireUser, resultLabel, resultTitle, seedForDay, session, shownStats, socialEndpoints, statusParts, statusText, untilText, versionEndpoints, windowDays };
5697
+ export { ACCOUNT_LIMITS, AckFrame, AppFilePath, Audience, AuthResult, AuthUser, Board, Bundle, BundleInput, ButtonIndex, CHAT_ERRORS, COMMAND_PLACEHOLDERS, ChangeEntry, ChangeNote, ChangeText, ChatAllowWord, ChatLine, ChatLineEvent, ChatMode, ChatSend, CloseCode, Command, ContentRejected, ControlMap, Conversation, Counts, Credentials, DailyCompletion, DailyCompletions, DayKey, ENVELOPE_ERRORS, ErrAckFrame, ErrorBody, ErrorCode, EvFrame, FriendChange, FriendEntry, FriendRequestInput, FriendRequestStatus, FriendRequests, FriendsList, GAME_LABELS, GAME_LABEL_GROUPS, GAME_LABEL_GROUP_ORDER, GAME_LABEL_GROUP_TITLES, GAME_LABEL_TITLES, GameChanges, GameControls, GameInfo, GameLabel, GameLabels, GameLeaderboardDecls, GameLeaderboards, GameManifest, GameMode, GameMultiplayer, GamePackageJson, GamePlayerCount, GamePlayerCounts, GameRace, GameRecords, GameSnapshot, GameTicket, HexColor, HostManifest, HostMigration, HubInject, HubVersion, IdParam, Inventory, Invite, InviteCreated, InviteLink, InviteLinkCreate, InviteLinkParams, InviteLinkQuery, InviteLinkToken, InviteStatus, JoinInfo, Json, Key, KeyCode, Launch, LaunchKind, LaunchTicket, LeaderboardDecl, LeaderboardEntry, LobbySuffix, MANIFEST_LIMITS, MAX_BUNDLE_ITEMS, MAX_ITEM_QTY, MenuOffset, MenuOffsetXY, MenuPosition, Message, MessageId, MessagesPage, Metrics, Millis, ModeKey, MultiplayerMode, MultiplayerSnapshot, MultiplayerSummary, MyGameActivity, MyTournamentMatch, NotifyChange, NotifyPrefKind, NotifyPrefs, OFFLINE_DEDUPE_TTL_MS, OFFLINE_OP_HEADER, OFFLINE_REPLAY_ENDPOINTS, OfflineOpKey, Ok, OkAckFrame, OpFrame, OpaqueId, OpenLobbyEntry, PRE_HELLO_OPS, Party, PlayOnline, PlayOnlineCounts, PlayerCard, PlayerLite, PlayerRange, Presence, PresenceInput, PresenceKind, PresenceMode, PresenceRoom, ProcessGame, Profile, PushSubscriptionJson, QuickId, RACE_STATS_LIMITS, RaceBlock, RaceHistory, RaceHistoryQuery, RaceKey, RaceOption, RaceOutcome, RaceParam, RaceParamValue, RaceParams, RacePhase, RaceProgress, RaceRecord, RaceRecordPlayer, RaceResult, RaceResultPlayer, RaceStart, RaceStat, RaceStatFormat, RaceStatValue, RaceState, RaceStats, RaceSummary, Racer, RacerResult, RacerState, Rank, RecentPlayer, RejectReason, Relation, ReportInput, ReportKind, ReportReason, ReportSnapshot, ReportedMessage, Role, RoomClosedReason, RoomCode, RoomInfo, RoomMessage, RoomModel, RoomPhase, RoomSeat, RoomState, RoomTarget, SEAT_LIMITS, Save, SaveMeta, SeatChange, SeatGoneReason, SeatStatus, SendMessageInput, ServerFrame, SessionUser, Slug, SocialConfig, SocialLimits, SocialMe, SocialMeResult, SortDir, StatMode, StaticGame, StaticGameMultiplayer, Stats, Suggestion, SuggestionId, SuggestionStatus, TOURNAMENT_LIMITS, TopQuery, TournamentCreate, TournamentDecision, TournamentDetail, TournamentGamePick, TournamentId, TournamentLaunch, TournamentListQuery, TournamentMatch, TournamentMe, TournamentPhase, TournamentPlayer, TournamentSeeding, TournamentStage, TournamentStart, TournamentSummary, TournamentVisibility, TradeOffer, TradePlayer, TradeStatus, Transport, UI_LIMIT_DEFAULTS, UNSUPPORTED_SUBSCRIPTION, USERNAME_PATTERN, UiLimits, UnreadSummary, UserId, UserIdParam, VersionRange, VoteResult, analyticsEndpoints, authEndpoints, badgeText, bundleInput, catalogActivityEndpoints, catalogEndpoints, countdown, dailyEndpoints, dayKey, economyEndpoints, elapsed, event, events, formatStat, game, groupOfLabel, hasOwnTime, headerLine, historyBadge, historyVerb, inWindow, isGameLabel, isOfflineReplayEndpoint, leaderboardsEndpoints, messagesEndpoints, mpEndpoints, nameList, normalizeChangeText, notifyEndpoints, op, ops, outcomeLine, paramsText, playOnlineEndpoints, progressEndpoints, publicApi, publicEndpoints, raceClock, raceEndpoints, racerStateTag, relativeTime, reportsEndpoints, requireActive, requireClaimed, requireTrader, requireUser, resultLabel, resultTitle, seedForDay, session, shownStats, socialEndpoints, splitGameLabels, statusParts, statusText, tournamentEndpoints, untilText, versionEndpoints, windowDays };
4854
5698
 
4855
5699
  //# sourceMappingURL=index.js.map