/** * LLWS 20-team double-elimination routing — the pure half of the bracket. * * Lives in lib/ rather than models/ because the renderer needs it: models/playoff-match * pulls in the database context and drizzle, which must not reach the browser bundle. * models/playoff-match re-exports everything here, so server-side callers are unchanged. */ import { llwsMatchNumber, llwsSideAndLocal } from "~/lib/bracket-templates"; /** * Where one participant goes after an LLWS match: a round, a side-local match number, * and which slot to fill. `null` means eliminated (or, for winners, no further game). */ interface LLWSDestination { round: string; localMatch: number; slot: "participant1Id" | "participant2Id"; } /** * LLWS advancement map, in SIDE-LOCAL match numbers. * * Keyed by round, then by the local match number of the completed game. Each entry * says where the winner goes and where the loser goes (null = eliminated). * * Verified game-by-game against the official 2026 LLBWS bracket. Note the deliberate * cross-overs — the elimination bracket does NOT feed straight across: * Elim R1: L(Opening m2) v L(Opening m3) and L(Opening m1) v L(Opening m4) * Elim R3: L(Semi m1) v W(Elim R2 m2) and L(Semi m2) v W(Elim R2 m1) * Elim R4: W(Elim R3 m1) v W(Elim R3 m2) * * A loss in the winners bracket routes into the elimination bracket rather than * eliminating the team; a loss in the elimination bracket is final. */ const LLWS_ADVANCEMENT: Record< string, Record > = { "Opening Round": { 1: { winner: { round: "Winners Round 2", localMatch: 1, slot: "participant2Id" }, loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant1Id" }, }, 2: { winner: { round: "Winners Round 2", localMatch: 2, slot: "participant2Id" }, loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant1Id" }, }, 3: { winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant1Id" }, loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant2Id" }, }, 4: { winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant2Id" }, loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant2Id" }, }, }, "Winners Round 2": { 1: { winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant2Id" }, loser: { round: "Elimination Round 2", localMatch: 1, slot: "participant1Id" }, }, 2: { winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant1Id" }, loser: { round: "Elimination Round 2", localMatch: 2, slot: "participant1Id" }, }, }, "Winners Semifinals": { 1: { winner: { round: "Winners Final", localMatch: 1, slot: "participant1Id" }, loser: { round: "Elimination Round 3", localMatch: 1, slot: "participant1Id" }, }, 2: { winner: { round: "Winners Final", localMatch: 1, slot: "participant2Id" }, loser: { round: "Elimination Round 3", localMatch: 2, slot: "participant1Id" }, }, }, "Winners Final": { 1: { winner: { round: "Bracket Championship", localMatch: 1, slot: "participant1Id" }, // A winners-bracket final loss is not an elimination — it drops to the // Elimination Final for a second chance at the side championship. loser: { round: "Elimination Final", localMatch: 1, slot: "participant1Id" }, }, }, "Elimination Round 1": { 1: { winner: { round: "Elimination Round 2", localMatch: 1, slot: "participant2Id" }, loser: null, }, 2: { winner: { round: "Elimination Round 2", localMatch: 2, slot: "participant2Id" }, loser: null, }, }, "Elimination Round 2": { // Cross-over: R2 m1's winner meets the OTHER semifinal loser. 1: { winner: { round: "Elimination Round 3", localMatch: 2, slot: "participant2Id" }, loser: null, }, 2: { winner: { round: "Elimination Round 3", localMatch: 1, slot: "participant2Id" }, loser: null, }, }, "Elimination Round 3": { // The later game (m2) is printed on top: G32 = W28 v W26, G31 = W27 v W25. 1: { winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant2Id" }, loser: null, }, 2: { winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant1Id" }, loser: null, }, }, "Elimination Round 4": { 1: { winner: { round: "Elimination Final", localMatch: 1, slot: "participant2Id" }, loser: null, }, }, "Elimination Final": { 1: { winner: { round: "Bracket Championship", localMatch: 1, slot: "participant2Id" }, loser: null, }, }, }; /** Rounds whose losers drop into the elimination bracket instead of going out. */ export const LLWS_LOSER_ADVANCES_ROUNDS = new Set([ "Opening Round", "Winners Round 2", "Winners Semifinals", ]); /** A resolved LLWS destination, in global (not side-local) match numbers. */ export interface LLWSResolvedDestination { round: string; matchNumber: number; slot: "participant1Id" | "participant2Id"; } /** * Resolve where the winner and loser of a completed LLWS match go, in global match * numbers. `null` means that participant has no further game (eliminated, or the * tournament is over for them). * * Pure — no DB access — so the whole 38-game routing can be verified against the * official bracket in tests. advanceLLWSWinner is a thin writer on top of this. */ export function resolveLLWSAdvancement( round: string, matchNumber: number ): { winner: LLWSResolvedDestination | null; loser: LLWSResolvedDestination | null } { // Terminal rounds — nobody advances. if (round === "Consolation Third Place" || round === "World Championship") { return { winner: null, loser: null }; } // Bracket Championship is the crossover: the winner goes to the World Championship // and the loser to the Consolation game. The side fixes the slot in both (U.S. takes // participant1, International participant2), so the two sides can't collide. if (round === "Bracket Championship") { const { side } = llwsSideAndLocal("Bracket Championship", matchNumber); const slot: "participant1Id" | "participant2Id" = side === 0 ? "participant1Id" : "participant2Id"; return { winner: { round: "World Championship", matchNumber: 1, slot }, loser: { round: "Consolation Third Place", matchNumber: 1, slot }, }; } const roundMap = LLWS_ADVANCEMENT[round]; if (!roundMap) { throw new Error(`Round '${round}' is not part of the LLWS bracket`); } const { side, localMatch } = llwsSideAndLocal(round, matchNumber); const routes = roundMap[localMatch]; if (!routes) { throw new Error(`No LLWS advancement defined for ${round} match ${matchNumber}`); } // Winner and loser stay on their own side, so the same side offset applies to both. const toGlobal = (d: LLWSDestination | null): LLWSResolvedDestination | null => d === null ? null : { round: d.round, matchNumber: llwsMatchNumber(d.round, side, d.localMatch), slot: d.slot }; return { winner: toGlobal(routes.winner), loser: toGlobal(routes.loser) }; }