diff --git a/app/components/scoring/PlayoffBracket.tsx b/app/components/scoring/PlayoffBracket.tsx index a3def30..17cfc40 100644 --- a/app/components/scoring/PlayoffBracket.tsx +++ b/app/components/scoring/PlayoffBracket.tsx @@ -196,16 +196,50 @@ export interface ConsolationRound { /** * Find the template's consolation round, if it has one. + * + * A consolation round must be TERMINAL — its winner plays no further game, which is + * what lets its result split two exact positions. `loserFeedsInto` alone is not + * enough: a double-elimination bracket (llws_20) uses it on every winners-bracket + * round to route losers into the elimination bracket, and those targets are ordinary + * rounds that feed onward. Picking the first `loserFeedsInto` there would mistake + * "Elimination Round 1" for a third-place game and corrupt the final rankings. + * * Exported for unit testing. */ export function findConsolationRound( template: BracketTemplate | undefined ): ConsolationRound | undefined { - const feeder = template?.rounds.find((r) => r.loserFeedsInto); + const isTerminal = (roundName: string) => + template?.rounds.find((r) => r.name === roundName)?.feedsInto === null; + + const feeder = template?.rounds.find( + (r) => r.loserFeedsInto && isTerminal(r.loserFeedsInto) + ); if (!feeder?.loserFeedsInto) return undefined; return { round: feeder.loserFeedsInto, feederRound: feeder.name }; } +/** + * Round names whose losers are placed by some LATER round rather than finishing where + * they lost — i.e. double-elimination winners-bracket rounds, whose losers drop into + * the elimination bracket. + * + * The consolation feeder is deliberately excluded: its losers do finish at that tier + * (the consolation game splits their two positions), so it still consumes them. + * + * Exported for unit testing. + */ +export function roundsWithLosersPlacedLater( + template: BracketTemplate | undefined, + consolation: ConsolationRound | undefined +): Set { + return new Set( + (template?.rounds ?? []) + .filter((r) => r.loserFeedsInto && r.name !== consolation?.feederRound) + .map((r) => r.name) + ); +} + /** * Build the ordered final-rankings list from completed matches. * @@ -228,7 +262,9 @@ export function computeRankedEntries( rounds: string[], matchesByRound: Map, consolation: ConsolationRound | undefined, - ownershipMap: Map + ownershipMap: Map, + /** See roundsWithLosersPlacedLater. Empty for single-elimination brackets. */ + losersPlacedLater: Set = new Set() ): EliminatedEntry[] { const eliminatedByRound = computeEliminatedByRound(matches, rounds); @@ -300,6 +336,14 @@ export function computeRankedEntries( // consumes none of its own. if (consolationActive && roundName === consolation?.round) continue; + // A round normally consumes one position per match — its losers finish here, + // whether or not the games have been played yet (four semifinalists occupy 1–4 + // regardless). But in a double-elimination bracket a winners-bracket loss places + // nobody: the loser drops into the elimination bracket and is ranked by whatever + // knocks them out later. Those rounds must consume nothing, or every position + // below inflates (a 20-team llws_20 bracket would end at "T23"). + if (losersPlacedLater.has(roundName)) continue; + nextRank += matchesByRound.get(roundName)?.length ?? 0; } @@ -371,7 +415,8 @@ export function PlayoffBracket({ rounds, matchesByRound, consolation, - ownershipMap + ownershipMap, + roundsWithLosersPlacedLater(template, consolation) ); const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id)); diff --git a/app/components/scoring/TabbedBracketLayout.tsx b/app/components/scoring/TabbedBracketLayout.tsx index 95b377b..5697465 100644 --- a/app/components/scoring/TabbedBracketLayout.tsx +++ b/app/components/scoring/TabbedBracketLayout.tsx @@ -152,7 +152,22 @@ export function TabbedBracketLayout({ const simpleRounds = phase.groups ? [] : (phase.rounds ?? []).filter((r) => rounds.includes(r)); const phaseRounds = phase.groups ? [...groupRounds, ...sharedRounds] : simpleRounds; - const phaseMatchesByRound = new Map(phaseRounds.map((r) => [r, matchesByRound.get(r) ?? []])); + // Restrict each round to the match numbers this phase's groups actually claim. + // Rounds can be shared across phases (LLWS runs U.S. and International through + // the same rounds), so without this the mobile view would merge both sides into + // one column. No-op where a phase's groups already cover every match in the + // round (NCAA regions, NBA conferences) and for sharedRounds, which have no + // group filter. + const phaseMatchesByRound = new Map( + phaseRounds.map((r) => { + const all = matchesByRound.get(r) ?? []; + if (!phase.groups || sharedRounds.includes(r)) return [r, all] as const; + const allowed = new Set( + phase.groups.flatMap((g) => g.roundMatchNumbers[r] ?? []) + ); + return [r, allowed.size > 0 ? all.filter((m) => allowed.has(m.matchNumber)) : all] as const; + }) + ); const sharedMatchesByRound = new Map(sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []])); const phaseFirstScoringIdx = phaseRounds.findIndex((r) => rounds.indexOf(r) >= scoringRoundIdx); diff --git a/app/components/scoring/__tests__/PlayoffBracket.test.tsx b/app/components/scoring/__tests__/PlayoffBracket.test.tsx index 42c3a07..a4df24f 100644 --- a/app/components/scoring/__tests__/PlayoffBracket.test.tsx +++ b/app/components/scoring/__tests__/PlayoffBracket.test.tsx @@ -7,9 +7,11 @@ import { computeEliminatedByRound, computeRankedEntries, findConsolationRound, + roundsWithLosersPlacedLater, type Match, } from "../PlayoffBracket"; import { getBracketTemplate } from "~/lib/bracket-templates"; +import { resolveLLWSAdvancement } from "~/models/playoff-match"; // --------------------------------------------------------------------------- // Helpers @@ -426,6 +428,129 @@ function rankOf(entries: ReturnType, id: string) { return entries.find((e) => e.participant.id === id)?.rankLabel; } +// --------------------------------------------------------------------------- +// llws_20 — double elimination, where a winners-bracket loss places nobody +// --------------------------------------------------------------------------- + +/** Stable participant id for an llws_20 bracket slot. */ +function llwsTeam(i: number): string { + return `t${String(i).padStart(2, "0")}`; +} + +/** + * Play a full 20-team LLWS tournament, always advancing the lower-numbered + * participant id so the outcome is deterministic, and return every match. + * Routing comes from the real advancement map rather than being hand-listed. + */ +function llwsMatches(): Match[] { + const template = getBracketTemplate("llws_20"); + if (!template) throw new Error("llws_20 template missing"); + + // round → matchNumber → [p1, p2] + const slots = new Map>(); + for (const round of template.rounds) { + const byNumber = new Map(); + for (let n = 1; n <= round.matchCount; n++) byNumber.set(n, [null, null]); + slots.set(round.name, byNumber); + } + const put = (round: string, n: number, slot: 0 | 1, id: string) => { + const pair = slots.get(round)?.get(n); + if (pair) pair[slot] = id; + }; + + // Seed the Opening Round and the four byes, mirroring generateLLWS20Bracket. + for (const [base, roundBase] of [[0, 1], [10, 5]] as const) { + for (let local = 0; local < 4; local++) { + put("Opening Round", roundBase + local, 0, llwsTeam(base + local * 2)); + put("Opening Round", roundBase + local, 1, llwsTeam(base + local * 2 + 1)); + } + } + put("Winners Round 2", 1, 0, llwsTeam(8)); + put("Winners Round 2", 2, 0, llwsTeam(9)); + put("Winners Round 2", 3, 0, llwsTeam(18)); + put("Winners Round 2", 4, 0, llwsTeam(19)); + + const matches: Match[] = []; + for (const round of template.rounds) { + for (let n = 1; n <= round.matchCount; n++) { + const [p1, p2] = slots.get(round.name)?.get(n) ?? [null, null]; + if (!p1 || !p2) throw new Error(`${round.name} #${n} was not filled`); + // Deterministic: the lower id always wins. + const winnerId = p1 < p2 ? p1 : p2; + const loserId = p1 < p2 ? p2 : p1; + matches.push( + makeRankedMatch(round.name, n, winnerId, loserId, { + winnerSlot: winnerId === p1 ? 1 : 2, + }) + ); + const { winner, loser } = resolveLLWSAdvancement(round.name, n); + if (winner) put(winner.round, winner.matchNumber, winner.slot === "participant1Id" ? 0 : 1, winnerId); + if (loser) put(loser.round, loser.matchNumber, loser.slot === "participant1Id" ? 0 : 1, loserId); + } + } + return matches; +} + +describe("computeRankedEntries — llws_20 double elimination", () => { + const template = getBracketTemplate("llws_20"); + const rounds = template?.rounds.map((r) => r.name) ?? []; + + function rankLlws() { + const matches = llwsMatches(); + const consolation = findConsolationRound(template); + return computeRankedEntries( + matches, + rounds, + groupMatchesByRound(matches), + consolation, + new Map(), + roundsWithLosersPlacedLater(template, consolation) + ); + } + + it("ranks all 19 non-champions exactly once", () => { + const entries = rankLlws(); + expect(entries).toHaveLength(19); + expect(new Set(entries.map((e) => e.participant.id)).size).toBe(19); + }); + + it("gives the top 8 the positions the scoring tiers depend on", () => { + const entries = rankLlws(); + const labels = entries.map((e) => e.rankLabel); + // 2nd (World Championship loser), then 3rd and 4th decided by the consolation + // game, then the two 5–6 and two 7–8 tier teams. + expect(labels[0]).toBe("T2"); + expect(labels.filter((l) => l === "3")).toHaveLength(1); + expect(labels.filter((l) => l === "4")).toHaveLength(1); + expect(labels.filter((l) => l === "T5")).toHaveLength(2); + expect(labels.filter((l) => l === "T7")).toHaveLength(2); + }); + + it("does not inflate positions below the top 8", () => { + // Winners-bracket losses place nobody — those teams are ranked by the + // elimination-bracket game that actually knocks them out. If the winners + // rounds consumed positions, the last tier would read T23 in a 20-team field. + const entries = rankLlws(); + const labels = entries.map((e) => e.rankLabel); + expect(labels.filter((l) => l === "T9")).toHaveLength(4); + expect(labels.filter((l) => l === "T13")).toHaveLength(4); + expect(labels.filter((l) => l === "T17")).toHaveLength(4); + // 1 champion (not in the list) + 19 ranked = the full 20-team field. + expect(labels.some((l) => Number(l.replace("T", "")) > 17)).toBe(false); + }); + + it("never ranks a winners-bracket loser at the round they first lost", () => { + const entries = rankLlws(); + // t00 wins every game it plays (lowest id), so take a team that loses in the + // winners bracket but survives: the Opening Round M1 loser, t01. + const t01 = entries.find((e) => e.participant.id === "t01"); + expect(t01).toBeDefined(); + // Losing the opening game must not park them in the bottom tier — they got a + // second life in the elimination bracket. + expect(t01?.rankLabel).not.toBe("T17"); + }); +}); + describe("findConsolationRound", () => { it("identifies the fifa_48 third place game and the round that feeds it", () => { expect(findConsolationRound(getBracketTemplate("fifa_48"))).toEqual({ @@ -441,6 +566,17 @@ describe("findConsolationRound", () => { it("returns undefined when there is no template", () => { expect(findConsolationRound(undefined)).toBeUndefined(); }); + + it("ignores double-elimination loser routing and finds the real consolation game", () => { + // llws_20 sets loserFeedsInto on every winners-bracket round to route losers + // into the elimination bracket. Only the Bracket Championship feeds a terminal + // round; taking the first loserFeedsInto instead would mistake "Elimination + // Round 1" for a third-place game and corrupt the final rankings. + expect(findConsolationRound(getBracketTemplate("llws_20"))).toEqual({ + round: "Consolation Third Place", + feederRound: "Bracket Championship", + }); + }); }); describe("computeRankedEntries", () => { diff --git a/app/lib/bracket-templates.ts b/app/lib/bracket-templates.ts index 94b3892..a687d32 100644 --- a/app/lib/bracket-templates.ts +++ b/app/lib/bracket-templates.ts @@ -19,6 +19,18 @@ export interface BracketRound { * When set, the loser of each match in this round is placed into the target round. */ loserFeedsInto?: string | null; + /** + * Floor position banked by the WINNER of a *non-scoring* round. + * + * Omit for the default behavior: winners entering the first scoring round bank a + * T5–T8 floor (position 5), everyone else banks nothing. Set an explicit number when + * that default is wrong — in a double-elimination losers bracket a win can guarantee + * a worse finish than 5th (llws_20 "Elimination Round 3" → 7). Set null to bank no + * floor even though the next round scores. + * + * Has no effect on scoring rounds, which use RoundScoringConfig.winnerFloor instead. + */ + nonScoringWinnerFloor?: number | null; } export interface GroupStageConfig { @@ -934,6 +946,258 @@ export const NBA_20: BracketTemplate = { ], }; +// ── LLWS 20 ─────────────────────────────────────────────────────────────────── + +/** Side-local match numbers → global match numbers, per round shape. */ +const LLWS_OPENING_OFFSET = 4; // Opening Round: US M1–4, Intl M5–8 +const LLWS_PAIR_OFFSET = 2; // 4-match rounds: US M1–2, Intl M3–4 +const LLWS_SOLO_OFFSET = 1; // 2-match rounds: US M1, Intl M2 + +/** Rounds with 4 matches (2 per side). Opening Round has 8; the rest have 2. */ +export const LLWS_FOUR_MATCH_ROUNDS = new Set([ + "Winners Round 2", + "Elimination Round 1", + "Winners Semifinals", + "Elimination Round 2", + "Elimination Round 3", +]); + +/** + * Returns the global match number for a side-local match in an LLWS round. + * side 0 = United States, side 1 = International. + */ +export function llwsMatchNumber(round: string, side: 0 | 1, localMatch: number): number { + const offset = + round === "Opening Round" + ? LLWS_OPENING_OFFSET + : LLWS_FOUR_MATCH_ROUNDS.has(round) + ? LLWS_PAIR_OFFSET + : LLWS_SOLO_OFFSET; + return localMatch + side * offset; +} + +/** + * Inverse of llwsMatchNumber: global match number → { side, localMatch }. + */ +export function llwsSideAndLocal( + round: string, + matchNumber: number +): { side: 0 | 1; localMatch: number } { + const offset = + round === "Opening Round" + ? LLWS_OPENING_OFFSET + : LLWS_FOUR_MATCH_ROUNDS.has(round) + ? LLWS_PAIR_OFFSET + : LLWS_SOLO_OFFSET; + const side: 0 | 1 = matchNumber > offset ? 1 : 0; + return { side, localMatch: matchNumber - side * offset }; +} + +/** + * Little League Baseball World Series (20 teams, 2025+ double-elimination format) + * + * Two independent 10-team double-elimination brackets — United States and + * International — each producing a side champion, then a World Championship game and + * a Consolation Third Place game between the two side runners-up. + * + * Rounds are shared across both sides: U.S. matches take the low match numbers and + * International the high ones (see llwsMatchNumber). The phases/groups config splits + * them back apart for display. + * + * A loss in the winners bracket is NOT an elimination — it drops the team into the + * elimination bracket at a specific slot (see advanceLLWSWinner in models/playoff-match). + * A loss in the elimination bracket is final. + * + * There is deliberately NO "if necessary" game: the winners-bracket champion is out if + * it loses the Bracket Championship, dropping to the Consolation game rather than + * forcing a rematch. This is the official LLWS modified double-elimination format. + * + * Placement tiers (only 8 teams score — the field is exactly 8 when Elim R4 begins): + * 1st / 2nd World Championship + * 3rd / 4th Consolation Third Place (real game, so positions are distinct) + * 5th / 6th Elimination Final losers + * 7th / 8th Elimination Round 4 losers + * 0 pts the 12 teams eliminated in Elimination Rounds 1–3 + * + * Participant array layout (20 slots): + * [0–7] U.S. Opening Round teams, two per game (M1..M4) + * [8, 9] U.S. bye teams, entering Winners Round 2 M1 / M2 at participant1 + * [10–17] International Opening Round teams, two per game (M5..M8) + * [18,19] International bye teams, entering Winners Round 2 M3 / M4 at participant1 + */ +export const LLWS_20: BracketTemplate = { + id: "llws_20", + name: "Little League World Series (20 teams)", + totalTeams: 20, + scoringStartsAtRound: "Winners Final", + // Ordered by the real schedule so non-phased views read chronologically. + rounds: [ + { + name: "Opening Round", + matchCount: 8, + feedsInto: "Winners Round 2", + isScoring: false, + loserFeedsInto: "Elimination Round 1", + nonScoringWinnerFloor: null, // 16 teams still alive — nothing guaranteed + }, + { + name: "Winners Round 2", + matchCount: 4, + feedsInto: "Winners Semifinals", + isScoring: false, + loserFeedsInto: "Elimination Round 2", + nonScoringWinnerFloor: null, + }, + { + name: "Elimination Round 1", + matchCount: 4, + feedsInto: "Elimination Round 2", + isScoring: false, // losers finish 13th–16th + nonScoringWinnerFloor: null, + }, + { + name: "Winners Semifinals", + matchCount: 4, + feedsInto: "Winners Final", + isScoring: false, + loserFeedsInto: "Elimination Round 3", + // Reaching the Winners Final guarantees at worst 5th (lose it, then lose the + // Elimination Final). Same value as the engine default, stated explicitly. + nonScoringWinnerFloor: 5, + }, + { + name: "Elimination Round 2", + matchCount: 4, + feedsInto: "Elimination Round 3", + isScoring: false, // losers finish 11th–12th + nonScoringWinnerFloor: null, + }, + { + name: "Elimination Round 3", + matchCount: 4, + feedsInto: "Elimination Round 4", + isScoring: false, // losers finish 9th–10th + // Winners reach Elimination Round 4, where a loss is 7th — not 5th. + nonScoringWinnerFloor: 7, + }, + { + name: "Winners Final", + matchCount: 2, + feedsInto: "Bracket Championship", + isScoring: true, // loser drops to the Elimination Final (provisional 5th) + loserFeedsInto: "Elimination Final", + }, + { + name: "Elimination Round 4", + matchCount: 2, + feedsInto: "Elimination Final", + isScoring: true, // losers share 7th–8th + }, + { + name: "Elimination Final", + matchCount: 2, + feedsInto: "Bracket Championship", + isScoring: true, // losers share 5th–6th + }, + { + name: "Bracket Championship", + matchCount: 2, + feedsInto: "World Championship", + isScoring: true, // loser drops to the Consolation game (provisional 4th) + loserFeedsInto: "Consolation Third Place", + }, + { + name: "Consolation Third Place", + matchCount: 1, + feedsInto: null, + isScoring: true, // winner 3rd, loser 4th + }, + { + name: "World Championship", + matchCount: 1, + feedsInto: null, + isScoring: true, // winner 1st, loser 2nd + }, + ], + // Region assignments rotate year to year (which region draws the bye changes), so + // these are positional slot labels rather than region names. Kept short — the admin + // form renders them in a narrow fixed-width column alongside each participant picker. + participantLabels: [ + "US G1 Home", "US G1 Away", + "US G2 Home", "US G2 Away", + "US G3 Home", "US G3 Away", + "US G4 Home", "US G4 Away", + "US Bye 1", "US Bye 2", + "Intl G1 Home", "Intl G1 Away", + "Intl G2 Home", "Intl G2 Away", + "Intl G3 Home", "Intl G3 Away", + "Intl G4 Home", "Intl G4 Away", + "Intl Bye 1", "Intl Bye 2", + ], + phases: [ + { + name: "United States", + groups: [ + { + name: "U.S. Winner's Bracket", + roundMatchNumbers: { + "Opening Round": [1, 2, 3, 4], + "Winners Round 2": [1, 2], + "Winners Semifinals": [1, 2], + "Winners Final": [1], + }, + }, + { + name: "U.S. Elimination Bracket", + roundMatchNumbers: { + "Elimination Round 1": [1, 2], + "Elimination Round 2": [1, 2], + "Elimination Round 3": [1, 2], + "Elimination Round 4": [1], + "Elimination Final": [1], + }, + }, + { + name: "U.S. Championship", + roundMatchNumbers: { "Bracket Championship": [1] }, + }, + ], + }, + { + name: "International", + groups: [ + { + name: "International Winner's Bracket", + roundMatchNumbers: { + "Opening Round": [5, 6, 7, 8], + "Winners Round 2": [3, 4], + "Winners Semifinals": [3, 4], + "Winners Final": [2], + }, + }, + { + name: "International Elimination Bracket", + roundMatchNumbers: { + "Elimination Round 1": [3, 4], + "Elimination Round 2": [3, 4], + "Elimination Round 3": [3, 4], + "Elimination Round 4": [2], + "Elimination Final": [2], + }, + }, + { + name: "International Championship", + roundMatchNumbers: { "Bracket Championship": [2] }, + }, + ], + }, + { + name: "Championship", + rounds: ["Consolation Third Place", "World Championship"], + }, + ], +}; + /** * All available bracket templates */ @@ -951,6 +1215,7 @@ export const BRACKET_TEMPLATES: Record = { tennis_128: TENNIS_128, cfp_12: CFP_12, nba_20: NBA_20, + llws_20: LLWS_20, }; /** @@ -1006,6 +1271,18 @@ export function getScoringRoundType( const round = template.rounds.find((r) => r.name === roundName); if (!round || !round.isScoring) return null; + // Special handling for LLWS double elimination: match counts don't identify the + // tier (Elimination Round 4 and the Elimination Final both have 2 matches), and + // the Winners Final eliminates nobody. + if (template.id === "llws_20") { + if (roundName === "Elimination Round 4") return "quarterfinals"; // losers share 7-8th + if (roundName === "Elimination Final") return "quarterfinals"; // losers share 5-6th + if (roundName === "Bracket Championship") return "semifinals"; // losers play for 3-4th + if (roundName === "Consolation Third Place") return "semifinals"; // finalizes 3rd/4th + if (roundName === "World Championship") return "finals"; // 1st and 2nd + return null; // Winners Final: loser drops to the elimination bracket, nobody is out + } + // Special handling for AFL finals if (template.id === "afl_10") { if (roundName === "Elimination Finals") return "quarterfinals"; // Losers share 7-8th diff --git a/app/models/__tests__/llws-20-bracket.test.ts b/app/models/__tests__/llws-20-bracket.test.ts new file mode 100644 index 0000000..d489ac3 --- /dev/null +++ b/app/models/__tests__/llws-20-bracket.test.ts @@ -0,0 +1,589 @@ +/** + * LLWS 20-Team Double-Elimination Bracket Tests + * + * Verifies the llws_20 template against the official 2026 LLBWS bracket + * (Williamsport, Aug 19–30). The PDF numbers its games 1–38; those numbers appear + * throughout as `G` so the routing can be checked against the printed bracket. + * + * The critical property under test is the double-elimination loser routing: a loss in + * the winners bracket drops a team into the elimination bracket at a specific slot, + * while a loss in the elimination bracket is final. + */ + +import { describe, it, expect, vi } from "vitest"; +import { + LLWS_20, + getScoringRoundType, + llwsMatchNumber, + llwsSideAndLocal, +} from "~/lib/bracket-templates"; +import { + doesLoserAdvance, + generateBracketFromTemplate, + resolveLLWSAdvancement, +} from "../playoff-match"; +import { + calculateBracketPoints, + calculateAveragedPoints, + type ScoringRules, +} from "../scoring-rules"; + +// generateBracketFromTemplate's only DB touch for llws_20 is the bulk insert, so a +// minimal stub is enough to capture the generated rows. +const insertedRows: Record[] = []; +vi.mock("~/database/context", () => ({ + database: () => ({ + insert: () => ({ + values: (rows: Record[]) => ({ + returning: async () => { + insertedRows.push(...rows); + return rows; + }, + }), + }), + }), +})); + +const DEFAULT_SCORING: ScoringRules = { + pointsFor1st: 100, + pointsFor2nd: 70, + pointsFor3rd: 50, + pointsFor4th: 40, + pointsFor5th: 25, + pointsFor6th: 20, + pointsFor7th: 15, + pointsFor8th: 10, +}; + +// ── PDF game number ↔ (round, match number) ────────────────────────────────── +// +// Transcribed directly from the 2026 LLBWS bracket. U.S. games take the low match +// numbers in each round, International the high ones. +const GAME_TO_MATCH: Record = { + // Opening Round — U.S. G2,4,6,8 (M1–4); Intl G1,3,5,7 (M5–8) + 2: { round: "Opening Round", matchNumber: 1 }, + 4: { round: "Opening Round", matchNumber: 2 }, + 6: { round: "Opening Round", matchNumber: 3 }, + 8: { round: "Opening Round", matchNumber: 4 }, + 1: { round: "Opening Round", matchNumber: 5 }, + 3: { round: "Opening Round", matchNumber: 6 }, + 5: { round: "Opening Round", matchNumber: 7 }, + 7: { round: "Opening Round", matchNumber: 8 }, + // Winners Round 2 — U.S. G10,12; Intl G9,11 + 10: { round: "Winners Round 2", matchNumber: 1 }, + 12: { round: "Winners Round 2", matchNumber: 2 }, + 9: { round: "Winners Round 2", matchNumber: 3 }, + 11: { round: "Winners Round 2", matchNumber: 4 }, + // Elimination Round 1 — U.S. G14,16; Intl G13,15 + 14: { round: "Elimination Round 1", matchNumber: 1 }, + 16: { round: "Elimination Round 1", matchNumber: 2 }, + 13: { round: "Elimination Round 1", matchNumber: 3 }, + 15: { round: "Elimination Round 1", matchNumber: 4 }, + // Winners Semifinals — U.S. G17,19; Intl G18,20 + 17: { round: "Winners Semifinals", matchNumber: 1 }, + 19: { round: "Winners Semifinals", matchNumber: 2 }, + 18: { round: "Winners Semifinals", matchNumber: 3 }, + 20: { round: "Winners Semifinals", matchNumber: 4 }, + // Elimination Round 2 — U.S. G22,24; Intl G21,23 + 22: { round: "Elimination Round 2", matchNumber: 1 }, + 24: { round: "Elimination Round 2", matchNumber: 2 }, + 21: { round: "Elimination Round 2", matchNumber: 3 }, + 23: { round: "Elimination Round 2", matchNumber: 4 }, + // Elimination Round 3 — U.S. G26,28; Intl G25,27 + 26: { round: "Elimination Round 3", matchNumber: 1 }, + 28: { round: "Elimination Round 3", matchNumber: 2 }, + 25: { round: "Elimination Round 3", matchNumber: 3 }, + 27: { round: "Elimination Round 3", matchNumber: 4 }, + // Winners Final — U.S. G30; Intl G29 + 30: { round: "Winners Final", matchNumber: 1 }, + 29: { round: "Winners Final", matchNumber: 2 }, + // Elimination Round 4 — U.S. G32; Intl G31 + 32: { round: "Elimination Round 4", matchNumber: 1 }, + 31: { round: "Elimination Round 4", matchNumber: 2 }, + // Elimination Final — U.S. G34; Intl G33 + 34: { round: "Elimination Final", matchNumber: 1 }, + 33: { round: "Elimination Final", matchNumber: 2 }, + // Bracket Championship — U.S. G36; Intl G35 + 36: { round: "Bracket Championship", matchNumber: 1 }, + 35: { round: "Bracket Championship", matchNumber: 2 }, + // Finals + 37: { round: "Consolation Third Place", matchNumber: 1 }, + 38: { round: "World Championship", matchNumber: 1 }, +}; + +const MATCH_TO_GAME = new Map( + Object.entries(GAME_TO_MATCH).map(([game, m]) => [ + `${m.round}#${m.matchNumber}`, + Number(game), + ]) +); + +function gameNumberFor(round: string, matchNumber: number): number { + const game = MATCH_TO_GAME.get(`${round}#${matchNumber}`); + if (game === undefined) throw new Error(`No PDF game for ${round} #${matchNumber}`); + return game; +} + +/** Narrows a destination that the test expects to exist. */ +function required(destination: T | null): T { + if (destination === null) throw new Error("Expected a destination, got null"); + return destination; +} + +/** PDF game number a destination points at. */ +function destinationGame( + destination: { round: string; matchNumber: number } | null +): number { + const d = required(destination); + return gameNumberFor(d.round, d.matchNumber); +} + +/** + * The official bracket printed as feed labels: for each game, which prior game's + * winner (W) or loser (L) fills each slot. `null` = a team seeded in directly. + * + * Transcribed from the PDF. This is the source of truth the routing must reproduce. + */ +const EXPECTED_SLOTS: Record = { + // Opening Round — all directly seeded + 1: [null, null], 2: [null, null], 3: [null, null], 4: [null, null], + 5: [null, null], 6: [null, null], 7: [null, null], 8: [null, null], + // Winners Round 2 — bye team, then an Opening Round winner + 9: [null, "W1"], 10: [null, "W2"], 11: [null, "W3"], 12: [null, "W4"], + // Elimination Round 1 + 13: ["L3", "L5"], 14: ["L4", "L6"], 15: ["L1", "L7"], 16: ["L2", "L8"], + // Winners Semifinals + 17: ["W6", "W10"], 18: ["W5", "W9"], 19: ["W12", "W8"], 20: ["W11", "W7"], + // Elimination Round 2 + 21: ["L9", "W13"], 22: ["L10", "W14"], 23: ["L11", "W15"], 24: ["L12", "W16"], + // Elimination Round 3 — cross-over + 25: ["L18", "W23"], 26: ["L17", "W24"], 27: ["L20", "W21"], 28: ["L19", "W22"], + // Winners Final + 29: ["W18", "W20"], 30: ["W17", "W19"], + // Elimination Round 4 + 31: ["W27", "W25"], 32: ["W28", "W26"], + // Elimination Final + 33: ["L29", "W31"], 34: ["L30", "W32"], + // Bracket Championship + 35: ["W29", "W33"], 36: ["W30", "W34"], + // Finals + 37: ["L36", "L35"], 38: ["W36", "W35"], +}; + +describe("LLWS 20 Bracket Template", () => { + describe("Template structure", () => { + it("has correct identity and size", () => { + expect(LLWS_20.id).toBe("llws_20"); + expect(LLWS_20.totalTeams).toBe(20); + expect(LLWS_20.scoringStartsAtRound).toBe("Winners Final"); + }); + + it("has 12 rounds totalling 38 matches", () => { + expect(LLWS_20.rounds).toHaveLength(12); + const total = LLWS_20.rounds.reduce((sum, r) => sum + r.matchCount, 0); + expect(total).toBe(38); + }); + + it("has the expected match count per round", () => { + const counts = Object.fromEntries( + LLWS_20.rounds.map((r) => [r.name, r.matchCount]) + ); + expect(counts).toEqual({ + "Opening Round": 8, + "Winners Round 2": 4, + "Elimination Round 1": 4, + "Winners Semifinals": 4, + "Elimination Round 2": 4, + "Elimination Round 3": 4, + "Winners Final": 2, + "Elimination Round 4": 2, + "Elimination Final": 2, + "Bracket Championship": 2, + "Consolation Third Place": 1, + "World Championship": 1, + }); + }); + + it("marks exactly the point-awarding rounds as scoring", () => { + const scoring = LLWS_20.rounds.filter((r) => r.isScoring).map((r) => r.name); + expect(scoring).toEqual([ + "Winners Final", + "Elimination Round 4", + "Elimination Final", + "Bracket Championship", + "Consolation Third Place", + "World Championship", + ]); + }); + + it("lists rounds in chronological order", () => { + // Elimination Round 1 (Aug 22) is played before Winners Semifinals (Aug 23). + const names = LLWS_20.rounds.map((r) => r.name); + expect(names.indexOf("Elimination Round 1")).toBeLessThan( + names.indexOf("Winners Semifinals") + ); + expect(names.indexOf("Winners Final")).toBeLessThan( + names.indexOf("Elimination Final") + ); + }); + + it("gives elimination-bracket winners a floor matching their real worst case", () => { + const byName = (n: string) => LLWS_20.rounds.find((r) => r.name === n); + // Winning Elim R3 only guarantees 7th (a loss in Elim R4 is the 7–8 tier), + // so the engine's default floor of 5 would overstate it. + expect(byName("Elimination Round 3")?.nonScoringWinnerFloor).toBe(7); + // Reaching the Winners Final guarantees 5th at worst. + expect(byName("Winners Semifinals")?.nonScoringWinnerFloor).toBe(5); + // Nothing is guaranteed earlier than that. + expect(byName("Opening Round")?.nonScoringWinnerFloor).toBeNull(); + expect(byName("Winners Round 2")?.nonScoringWinnerFloor).toBeNull(); + expect(byName("Elimination Round 1")?.nonScoringWinnerFloor).toBeNull(); + expect(byName("Elimination Round 2")?.nonScoringWinnerFloor).toBeNull(); + }); + + it("has 20 participant labels", () => { + expect(LLWS_20.participantLabels).toHaveLength(20); + }); + + it("splits display into U.S., International and Championship phases", () => { + expect(LLWS_20.phases?.map((p) => p.name)).toEqual([ + "United States", + "International", + "Championship", + ]); + }); + + it("assigns every match to exactly one phase group", () => { + const claimed = new Map(); + for (const phase of LLWS_20.phases ?? []) { + for (const group of phase.groups ?? []) { + for (const [round, numbers] of Object.entries(group.roundMatchNumbers)) { + for (const n of numbers) { + const key = `${round}#${n}`; + claimed.set(key, (claimed.get(key) ?? 0) + 1); + } + } + } + } + // Every per-side match claimed exactly once (36 games; the 2 finals live in + // the Championship phase's plain round list, not in a group). + expect(claimed.size).toBe(36); + expect([...claimed.values()].every((c) => c === 1)).toBe(true); + }); + }); + + describe("Bracket generation", () => { + const PARTICIPANTS = Array.from({ length: 20 }, (_, i) => `team-${i}`); + + async function generate() { + insertedRows.length = 0; + await generateBracketFromTemplate("event-1", "llws_20", PARTICIPANTS); + return insertedRows.map((r) => ({ + round: r.round as string, + matchNumber: r.matchNumber as number, + participant1Id: (r.participant1Id ?? null) as string | null, + participant2Id: (r.participant2Id ?? null) as string | null, + isScoring: r.isScoring as boolean, + })); + } + + it("creates all 38 matches", async () => { + const rows = await generate(); + expect(rows).toHaveLength(38); + }); + + it("creates the right number of matches per round", async () => { + const rows = await generate(); + for (const round of LLWS_20.rounds) { + expect( + rows.filter((r) => r.round === round.name), + `${round.name} match count` + ).toHaveLength(round.matchCount); + } + }); + + it("numbers matches 1..n within each round", async () => { + const rows = await generate(); + for (const round of LLWS_20.rounds) { + const numbers = rows + .filter((r) => r.round === round.name) + .map((r) => r.matchNumber) + .toSorted((a, b) => a - b); + expect(numbers).toEqual( + Array.from({ length: round.matchCount }, (_, i) => i + 1) + ); + } + }); + + it("seeds the Opening Round two teams at a time, U.S. then International", async () => { + const rows = await generate(); + const opening = rows + .filter((r) => r.round === "Opening Round") + .toSorted((a, b) => a.matchNumber - b.matchNumber); + // U.S. slots 0–7 fill matches 1–4; International slots 10–17 fill matches 5–8. + expect(opening.map((m) => [m.participant1Id, m.participant2Id])).toEqual([ + ["team-0", "team-1"], + ["team-2", "team-3"], + ["team-4", "team-5"], + ["team-6", "team-7"], + ["team-10", "team-11"], + ["team-12", "team-13"], + ["team-14", "team-15"], + ["team-16", "team-17"], + ]); + }); + + it("seats the four bye teams in Winners Round 2 awaiting an opponent", async () => { + const rows = await generate(); + const wr2 = rows + .filter((r) => r.round === "Winners Round 2") + .toSorted((a, b) => a.matchNumber - b.matchNumber); + expect(wr2.map((m) => [m.participant1Id, m.participant2Id])).toEqual([ + ["team-8", null], + ["team-9", null], + ["team-18", null], + ["team-19", null], + ]); + }); + + it("uses each participant exactly once and leaves every other slot empty", async () => { + const rows = await generate(); + const seeded = rows + .flatMap((r) => [r.participant1Id, r.participant2Id]) + .filter((id): id is string => id !== null); + expect(seeded).toHaveLength(20); + expect(new Set(seeded).size).toBe(20); + expect(new Set(seeded)).toEqual(new Set(PARTICIPANTS)); + }); + + it("stamps isScoring from the template", async () => { + const rows = await generate(); + for (const round of LLWS_20.rounds) { + for (const row of rows.filter((r) => r.round === round.name)) { + expect(row.isScoring, `${round.name} #${row.matchNumber}`).toBe(round.isScoring); + } + } + }); + + it("rejects a participant count other than 20", async () => { + await expect( + generateBracketFromTemplate("event-1", "llws_20", PARTICIPANTS.slice(0, 19)) + ).rejects.toThrow(/requires 20 participants/); + }); + }); + + describe("Side / match-number mapping", () => { + it("round-trips every match number through side-local form", () => { + for (const round of LLWS_20.rounds) { + if (round.matchCount === 1) continue; // shared finals have no side + for (let n = 1; n <= round.matchCount; n++) { + const { side, localMatch } = llwsSideAndLocal(round.name, n); + expect(llwsMatchNumber(round.name, side, localMatch)).toBe(n); + } + } + }); + + it("puts U.S. matches in the low half and International in the high half", () => { + expect(llwsSideAndLocal("Opening Round", 4).side).toBe(0); + expect(llwsSideAndLocal("Opening Round", 5).side).toBe(1); + expect(llwsSideAndLocal("Winners Semifinals", 2).side).toBe(0); + expect(llwsSideAndLocal("Winners Semifinals", 3).side).toBe(1); + expect(llwsSideAndLocal("Winners Final", 1).side).toBe(0); + expect(llwsSideAndLocal("Winners Final", 2).side).toBe(1); + }); + }); + + describe("Advancement matches the official bracket", () => { + /** + * Replay the whole tournament through resolveLLWSAdvancement and record which + * feed label ends up in each slot, then compare against the printed bracket. + */ + const actualSlots: Record = {}; + for (const game of Object.keys(EXPECTED_SLOTS)) { + actualSlots[Number(game)] = [null, null]; + } + + for (const [gameStr, { round, matchNumber }] of Object.entries(GAME_TO_MATCH)) { + const game = Number(gameStr); + const { winner, loser } = resolveLLWSAdvancement(round, matchNumber); + for (const [dest, label] of [ + [winner, `W${game}`], + [loser, `L${game}`], + ] as const) { + if (!dest) continue; + const targetGame = gameNumberFor(dest.round, dest.matchNumber); + const slotIndex = dest.slot === "participant1Id" ? 0 : 1; + actualSlots[targetGame][slotIndex] = label; + } + } + + it.each(Object.keys(EXPECTED_SLOTS).map(Number).toSorted((a, b) => a - b))( + "Game %i has the printed participants", + (game) => { + expect(actualSlots[game]).toEqual(EXPECTED_SLOTS[game]); + } + ); + + it("fills every slot in the bracket exactly once", () => { + // 38 games × 2 slots = 76. 20 are seeded directly (16 opening teams + 4 byes), + // leaving 56 to be filled by advancement. + const filled = Object.values(actualSlots) + .flat() + .filter((s) => s !== null).length; + expect(filled).toBe(56); + }); + }); + + describe("Double-elimination loser routing", () => { + it("routes every winners-bracket loser into the elimination bracket", () => { + const winnersRounds = [ + "Opening Round", + "Winners Round 2", + "Winners Semifinals", + "Winners Final", + ]; + for (const roundName of winnersRounds) { + const round = LLWS_20.rounds.find((r) => r.name === roundName); + if (!round) throw new Error(`missing round ${roundName}`); + for (let n = 1; n <= round.matchCount; n++) { + const { loser } = resolveLLWSAdvancement(roundName, n); + expect(loser, `${roundName} #${n} loser should advance`).not.toBeNull(); + expect(loser?.round.startsWith("Elimination")).toBe(true); + } + } + }); + + it("eliminates every elimination-bracket loser", () => { + const elimRounds = [ + "Elimination Round 1", + "Elimination Round 2", + "Elimination Round 3", + "Elimination Round 4", + "Elimination Final", + ]; + for (const roundName of elimRounds) { + const round = LLWS_20.rounds.find((r) => r.name === roundName); + if (!round) throw new Error(`missing round ${roundName}`); + for (let n = 1; n <= round.matchCount; n++) { + const { loser } = resolveLLWSAdvancement(roundName, n); + expect(loser, `${roundName} #${n} loser should be out`).toBeNull(); + } + } + }); + + it("keeps the winners-bracket final loser alive via the Elimination Final", () => { + // G30 (U.S. Winners Final) loser → G34, not out. This is the defining + // double-elimination behavior: a first loss never eliminates. + const { winner, loser } = resolveLLWSAdvancement("Winners Final", 1); + expect(destinationGame(loser)).toBe(34); + expect(destinationGame(winner)).toBe(36); + }); + + it("sends the side-championship loser to the consolation game, not out", () => { + // No "if necessary" rematch: the winners-bracket champion that loses G36 is + // done in the bracket, but still plays G37 for 3rd/4th. + const us = resolveLLWSAdvancement("Bracket Championship", 1); + expect(destinationGame(us.winner)).toBe(38); + expect(destinationGame(us.loser)).toBe(37); + expect(required(us.winner).slot).toBe("participant1Id"); + expect(required(us.loser).slot).toBe("participant1Id"); + + const intl = resolveLLWSAdvancement("Bracket Championship", 2); + expect(required(intl.winner).slot).toBe("participant2Id"); + expect(required(intl.loser).slot).toBe("participant2Id"); + }); + + it("flags winners-bracket losers as advancing so they are not marked eliminated", () => { + // doesLoserAdvance is what stops the scoring engine writing a 0-point + // elimination (and announcing a knockout) for a team that is still alive. + // Winners Final and Bracket Championship are scoring rounds and are covered + // by loserIsPartial instead, so they are deliberately not listed here. + for (const round of ["Opening Round", "Winners Round 2", "Winners Semifinals"]) { + expect(doesLoserAdvance(round, 1, "llws_20"), round).toBe(true); + } + for (const round of [ + "Elimination Round 1", + "Elimination Round 2", + "Elimination Round 3", + "Elimination Round 4", + "Elimination Final", + ]) { + expect(doesLoserAdvance(round, 1, "llws_20"), round).toBe(false); + } + }); + + it("does not apply LLWS loser routing to other templates", () => { + expect(doesLoserAdvance("Opening Round", 1, "ncaa_68")).toBe(false); + expect(doesLoserAdvance("Winners Semifinals", 1, "")).toBe(false); + }); + + it("advances nobody out of the two final games", () => { + for (const round of ["Consolation Third Place", "World Championship"]) { + expect(resolveLLWSAdvancement(round, 1)).toEqual({ winner: null, loser: null }); + } + }); + + it("never crosses a team between the U.S. and International sides", () => { + for (const round of LLWS_20.rounds) { + if (round.name === "Bracket Championship") continue; // the crossover point + if (round.matchCount === 1) continue; + for (let n = 1; n <= round.matchCount; n++) { + const { side } = llwsSideAndLocal(round.name, n); + const { winner, loser } = resolveLLWSAdvancement(round.name, n); + for (const dest of [winner, loser]) { + if (!dest) continue; + const destRound = LLWS_20.rounds.find((r) => r.name === dest.round); + if (!destRound || destRound.matchCount === 1) continue; + expect(llwsSideAndLocal(dest.round, dest.matchNumber).side).toBe(side); + } + } + } + }); + }); + + describe("Placement tiers", () => { + it("classifies scoring rounds correctly", () => { + expect(getScoringRoundType("Elimination Round 4", LLWS_20)).toBe("quarterfinals"); + expect(getScoringRoundType("Elimination Final", LLWS_20)).toBe("quarterfinals"); + expect(getScoringRoundType("Bracket Championship", LLWS_20)).toBe("semifinals"); + expect(getScoringRoundType("World Championship", LLWS_20)).toBe("finals"); + // Nobody is eliminated in the Winners Final — the loser drops to the + // elimination bracket — so it has no placement tier. + expect(getScoringRoundType("Winners Final", LLWS_20)).toBeNull(); + }); + + it("pays 3rd and 4th distinctly (there is a real consolation game)", () => { + expect(calculateBracketPoints(3, DEFAULT_SCORING, "llws_20")).toBe(50); + expect(calculateBracketPoints(4, DEFAULT_SCORING, "llws_20")).toBe(40); + }); + + it("splits 5–8 into two two-team tiers", () => { + const upper = calculateAveragedPoints([5, 6], DEFAULT_SCORING); // (25+20)/2 + const lower = calculateAveragedPoints([7, 8], DEFAULT_SCORING); // (15+10)/2 + expect(calculateBracketPoints(5, DEFAULT_SCORING, "llws_20")).toBe(upper); + expect(calculateBracketPoints(6, DEFAULT_SCORING, "llws_20")).toBe(upper); + expect(calculateBracketPoints(7, DEFAULT_SCORING, "llws_20")).toBe(lower); + expect(calculateBracketPoints(8, DEFAULT_SCORING, "llws_20")).toBe(lower); + // Surviving Elimination Round 4 is worth more than losing it. + expect(upper).toBeGreaterThan(lower); + }); + + it("awards nothing below 8th", () => { + // The 12 teams knocked out in Elimination Rounds 1–3 finish 9th–20th. + expect(calculateBracketPoints(9, DEFAULT_SCORING, "llws_20")).toBe(0); + expect(calculateBracketPoints(0, DEFAULT_SCORING, "llws_20")).toBe(0); + }); + + it("has exactly 8 teams alive when the first scoring elimination game is played", () => { + // Elimination Round 4 is the 7th–8th tier, so the field must be 8 at that point: + // per side the Winners Final winner, the Winners Final loser, and the two + // Elimination Round 3 winners. + const eliminatedBeforeElimR4 = + (LLWS_20.rounds.find((r) => r.name === "Elimination Round 1")?.matchCount ?? 0) + + (LLWS_20.rounds.find((r) => r.name === "Elimination Round 2")?.matchCount ?? 0) + + (LLWS_20.rounds.find((r) => r.name === "Elimination Round 3")?.matchCount ?? 0); + expect(eliminatedBeforeElimR4).toBe(12); + expect(LLWS_20.totalTeams - eliminatedBeforeElimR4).toBe(8); + }); + }); +}); diff --git a/app/models/playoff-match.ts b/app/models/playoff-match.ts index 18deb60..101daad 100644 --- a/app/models/playoff-match.ts +++ b/app/models/playoff-match.ts @@ -6,6 +6,8 @@ import { getBracketTemplate, buildNCAA68SlotMap, matchIndexForSeedSlot, + llwsMatchNumber, + llwsSideAndLocal, STANDARD_BRACKET_SEEDING, } from "~/lib/bracket-templates"; @@ -467,6 +469,11 @@ export async function generateBracketFromTemplate( return await generateNBA20Bracket(eventId, template, participantIds); } + // LLWS 20 requires special handling for its two double-elimination brackets + if (templateId === "llws_20") { + return await generateLLWS20Bracket(eventId, template, participantIds); + } + const matches: NewPlayoffMatch[] = []; // Generate matches for each round in the template @@ -980,6 +987,15 @@ export async function advanceWinnerTemplate( return await advanceNBAPlayInWinner(match, winnerId, loserId); } + // Special handling for LLWS 20 double elimination: winners-bracket losers route + // into the elimination bracket instead of being knocked out. + if (template.id === "llws_20") { + const loserId = + match.participant1Id === winnerId ? match.participant2Id : match.participant1Id; + if (!loserId) throw new Error("Cannot determine loser for LLWS advancement"); + return await advanceLLWSWinner(match, winnerId, loserId); + } + // Special handling for AFL 10 double-chance system // Phase 3.3: AFL has complex winner/loser advancement rules if (template.id === "afl_10") { @@ -1427,6 +1443,12 @@ export function doesLoserAdvance( if (templateId === "afl_10" && round === "Qualifying Finals") { return true; } + // LLWS winners bracket: a loss drops the team into the elimination bracket, so it + // must not be recorded as an elimination. (Winners Final and Bracket Championship + // are scoring rounds and are handled via loserIsPartial instead.) + if (templateId === "llws_20" && LLWS_LOSER_ADVANCES_ROUNDS.has(round)) { + return true; + } return false; } @@ -1538,3 +1560,320 @@ async function advanceNBAPlayInWinner( throw new Error(`Unknown Play-In Round 2 match number: ${match.matchNumber}`); } } + +// ── LLWS 20 (double elimination) ────────────────────────────────────────────── + +/** + * 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. */ +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) }; +} + +/** + * Generate the 20-team LLWS double-elimination bracket (38 matches). + * + * Only the Opening Round and the four bye slots receive participants up front; + * everything else is filled by advanceLLWSWinner as games complete. + * + * Participant array layout (see LLWS_20 in lib/bracket-templates): + * [0–7] U.S. Opening Round teams, two per game + * [8, 9] U.S. bye teams → Winners Round 2 M1 / M2 participant1 + * [10–17] International Opening Round teams, two per game + * [18,19] International bye teams → Winners Round 2 M3 / M4 participant1 + */ +async function generateLLWS20Bracket( + eventId: string, + template: BracketTemplate, + participantIds?: string[] +): Promise { + const matches: NewPlayoffMatch[] = []; + const p = (idx: number): string | null => + participantIds ? (participantIds[idx] ?? null) : null; + + const sides = [ + { side: 0 as const, label: "U.S.", openingBase: 0, byeBase: 8 }, + { side: 1 as const, label: "Intl", openingBase: 10, byeBase: 18 }, + ]; + + // ── Opening Round: 4 games per side, both slots seeded ────────────────────── + for (const { side, label, openingBase } of sides) { + for (let local = 1; local <= 4; local++) { + matches.push({ + scoringEventId: eventId, + round: "Opening Round", + matchNumber: llwsMatchNumber("Opening Round", side, local), + participant1Id: p(openingBase + (local - 1) * 2), + participant2Id: p(openingBase + (local - 1) * 2 + 1), + isComplete: false, + isScoring: false, + templateRound: "Opening Round", + seedInfo: `${label} Opening ${local}`, + }); + } + } + + // ── Winners Round 2: bye team at participant1, Opening winner at participant2 ─ + for (const { side, label, byeBase } of sides) { + for (let local = 1; local <= 2; local++) { + matches.push({ + scoringEventId: eventId, + round: "Winners Round 2", + matchNumber: llwsMatchNumber("Winners Round 2", side, local), + participant1Id: p(byeBase + (local - 1)), + participant2Id: null, // Opening Round winner + isComplete: false, + isScoring: false, + templateRound: "Winners Round 2", + seedInfo: `${label} Bye ${local} vs Opening ${local} winner`, + }); + } + } + + // ── Every remaining round starts empty ────────────────────────────────────── + const remaining = template.rounds.filter( + (r) => r.name !== "Opening Round" && r.name !== "Winners Round 2" + ); + for (const round of remaining) { + for (let i = 1; i <= round.matchCount; i++) { + // Championship/Consolation are single shared games; everything else is per-side. + const perSide = round.matchCount > 1; + const label = perSide + ? llwsSideAndLocal(round.name, i).side === 0 + ? "U.S." + : "Intl" + : null; + matches.push({ + scoringEventId: eventId, + round: round.name, + matchNumber: i, + participant1Id: null, + participant2Id: null, + isComplete: false, + isScoring: round.isScoring, + templateRound: round.name, + seedInfo: label ? `${label} ${round.name}` : null, + }); + } + } + + return await createManyPlayoffMatches(matches); +} + +/** + * LLWS advancement: routes the winner forward and, in the winners bracket, routes the + * loser into the elimination bracket rather than eliminating them. + * + * All routing decisions live in resolveLLWSAdvancement; this function only writes. + */ +async function advanceLLWSWinner( + match: PlayoffMatch, + winnerId: string, + loserId: string +): Promise { + const eventId = match.scoringEventId; + const { winner, loser } = resolveLLWSAdvancement(match.round, match.matchNumber); + + // Winner and loser can land in different rounds, so resolve each independently. + const moves: Array<{ destination: LLWSResolvedDestination; participantId: string }> = []; + if (winner) moves.push({ destination: winner, participantId: winnerId }); + if (loser) moves.push({ destination: loser, participantId: loserId }); + + for (const { destination, participantId } of moves) { + const targetMatches = await findPlayoffMatchesByEventIdAndRound( + eventId, + destination.round + ); + const target = targetMatches.find((m) => m.matchNumber === destination.matchNumber); + if (!target) { + throw new Error( + `Next match not found: round=${destination.round}, matchNumber=${destination.matchNumber}` + ); + } + if (target[destination.slot]) { + throw new Error( + `Next match ${destination.slot} is already filled ` + + `(round=${destination.round}, matchNumber=${destination.matchNumber})` + ); + } + await updatePlayoffMatch(target.id, { [destination.slot]: participantId }); + } +} diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 00d19a0..cf6ef02 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -113,6 +113,21 @@ const TEMPLATE_ROUND_CONFIG: Record> // 3rd place game finalizes both positions distinctly. "Third Place Game": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 }, }, + llws_20: { + // Winners Final loser drops to the Elimination Final, so 5th is provisional — + // winning that game lifts them back to a 4th-place floor. + "Winners Final": { loserPosition: 5, loserIsPartial: true, winnerFloor: 4 }, + // Elimination Round 4 losers are the 7th–8th tier (8 teams alive at this point). + "Elimination Round 4": { loserPosition: 7, loserIsPartial: false, winnerFloor: 5 }, + // Elimination Final losers are the 5th–6th tier; the winner reaches the side + // championship, where the worst case is 4th (lose it, then lose the consolation). + "Elimination Final": { loserPosition: 5, loserIsPartial: false, winnerFloor: 4 }, + // Side championship loser still has the consolation game — provisional 4th. + "Bracket Championship": { loserPosition: 4, loserIsPartial: true, winnerFloor: 2 }, + // Consolation finalizes 3rd and 4th distinctly. + "Consolation Third Place": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 }, + "World Championship": { loserPosition: 2, loserIsPartial: false, winnerFloor: null }, + }, tennis_128: { // R16 losers share 9th–16th; winner advances to QF (floor 5th–8th). "Round of 16": { loserPosition: 9, loserIsPartial: false, winnerFloor: 5 }, @@ -126,28 +141,37 @@ const TEMPLATE_ROUND_CONFIG: Record> }; /** - * Returns true if a non-scoring round's winners are entering the first scoring round - * (i.e., they've guaranteed a top-8 fantasy placement and should receive a T5–T8 floor). + * Returns the floor position that winners of a NON-scoring round should bank, or null + * to bank nothing. * - * For multi-round pre-bracket sequences like NCAA (Round of 64 → Round of 32 → - * Sweet Sixteen → Elite Eight), only Sweet Sixteen winners are entering the scoring - * bracket — Round of 64 and Round of 32 winners should not receive any floor yet. + * Default: winners entering the first scoring round have guaranteed a top-8 fantasy + * placement and receive a T5–T8 floor (5); everyone else gets nothing yet. For + * multi-round pre-bracket sequences like NCAA (Round of 64 → Round of 32 → Sweet + * Sixteen → Elite Eight), only Sweet Sixteen winners are entering the scoring bracket. * - * Falls back to true when template/round info is unavailable to preserve legacy behavior. + * A round may override this with `nonScoringWinnerFloor` when the default is wrong — + * in a double-elimination losers bracket a win can guarantee a worse finish than 5th + * (llws_20 "Elimination Round 3" → 7), or nothing at all. + * + * Falls back to 5 when template/round info is unavailable, preserving legacy behavior. */ -function doesNonScoringRoundFeedIntoScoringRound( +function nonScoringWinnerFloorFor( round: string, bracketTemplateId: string | null | undefined -): boolean { - if (!bracketTemplateId) return true; // Legacy: preserve old behavior +): number | null { + if (!bracketTemplateId) return 5; // Legacy: preserve old behavior const template = BRACKET_TEMPLATES[bracketTemplateId]; - if (!template) return true; // Unknown template: preserve old behavior + if (!template) return 5; // Unknown template: preserve old behavior const currentRound = template.rounds.find((r) => r.name === round); - if (!currentRound) return true; // Unknown round: preserve old behavior + if (!currentRound) return 5; // Unknown round: preserve old behavior + // Explicit per-round override wins, including an explicit null (bank nothing). + if (currentRound.nonScoringWinnerFloor !== undefined) { + return currentRound.nonScoringWinnerFloor; + } const nextRoundName = currentRound.feedsInto; - if (!nextRoundName) return false; // No next round (shouldn't happen for non-scoring) + if (!nextRoundName) return null; // No next round (shouldn't happen for non-scoring) const nextRound = template.rounds.find((r) => r.name === nextRoundName); - return nextRound?.isScoring === true; + return nextRound?.isScoring === true ? 5 : null; } /** @@ -281,19 +305,18 @@ export async function processPlayoffEvent( } if (!isScoring) { - // Non-scoring (pre-bracket) round: losers are permanently eliminated (0 pts). - // Winners only bank a provisional T5–T8 floor if they're entering the first - // scoring round (i.e., guaranteed top-8). For multi-round pre-bracket sequences - // like NCAA (R64 → R32 → Sweet 16 → Elite Eight), only Sweet 16 winners should - // receive floor points — R64 and R32 winners are not yet guaranteed top-8. - const awardFloor = doesNonScoringRoundFeedIntoScoringRound(round, event.bracketTemplateId); + // Non-scoring round: losers are permanently eliminated (0 pts) unless they + // advance (double-elimination winners-bracket losers). Winners bank a + // provisional floor only when this round guarantees them one — see + // nonScoringWinnerFloorFor for how that is derived per template. + const winnerFloor = nonScoringWinnerFloorFor(round, event.bracketTemplateId); for (const match of matches) { const loserAdvances = doesLoserAdvance(round, match.matchNumber, event.bracketTemplateId ?? ""); if (match.loserId && !loserAdvances) { await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db); } - if (match.winnerId && awardFloor) { - await upsertParticipantResult(match.winnerId, event.sportsSeasonId, 5, db, true); + if (match.winnerId && winnerFloor !== null) { + await upsertParticipantResult(match.winnerId, event.sportsSeasonId, winnerFloor, db, true); } } } else { @@ -353,7 +376,7 @@ export async function processPlayoffEvent( // Progressive floor scoring: assign guaranteed minimum points to winners. // For Finals (winnerFloor=null) getGuaranteedMinimumPosition returns null — the // winner is already finalized as 1st above. For non-scoring rounds it also - // returns null (winners were given floor 5 inline above). + // returns null; those winners were given their floor inline above. const guaranteedMinimum = getGuaranteedMinimumPosition( round, event.bracketTemplateId, @@ -441,8 +464,9 @@ export async function processMatchResult( if (!loserAdvances) { await upsertParticipantResult(loserId, sportsSeasonId, 0, db); } - if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) { - await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true); + const nonScoringFloor = nonScoringWinnerFloorFor(round, bracketTemplateId); + if (nonScoringFloor !== null) { + await upsertParticipantResult(winnerId, sportsSeasonId, nonScoringFloor, db, true); } // Non-scoring round wins are not surfaced in the Recent Scores feed. } else { diff --git a/app/models/scoring-rules.ts b/app/models/scoring-rules.ts index 9145332..a05ed8f 100644 --- a/app/models/scoring-rules.ts +++ b/app/models/scoring-rules.ts @@ -134,14 +134,21 @@ export function calculateSharedPlacementPoints( * AFL is different: it has TWO distinct tiers in the 5–8 zone: * - T5-T6: Semi-Finals losers (positions 5 and 6) → avg([5,6]) * - T7-T8: Elimination Finals losers (positions 7 and 8) → avg([7,8]) + * + * LLWS has the same shape from its two elimination brackets: + * - T5-T6: Elimination Final losers (one per side) → avg([5,6]) + * - T7-T8: Elimination Round 4 losers (one per side) → avg([7,8]) */ -const SPLIT_5678_TEMPLATE_IDS = new Set(["afl_10"]); +const SPLIT_5678_TEMPLATE_IDS = new Set(["afl_10", "llws_20"]); /** * Brackets with a real 3rd place game, meaning positions 3 and 4 are distinct * (not averaged). Standard brackets average them because both SF losers tie. + * + * llws_20's Consolation Third Place game decides 3rd and 4th head-to-head between + * the two side runners-up. */ -const DISTINCT_34_TEMPLATE_IDS = new Set(["fifa_48"]); +const DISTINCT_34_TEMPLATE_IDS = new Set(["fifa_48", "llws_20"]); /** * Calculate fantasy points for a bracket placement, averaging tied positions. diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx index 5569fe0..154ba39 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx @@ -889,7 +889,7 @@ export default function EventBracket({ return ( // eslint-disable-next-line react/no-array-index-key
-