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/__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 1922d88..a687d32 100644 --- a/app/lib/bracket-templates.ts +++ b/app/lib/bracket-templates.ts @@ -1120,18 +1120,19 @@ export const LLWS_20: BracketTemplate = { }, ], // Region assignments rotate year to year (which region draws the bye changes), so - // these are positional slot labels rather than region names. + // 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: [ - "U.S. Opening 1 — Home", "U.S. Opening 1 — Away", - "U.S. Opening 2 — Home", "U.S. Opening 2 — Away", - "U.S. Opening 3 — Home", "U.S. Opening 3 — Away", - "U.S. Opening 4 — Home", "U.S. Opening 4 — Away", - "U.S. Bye — Winners R2 G1", "U.S. Bye — Winners R2 G2", - "Intl Opening 1 — Home", "Intl Opening 1 — Away", - "Intl Opening 2 — Home", "Intl Opening 2 — Away", - "Intl Opening 3 — Home", "Intl Opening 3 — Away", - "Intl Opening 4 — Home", "Intl Opening 4 — Away", - "Intl Bye — Winners R2 G1", "Intl Bye — Winners R2 G2", + "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: [ { diff --git a/app/models/__tests__/llws-20-bracket.test.ts b/app/models/__tests__/llws-20-bracket.test.ts index 929b99a..d489ac3 100644 --- a/app/models/__tests__/llws-20-bracket.test.ts +++ b/app/models/__tests__/llws-20-bracket.test.ts @@ -17,7 +17,11 @@ import { llwsMatchNumber, llwsSideAndLocal, } from "~/lib/bracket-templates"; -import { generateBracketFromTemplate, resolveLLWSAdvancement } from "../playoff-match"; +import { + doesLoserAdvance, + generateBracketFromTemplate, + resolveLLWSAdvancement, +} from "../playoff-match"; import { calculateBracketPoints, calculateAveragedPoints, @@ -489,6 +493,30 @@ describe("LLWS 20 Bracket Template", () => { 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 }); diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index aa396d8..cf6ef02 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -305,11 +305,10 @@ 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. + // 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 ?? ""); @@ -377,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, 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
-