Fix final rankings for double-elimination brackets
Review of the llws_20 bracket turned up two display bugs in the shared ranking code, both triggered by the template setting loserFeedsInto on every winners-bracket round. findConsolationRound took the FIRST round with loserFeedsInto and treated its target as a third-place game. That is sound for fifa_48, whose only such round is the Semifinals, but llws_20 uses loserFeedsInto to route losers into the elimination bracket — so "Elimination Round 1" was being read as the consolation game. Its four matches were then placed as exact positions inside the Opening Round tier, corrupting the final rankings list. A consolation round now has to be terminal: its winner plays no further game, which is precisely what lets its result split two exact positions. The rank walk also advanced by a round's match count on the assumption that every match places its loser. That holds in single elimination and must be kept for undecided rounds — four semifinalists occupy positions 1-4 whether or not the games have been played — but a winners-bracket loss places nobody, since the loser drops into the elimination bracket and is ranked by whatever knocks them out later. Those rounds consumed positions they never filled, so a 20-team bracket ranked its last teams T23. Rounds whose losers are placed later now consume nothing, leaving LLWS at 1, T2, 3, 4, T5, T7, T9, T13, T17 for the full 20-team field. Single-elimination templates are unaffected; afl_10's sub-8th ranks were inflated the same way and are now correct too. Also from review: the llws_20 participant labels were 21-24 characters in an 80px fixed-width admin column and would have wrapped to three lines across all 20 rows. Shortened them and widened the column. Tests play a full 20-team tournament through the real advancement map and assert the resulting rank labels. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
This commit is contained in:
parent
1bd23a4419
commit
37af2f1fc3
6 changed files with 231 additions and 22 deletions
|
|
@ -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<string> {
|
||||
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<string, Match[]>,
|
||||
consolation: ConsolationRound | undefined,
|
||||
ownershipMap: Map<string, TeamOwnership>
|
||||
ownershipMap: Map<string, TeamOwnership>,
|
||||
/** See roundsWithLosersPlacedLater. Empty for single-elimination brackets. */
|
||||
losersPlacedLater: Set<string> = 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));
|
||||
|
|
|
|||
|
|
@ -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<typeof computeRankedEntries>, 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<string, Map<number, [string | null, string | null]>>();
|
||||
for (const round of template.rounds) {
|
||||
const byNumber = new Map<number, [string | null, string | null]>();
|
||||
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", () => {
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -889,7 +889,7 @@ export default function EventBracket({
|
|||
return (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Label className="w-20 text-sm text-muted-foreground shrink-0">
|
||||
<Label className="w-28 text-sm text-muted-foreground shrink-0">
|
||||
{slotLabel}
|
||||
</Label>
|
||||
<div className="flex-1 min-w-0">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue