195 lines
7.2 KiB
TypeScript
195 lines
7.2 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<number, { winner: LLWSDestination | null; loser: LLWSDestination | null }>
|
||
|
|
> = {
|
||
|
|
"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) };
|
||
|
|
}
|