The Little League Baseball World Series runs 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 side runners-up. 38 games in all. No existing template could express it: every one is single elimination, at most with a bolted-on third-place game. Adds the llws_20 template plus dedicated generation and advancement, following the same bespoke-routing pattern afl_10 and nba_20 use rather than the generic ceil(matchNumber / 2) advancement. The core of the change is loser routing. In the winners bracket a loss is not an elimination — it drops the team into the elimination bracket at a specific slot, including the deliberate cross-overs the official bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination Round 3 pairs each semifinal loser with the winner from the opposite half). In the elimination bracket a loss is final. Matching the official modified double-elimination format, there is no "if necessary" game: the winners-bracket champion is eliminated if it loses the side championship, dropping to the consolation game. Rounds are shared across both sides, U.S. taking the low match numbers and International the high ones, so the scoring config stays one entry per stage. The existing phases/groups display machinery splits them back apart into United States / International / Championship tabs. Scoring lands on exactly 8 point-earning teams, which is the field size when Elimination Round 4 begins: the two finals decide 1st–4th, Elimination Final losers take 5th–6th, and Elimination Round 4 losers 7th–8th. 3rd and 4th are distinct because the consolation game is real, and 5–8 splits into two two-team tiers so surviving Elimination Round 4 is worth more than losing it. Also: - Adds an optional nonScoringWinnerFloor to BracketRound. The engine hardcoded a 5th-place floor for winners of non-scoring rounds feeding a scoring one, which is wrong inside a losers bracket where a win can guarantee only 7th. Opt-in, so no existing template changes behavior. - Fixes TabbedBracketLayout's mobile path, which built its match map unfiltered and so would have merged U.S. and International games into one column. No-op for NCAA and NBA, whose groups already cover every match in their phases. - Rewrites the LLWS Monte Carlo simulator, which still modelled the retired pool-play format (5 teams per pool, then a 4-team bracket per side) and no longer described the tournament being scored. It now runs the real 10-team double elimination and splits the 5–8 probabilities into the correct tiers instead of one even four-way split. Legacy "US:A"/"Intl:B" externalIds are still accepted, read as the side alone, so seasons configured for the old format keep loading. Tests replay all 38 games through the pure advancement resolver and assert each one against the feed labels printed on the official bracket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
561 lines
22 KiB
TypeScript
561 lines
22 KiB
TypeScript
/**
|
||
* 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<n>` 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 { 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<string, unknown>[] = [];
|
||
vi.mock("~/database/context", () => ({
|
||
database: () => ({
|
||
insert: () => ({
|
||
values: (rows: Record<string, unknown>[]) => ({
|
||
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<number, { round: string; matchNumber: number }> = {
|
||
// 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<string, number>(
|
||
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<T>(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<number, [string | null, string | null]> = {
|
||
// 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<string, number>();
|
||
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<number, [string | null, string | null]> = {};
|
||
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("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);
|
||
});
|
||
});
|
||
});
|