Add LLWS 20-team double-elimination bracket
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
This commit is contained in:
parent
b85f387c79
commit
1bd23a4419
10 changed files with 1429 additions and 234 deletions
|
|
@ -152,7 +152,22 @@ export function TabbedBracketLayout({
|
||||||
const simpleRounds = phase.groups ? [] : (phase.rounds ?? []).filter((r) => rounds.includes(r));
|
const simpleRounds = phase.groups ? [] : (phase.rounds ?? []).filter((r) => rounds.includes(r));
|
||||||
|
|
||||||
const phaseRounds = phase.groups ? [...groupRounds, ...sharedRounds] : simpleRounds;
|
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 sharedMatchesByRound = new Map(sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
|
||||||
|
|
||||||
const phaseFirstScoringIdx = phaseRounds.findIndex((r) => rounds.indexOf(r) >= scoringRoundIdx);
|
const phaseFirstScoringIdx = phaseRounds.findIndex((r) => rounds.indexOf(r) >= scoringRoundIdx);
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,18 @@ export interface BracketRound {
|
||||||
* When set, the loser of each match in this round is placed into the target round.
|
* When set, the loser of each match in this round is placed into the target round.
|
||||||
*/
|
*/
|
||||||
loserFeedsInto?: string | null;
|
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 {
|
export interface GroupStageConfig {
|
||||||
|
|
@ -934,6 +946,257 @@ 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.
|
||||||
|
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",
|
||||||
|
],
|
||||||
|
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
|
* All available bracket templates
|
||||||
*/
|
*/
|
||||||
|
|
@ -951,6 +1214,7 @@ export const BRACKET_TEMPLATES: Record<string, BracketTemplate> = {
|
||||||
tennis_128: TENNIS_128,
|
tennis_128: TENNIS_128,
|
||||||
cfp_12: CFP_12,
|
cfp_12: CFP_12,
|
||||||
nba_20: NBA_20,
|
nba_20: NBA_20,
|
||||||
|
llws_20: LLWS_20,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1006,6 +1270,18 @@ export function getScoringRoundType(
|
||||||
const round = template.rounds.find((r) => r.name === roundName);
|
const round = template.rounds.find((r) => r.name === roundName);
|
||||||
if (!round || !round.isScoring) return null;
|
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
|
// Special handling for AFL finals
|
||||||
if (template.id === "afl_10") {
|
if (template.id === "afl_10") {
|
||||||
if (roundName === "Elimination Finals") return "quarterfinals"; // Losers share 7-8th
|
if (roundName === "Elimination Finals") return "quarterfinals"; // Losers share 7-8th
|
||||||
|
|
|
||||||
561
app/models/__tests__/llws-20-bracket.test.ts
Normal file
561
app/models/__tests__/llws-20-bracket.test.ts
Normal file
|
|
@ -0,0 +1,561 @@
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,6 +6,8 @@ import {
|
||||||
getBracketTemplate,
|
getBracketTemplate,
|
||||||
buildNCAA68SlotMap,
|
buildNCAA68SlotMap,
|
||||||
matchIndexForSeedSlot,
|
matchIndexForSeedSlot,
|
||||||
|
llwsMatchNumber,
|
||||||
|
llwsSideAndLocal,
|
||||||
STANDARD_BRACKET_SEEDING,
|
STANDARD_BRACKET_SEEDING,
|
||||||
} from "~/lib/bracket-templates";
|
} from "~/lib/bracket-templates";
|
||||||
|
|
||||||
|
|
@ -467,6 +469,11 @@ export async function generateBracketFromTemplate(
|
||||||
return await generateNBA20Bracket(eventId, template, participantIds);
|
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[] = [];
|
const matches: NewPlayoffMatch[] = [];
|
||||||
|
|
||||||
// Generate matches for each round in the template
|
// Generate matches for each round in the template
|
||||||
|
|
@ -980,6 +987,15 @@ export async function advanceWinnerTemplate(
|
||||||
return await advanceNBAPlayInWinner(match, winnerId, loserId);
|
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
|
// Special handling for AFL 10 double-chance system
|
||||||
// Phase 3.3: AFL has complex winner/loser advancement rules
|
// Phase 3.3: AFL has complex winner/loser advancement rules
|
||||||
if (template.id === "afl_10") {
|
if (template.id === "afl_10") {
|
||||||
|
|
@ -1427,6 +1443,12 @@ export function doesLoserAdvance(
|
||||||
if (templateId === "afl_10" && round === "Qualifying Finals") {
|
if (templateId === "afl_10" && round === "Qualifying Finals") {
|
||||||
return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1538,3 +1560,320 @@ async function advanceNBAPlayInWinner(
|
||||||
throw new Error(`Unknown Play-In Round 2 match number: ${match.matchNumber}`);
|
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<number, { winner: LLWSDestination | null; loser: LLWSDestination | null }>
|
||||||
|
> = {
|
||||||
|
"Opening Round": {
|
||||||
|
1: {
|
||||||
|
winner: { round: "Winners Round 2", localMatch: 1, slot: "participant2Id" },
|
||||||
|
loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant1Id" },
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
winner: { round: "Winners Round 2", localMatch: 2, slot: "participant2Id" },
|
||||||
|
loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant1Id" },
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant1Id" },
|
||||||
|
loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant2Id" },
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant2Id" },
|
||||||
|
loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant2Id" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Winners Round 2": {
|
||||||
|
1: {
|
||||||
|
winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant2Id" },
|
||||||
|
loser: { round: "Elimination Round 2", localMatch: 1, slot: "participant1Id" },
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant1Id" },
|
||||||
|
loser: { round: "Elimination Round 2", localMatch: 2, slot: "participant1Id" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Winners Semifinals": {
|
||||||
|
1: {
|
||||||
|
winner: { round: "Winners Final", localMatch: 1, slot: "participant1Id" },
|
||||||
|
loser: { round: "Elimination Round 3", localMatch: 1, slot: "participant1Id" },
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
winner: { round: "Winners Final", localMatch: 1, slot: "participant2Id" },
|
||||||
|
loser: { round: "Elimination Round 3", localMatch: 2, slot: "participant1Id" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Winners Final": {
|
||||||
|
1: {
|
||||||
|
winner: { round: "Bracket Championship", localMatch: 1, slot: "participant1Id" },
|
||||||
|
// A winners-bracket final loss is not an elimination — it drops to the
|
||||||
|
// Elimination Final for a second chance at the side championship.
|
||||||
|
loser: { round: "Elimination Final", localMatch: 1, slot: "participant1Id" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Elimination Round 1": {
|
||||||
|
1: {
|
||||||
|
winner: { round: "Elimination Round 2", localMatch: 1, slot: "participant2Id" },
|
||||||
|
loser: null,
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
winner: { round: "Elimination Round 2", localMatch: 2, slot: "participant2Id" },
|
||||||
|
loser: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Elimination Round 2": {
|
||||||
|
// Cross-over: R2 m1's winner meets the OTHER semifinal loser.
|
||||||
|
1: {
|
||||||
|
winner: { round: "Elimination Round 3", localMatch: 2, slot: "participant2Id" },
|
||||||
|
loser: null,
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
winner: { round: "Elimination Round 3", localMatch: 1, slot: "participant2Id" },
|
||||||
|
loser: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Elimination Round 3": {
|
||||||
|
// The later game (m2) is printed on top: G32 = W28 v W26, G31 = W27 v W25.
|
||||||
|
1: {
|
||||||
|
winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant2Id" },
|
||||||
|
loser: null,
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant1Id" },
|
||||||
|
loser: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Elimination Round 4": {
|
||||||
|
1: {
|
||||||
|
winner: { round: "Elimination Final", localMatch: 1, slot: "participant2Id" },
|
||||||
|
loser: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Elimination Final": {
|
||||||
|
1: {
|
||||||
|
winner: { round: "Bracket Championship", localMatch: 1, slot: "participant2Id" },
|
||||||
|
loser: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Rounds whose losers drop into the elimination bracket instead of going out. */
|
||||||
|
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<PlayoffMatch[]> {
|
||||||
|
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<void> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,21 @@ const TEMPLATE_ROUND_CONFIG: Record<string, Record<string, RoundScoringConfig>>
|
||||||
// 3rd place game finalizes both positions distinctly.
|
// 3rd place game finalizes both positions distinctly.
|
||||||
"Third Place Game": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 },
|
"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: {
|
tennis_128: {
|
||||||
// R16 losers share 9th–16th; winner advances to QF (floor 5th–8th).
|
// R16 losers share 9th–16th; winner advances to QF (floor 5th–8th).
|
||||||
"Round of 16": { loserPosition: 9, loserIsPartial: false, winnerFloor: 5 },
|
"Round of 16": { loserPosition: 9, loserIsPartial: false, winnerFloor: 5 },
|
||||||
|
|
@ -126,28 +141,37 @@ const TEMPLATE_ROUND_CONFIG: Record<string, Record<string, RoundScoringConfig>>
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if a non-scoring round's winners are entering the first scoring round
|
* Returns the floor position that winners of a NON-scoring round should bank, or null
|
||||||
* (i.e., they've guaranteed a top-8 fantasy placement and should receive a T5–T8 floor).
|
* to bank nothing.
|
||||||
*
|
*
|
||||||
* For multi-round pre-bracket sequences like NCAA (Round of 64 → Round of 32 →
|
* Default: winners entering the first scoring round have guaranteed a top-8 fantasy
|
||||||
* Sweet Sixteen → Elite Eight), only Sweet Sixteen winners are entering the scoring
|
* placement and receive a T5–T8 floor (5); everyone else gets nothing yet. For
|
||||||
* bracket — Round of 64 and Round of 32 winners should not receive any floor yet.
|
* 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,
|
round: string,
|
||||||
bracketTemplateId: string | null | undefined
|
bracketTemplateId: string | null | undefined
|
||||||
): boolean {
|
): number | null {
|
||||||
if (!bracketTemplateId) return true; // Legacy: preserve old behavior
|
if (!bracketTemplateId) return 5; // Legacy: preserve old behavior
|
||||||
const template = BRACKET_TEMPLATES[bracketTemplateId];
|
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);
|
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;
|
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);
|
const nextRound = template.rounds.find((r) => r.name === nextRoundName);
|
||||||
return nextRound?.isScoring === true;
|
return nextRound?.isScoring === true ? 5 : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -286,14 +310,14 @@ export async function processPlayoffEvent(
|
||||||
// scoring round (i.e., guaranteed top-8). For multi-round pre-bracket sequences
|
// 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
|
// 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.
|
// receive floor points — R64 and R32 winners are not yet guaranteed top-8.
|
||||||
const awardFloor = doesNonScoringRoundFeedIntoScoringRound(round, event.bracketTemplateId);
|
const winnerFloor = nonScoringWinnerFloorFor(round, event.bracketTemplateId);
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
const loserAdvances = doesLoserAdvance(round, match.matchNumber, event.bracketTemplateId ?? "");
|
const loserAdvances = doesLoserAdvance(round, match.matchNumber, event.bracketTemplateId ?? "");
|
||||||
if (match.loserId && !loserAdvances) {
|
if (match.loserId && !loserAdvances) {
|
||||||
await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db);
|
await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db);
|
||||||
}
|
}
|
||||||
if (match.winnerId && awardFloor) {
|
if (match.winnerId && winnerFloor !== null) {
|
||||||
await upsertParticipantResult(match.winnerId, event.sportsSeasonId, 5, db, true);
|
await upsertParticipantResult(match.winnerId, event.sportsSeasonId, winnerFloor, db, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -441,8 +465,9 @@ export async function processMatchResult(
|
||||||
if (!loserAdvances) {
|
if (!loserAdvances) {
|
||||||
await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
|
await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
|
||||||
}
|
}
|
||||||
if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) {
|
const nonScoringFloor = nonScoringWinnerFloorFor(round, bracketTemplateId);
|
||||||
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
|
if (nonScoringFloor !== null) {
|
||||||
|
await upsertParticipantResult(winnerId, sportsSeasonId, nonScoringFloor, db, true);
|
||||||
}
|
}
|
||||||
// Non-scoring round wins are not surfaced in the Recent Scores feed.
|
// Non-scoring round wins are not surfaced in the Recent Scores feed.
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -134,14 +134,21 @@ export function calculateSharedPlacementPoints(
|
||||||
* AFL is different: it has TWO distinct tiers in the 5–8 zone:
|
* 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])
|
* - T5-T6: Semi-Finals losers (positions 5 and 6) → avg([5,6])
|
||||||
* - T7-T8: Elimination Finals losers (positions 7 and 8) → avg([7,8])
|
* - 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
|
* 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.
|
* (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.
|
* Calculate fantasy points for a bracket placement, averaging tied positions.
|
||||||
|
|
|
||||||
|
|
@ -126,22 +126,48 @@ describe("LLWSSimulator", () => {
|
||||||
expect(total).toBeCloseTo(1.0, 1);
|
expect(total).toBeCloseTo(1.0, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sum of probFifth across participants equals ~1.0 (4 bracket losers, split evenly)", async () => {
|
it("probFifth sums to ~1.0 (2 Elimination Final losers per sim, split over 5th/6th)", async () => {
|
||||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
||||||
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
||||||
// 4 bracket losers per sim, each assigned bracketLoser/(4*N) → sum = 1.0
|
|
||||||
const total = results.reduce((s, r) => s + r.probabilities.probFifth, 0);
|
const total = results.reduce((s, r) => s + r.probabilities.probFifth, 0);
|
||||||
expect(total).toBeCloseTo(1.0, 1);
|
expect(total).toBeCloseTo(1.0, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("probFifth through probEighth are equal for every participant (even bracket-loser split)", async () => {
|
it("probSeventh sums to ~1.0 (2 Elimination Round 4 losers per sim, split over 7th/8th)", async () => {
|
||||||
|
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
||||||
|
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
||||||
|
const total = results.reduce((s, r) => s + r.probabilities.probSeventh, 0);
|
||||||
|
expect(total).toBeCloseTo(1.0, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ties 5th with 6th and 7th with 8th, but keeps the two tiers separate", async () => {
|
||||||
|
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS, { includeOdds: true }));
|
||||||
|
const results = await new LLWSSimulator(2_000).simulate("season-1");
|
||||||
|
for (const r of results) {
|
||||||
|
const p = r.probabilities;
|
||||||
|
// Within a tier the two positions are tied.
|
||||||
|
expect(p.probFifth).toBeCloseTo(p.probSixth, 10);
|
||||||
|
expect(p.probSeventh).toBeCloseTo(p.probEighth, 10);
|
||||||
|
}
|
||||||
|
// The tiers are distinct outcomes (losing the Elimination Final vs losing
|
||||||
|
// Elimination Round 4), so they must not be forced equal across the field.
|
||||||
|
const differs = results.some(
|
||||||
|
(r) => Math.abs(r.probabilities.probFifth - r.probabilities.probSeventh) > 1e-9
|
||||||
|
);
|
||||||
|
expect(differs).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives every team a total placement probability of at most 1", async () => {
|
||||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
||||||
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
const p = r.probabilities;
|
const p = r.probabilities;
|
||||||
expect(p.probFifth).toBeCloseTo(p.probSixth, 10);
|
// Each sim assigns a team at most one placement, so summing the distinct
|
||||||
expect(p.probSixth).toBeCloseTo(p.probSeventh, 10);
|
// tiers (5th/6th and 7th/8th each count once) cannot exceed 1.
|
||||||
expect(p.probSeventh).toBeCloseTo(p.probEighth, 10);
|
const total =
|
||||||
|
p.probFirst + p.probSecond + p.probThird + p.probFourth +
|
||||||
|
p.probFifth * 2 + p.probSeventh * 2;
|
||||||
|
expect(total).toBeLessThanOrEqual(1 + 1e-9);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -179,10 +205,13 @@ describe("LLWSSimulator", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Pool assignment modes ─────────────────────────────────────────────────
|
// ── Legacy externalId formats ─────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The tournament no longer has pool play, but seasons configured for the old
|
||||||
|
// format still carry pool suffixes. Those must keep loading, read as the side alone.
|
||||||
|
|
||||||
describe("pool assignment modes", () => {
|
describe("legacy pool-suffix externalIds", () => {
|
||||||
it("fixed pools (US:A / US:B / Intl:A / Intl:B) produce valid results", async () => {
|
it("accepts US:A / US:B / Intl:A / Intl:B, ignoring the pool part", async () => {
|
||||||
setupMockDb(defaultParticipants("fixed"), makeEvRows(ALL_IDS));
|
setupMockDb(defaultParticipants("fixed"), makeEvRows(ALL_IDS));
|
||||||
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
||||||
expect(results).toHaveLength(20);
|
expect(results).toHaveLength(20);
|
||||||
|
|
@ -190,10 +219,10 @@ describe("LLWSSimulator", () => {
|
||||||
expect(total).toBeCloseTo(1.0, 1);
|
expect(total).toBeCloseTo(1.0, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("mixed mode: US fixed pools, Intl randomized", async () => {
|
it("accepts a mix of suffixed and bare side ids", async () => {
|
||||||
const participants = [
|
const participants = [
|
||||||
...US_IDS.slice(0, 5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
|
...US_IDS.slice(0, 5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
|
||||||
...US_IDS.slice(5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:B" })),
|
...US_IDS.slice(5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US" })),
|
||||||
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
||||||
];
|
];
|
||||||
setupMockDb(participants, makeEvRows(ALL_IDS));
|
setupMockDb(participants, makeEvRows(ALL_IDS));
|
||||||
|
|
@ -202,6 +231,17 @@ describe("LLWSSimulator", () => {
|
||||||
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||||
expect(total).toBeCloseTo(1.0, 1);
|
expect(total).toBeCloseTo(1.0, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts an uneven suffix split (pools no longer constrain anything)", async () => {
|
||||||
|
const participants = [
|
||||||
|
...US_IDS.slice(0, 6).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
|
||||||
|
...US_IDS.slice(6).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:B" })),
|
||||||
|
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
||||||
|
];
|
||||||
|
setupMockDb(participants, makeEvRows(ALL_IDS));
|
||||||
|
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
||||||
|
expect(results).toHaveLength(20);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Error cases ───────────────────────────────────────────────────────────
|
// ── Error cases ───────────────────────────────────────────────────────────
|
||||||
|
|
@ -262,26 +302,13 @@ describe("LLWSSimulator", () => {
|
||||||
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/10 US teams/);
|
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/10 US teams/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws when fixed pools have unequal A/B split", async () => {
|
it("throws when International team count is not 10", async () => {
|
||||||
const participants = [
|
const participants = [
|
||||||
// 6 in Pool A, 4 in Pool B
|
...Array.from({ length: 9 }, (_, i) => ({ id: `us-${i + 1}`, name: `US Team ${i + 1}`, externalId: "US" })),
|
||||||
...US_IDS.slice(0, 6).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
|
...Array.from({ length: 11 }, (_, i) => ({ id: `intl-${i + 1}`, name: `Team ${i + 1}`, externalId: "Intl" })),
|
||||||
...US_IDS.slice(6).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:B" })),
|
|
||||||
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
|
||||||
];
|
];
|
||||||
setupMockDb(participants, makeEvRows(ALL_IDS));
|
setupMockDb(participants, makeEvRows(ALL_IDS));
|
||||||
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/exactly 5 teams each/);
|
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/10 US teams/);
|
||||||
});
|
|
||||||
|
|
||||||
it("throws when US externalIds mix pool suffixes and bare side", async () => {
|
|
||||||
const participants = [
|
|
||||||
// Some US:A, some "US" (no pool suffix) → mixed
|
|
||||||
...US_IDS.slice(0, 5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
|
|
||||||
...US_IDS.slice(5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US" })), // no pool
|
|
||||||
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
|
||||||
];
|
|
||||||
setupMockDb(participants, makeEvRows(ALL_IDS));
|
|
||||||
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/mixed externalId formats/);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
/**
|
/**
|
||||||
* Little League World Series (LLWS) Bracket Simulator
|
* Little League World Series (LLWS) Bracket Simulator
|
||||||
*
|
*
|
||||||
* Monte Carlo simulation of the LLWS (20-team format, 2022–present).
|
* Monte Carlo simulation of the LLWS (20-team double-elimination format, 2025+).
|
||||||
|
*
|
||||||
|
* The tournament is two independent 10-team double-elimination brackets — United
|
||||||
|
* States and International — each producing a side champion, then a World
|
||||||
|
* Championship game and a Consolation game between the side runners-up. There is no
|
||||||
|
* pool play. This mirrors the llws_20 bracket template so simulated placements line
|
||||||
|
* up with the bracket admins actually score.
|
||||||
*
|
*
|
||||||
* Algorithm:
|
* Algorithm:
|
||||||
* 1. Load all 20 participants for the sports season from DB
|
* 1. Load all 20 participants for the sports season from DB
|
||||||
|
|
@ -10,48 +16,36 @@
|
||||||
* (entered via Admin → Futures Odds; American format)
|
* (entered via Admin → Futures Odds; American format)
|
||||||
* 3. Convert odds to normalized championship probabilities (vig removed).
|
* 3. Convert odds to normalized championship probabilities (vig removed).
|
||||||
* These drive per-game win probability: p1 / (p1 + p2). Falls back to 50/50.
|
* These drive per-game win probability: p1 / (p1 + p2). Falls back to 50/50.
|
||||||
* 4. Determine pool assignment mode from externalId:
|
* 4. Per simulation:
|
||||||
* - Fixed pools: externalId is "US:A", "US:B", "Intl:A", or "Intl:B"
|
* a. Shuffle each side's 10 teams into the 10 bracket slots (8 opening-round
|
||||||
* → use these exact pool assignments every simulation.
|
* teams + 2 byes). The draw is modelled as random — a specific known draw
|
||||||
* - Randomized pools: externalId is "US" or "Intl" only
|
* is not yet expressible in participant config.
|
||||||
* → randomly shuffle each side into Pool A / Pool B each simulation.
|
* b. Simulate the 10-team double-elimination bracket for each side
|
||||||
* 5. Per simulation:
|
* (see simulateSideBracket for the exact game-by-game structure)
|
||||||
* a. Assign pools (fixed or random)
|
* c. Consolation game: US side loser vs Intl side loser → 3rd / 4th
|
||||||
* b. Simulate pool play round-robin within each pool (10 games/pool)
|
* d. World Championship: US champion vs Intl champion → 1st / 2nd
|
||||||
* Top 2 by W-L record advance. Ties broken randomly.
|
* 5. Track placement counts across all simulations.
|
||||||
* c. Simulate 4-team double-elimination bracket per side:
|
* 6. Convert counts to probability distributions.
|
||||||
* G1: A1 vs B2 (WB)
|
|
||||||
* G2: B1 vs A2 (WB)
|
|
||||||
* G3: G1W vs G2W (WB Final)
|
|
||||||
* G4: G1L vs G2L (LB R1 — loser eliminated)
|
|
||||||
* G5: G3L vs G4W (LB Final — loser eliminated)
|
|
||||||
* G6: G3W vs G5W (Side Championship — loser eliminated)
|
|
||||||
* d. Consolation game: US loser vs Intl loser → 3rd / 4th
|
|
||||||
* e. World Series: US champion vs Intl champion → 1st / 2nd
|
|
||||||
* 6. Track placement counts across all simulations.
|
|
||||||
* 7. Convert counts to probability distributions.
|
|
||||||
*
|
*
|
||||||
* Pool assignment (externalId format):
|
* Side assignment (externalId): "US" or "Intl". The legacy pool suffixes
|
||||||
* "US:A" / "US:B" / "Intl:A" / "Intl:B" → fixed pools (post-draw mode)
|
* ("US:A", "US:B", "Intl:A", "Intl:B") are still accepted and read as the side
|
||||||
* "US" / "Intl" → randomized pools (pre-draw mode)
|
* alone, so seasons configured for the old pool-play format keep working — pools
|
||||||
* Mixed: if ANY US or Intl team has a pool suffix, ALL teams on that side must
|
* no longer exist, so the suffix has no effect.
|
||||||
* have one (throws otherwise). Sides can differ — US fixed while Intl randomized.
|
|
||||||
*
|
*
|
||||||
* Placement tiers → SimulationProbabilities mapping:
|
* Placement tiers → SimulationProbabilities mapping (matches llws_20's scoring):
|
||||||
* probFirst = World Series Champion (1 per sim)
|
* probFirst = World Championship winner (1 per sim)
|
||||||
* probSecond = World Series Runner-up (1 per sim)
|
* probSecond = World Championship loser (1 per sim)
|
||||||
* probThird = Consolation game winner / 3rd place (1 per sim)
|
* probThird = Consolation winner (1 per sim)
|
||||||
* probFourth = Consolation game loser / 4th place (1 per sim)
|
* probFourth = Consolation loser (1 per sim)
|
||||||
* probFifth–probEighth = Double-elim bracket losers before side championships
|
* probFifth/probSixth = Elimination Final losers (2 per sim — 1 per side)
|
||||||
* (4 per sim — split evenly: 2 US + 2 Intl)
|
* probSeventh/probEighth = Elimination Round 4 losers (2 per sim — 1 per side)
|
||||||
* Pool play losers → all 0 (12 teams, did not advance from pool play)
|
* Everyone else → all 0 (12 teams out in Elimination Rounds 1–3)
|
||||||
*
|
*
|
||||||
* Admin setup:
|
* Admin setup:
|
||||||
* 1. Create a Sport with simulatorType = "llws_bracket"
|
* 1. Create a Sport with simulatorType = "llws_bracket"
|
||||||
* 2. Create a Sports Season and add exactly 20 participants (10 US, 10 International)
|
* 2. Create a Sports Season and add exactly 20 participants (10 US, 10 International)
|
||||||
* 3. Set externalId on each participant via Admin → Manage Participants (optional if names follow the convention):
|
* 3. Set externalId on each participant via Admin → Manage Participants to "US" or
|
||||||
* Pre-draw: "US" or "Intl" (or leave null — names starting with "US " infer US, all others infer Intl)
|
* "Intl" (optional — names starting with "US " infer US, all others infer Intl)
|
||||||
* Post-draw: "US:A", "US:B", "Intl:A", or "Intl:B"
|
|
||||||
* 4. Enter championship futures odds via Admin → Futures Odds (sourceOdds)
|
* 4. Enter championship futures odds via Admin → Futures Odds (sourceOdds)
|
||||||
* 5. Run simulation via Admin → Simulate
|
* 5. Run simulation via Admin → Simulate
|
||||||
*/
|
*/
|
||||||
|
|
@ -68,7 +62,6 @@ import { positiveConfigNumber } from "./config-access";
|
||||||
const NUM_SIMULATIONS = 50_000;
|
const NUM_SIMULATIONS = 50_000;
|
||||||
const US_TEAM_COUNT = 10;
|
const US_TEAM_COUNT = 10;
|
||||||
const INTL_TEAM_COUNT = 10;
|
const INTL_TEAM_COUNT = 10;
|
||||||
const POOL_SIZE = 5; // teams per pool within each side
|
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -77,8 +70,6 @@ type Side = "US" | "Intl";
|
||||||
interface Team {
|
interface Team {
|
||||||
participantId: string;
|
participantId: string;
|
||||||
side: Side;
|
side: Side;
|
||||||
/** Explicit pool ("A" or "B") if set in externalId; null if randomized. */
|
|
||||||
fixedPool: "A" | "B" | null;
|
|
||||||
/** Normalized championship win probability (0–1, vig removed). */
|
/** Normalized championship win probability (0–1, vig removed). */
|
||||||
oddsProb: number;
|
oddsProb: number;
|
||||||
}
|
}
|
||||||
|
|
@ -88,11 +79,21 @@ interface PlacementCounts {
|
||||||
finalist: number;
|
finalist: number;
|
||||||
thirdPlace: number;
|
thirdPlace: number;
|
||||||
fourthPlace: number;
|
fourthPlace: number;
|
||||||
bracketLoser: number;
|
/** Lost the Elimination Final — the 5th–6th tier (1 per side per sim). */
|
||||||
|
elimFinalLoser: number;
|
||||||
|
/** Lost Elimination Round 4 — the 7th–8th tier (1 per side per sim). */
|
||||||
|
elimRound4Loser: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function zeroCounts(): PlacementCounts {
|
||||||
|
return {
|
||||||
|
champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0,
|
||||||
|
elimFinalLoser: 0, elimRound4Loser: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function simGame(t1: Team, t2: Team): { winner: Team; loser: Team } {
|
function simGame(t1: Team, t2: Team): { winner: Team; loser: Team } {
|
||||||
// If either team has no odds entered, treat the game as a coin flip.
|
// If either team has no odds entered, treat the game as a coin flip.
|
||||||
// The 50/50 fallback must cover the one-sided case (one team known, one not)
|
// The 50/50 fallback must cover the one-sided case (one team known, one not)
|
||||||
|
|
@ -118,101 +119,89 @@ function shuffle<T>(arr: T[]): T[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assign teams to Pool A / Pool B for one side.
|
* Simulate one side's 10-team double-elimination bracket.
|
||||||
* In fixed mode, respects the pre-set pool. In randomized mode, shuffles then splits.
|
|
||||||
*/
|
|
||||||
function assignPools(teams: Team[], randomized: boolean): [Team[], Team[]] {
|
|
||||||
if (!randomized) {
|
|
||||||
return [teams.filter((t) => t.fixedPool === "A"), teams.filter((t) => t.fixedPool === "B")];
|
|
||||||
}
|
|
||||||
const shuffled = shuffle([...teams]);
|
|
||||||
return [shuffled.slice(0, 5), shuffled.slice(5)];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simulate round-robin pool play among 5 teams.
|
|
||||||
* Returns the top 2 teams by win count (ties broken randomly).
|
|
||||||
*/
|
|
||||||
function simulatePoolPlay(pool: Team[]): [Team, Team] {
|
|
||||||
const wins = new Map<string, number>(pool.map((t) => [t.participantId, 0]));
|
|
||||||
|
|
||||||
// Each pair plays once.
|
|
||||||
for (let i = 0; i < pool.length; i++) {
|
|
||||||
for (let j = i + 1; j < pool.length; j++) {
|
|
||||||
const { winner } = simGame(pool[i], pool[j]);
|
|
||||||
wins.set(winner.participantId, (wins.get(winner.participantId) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by wins descending; pre-generate a stable random tiebreaker per team so
|
|
||||||
// the comparator is consistent (Math.random() inside a comparator is a bug — the
|
|
||||||
// engine may call it multiple times per pair and get contradictory results).
|
|
||||||
const tiebreaker = new Map(pool.map((t) => [t.participantId, Math.random()]));
|
|
||||||
const ranked = pool.toSorted((a, b) => {
|
|
||||||
const diff = (wins.get(b.participantId) ?? 0) - (wins.get(a.participantId) ?? 0);
|
|
||||||
return diff !== 0 ? diff : (tiebreaker.get(a.participantId) ?? 0) - (tiebreaker.get(b.participantId) ?? 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
return [ranked[0], ranked[1]];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simulate a 4-team double-elimination bracket for one side.
|
|
||||||
*
|
*
|
||||||
* Seeds (pool results):
|
* `slots` holds the side's teams in bracket order, matching the llws_20 participant
|
||||||
* poolA1 = Pool A winner, poolA2 = Pool A runner-up
|
* layout: slots[0..7] are the four opening-round games (two teams each) and
|
||||||
* poolB1 = Pool B winner, poolB2 = Pool B runner-up
|
* slots[8], slots[9] are the two bye teams entering Winners Round 2.
|
||||||
*
|
*
|
||||||
* Bracket:
|
* Structure (side-local, mirroring LLWS_ADVANCEMENT in models/playoff-match):
|
||||||
* G1 (WB): A1 vs B2
|
* Winners bracket
|
||||||
* G2 (WB): B1 vs A2
|
* OP1 s0 v s1 OP2 s2 v s3 OP3 s4 v s5 OP4 s6 v s7
|
||||||
* G3 (WB Final): G1W vs G2W
|
* WR2-1 s8 v OP1w WR2-2 s9 v OP2w
|
||||||
* G4 (LB R1): G1L vs G2L → loser eliminated (bracketLoser)
|
* WSF1 OP3w v WR2-1w WSF2 WR2-2w v OP4w
|
||||||
* G5 (LB Final): G3L vs G4W → loser eliminated (bracketLoser)
|
* WF WSF1w v WSF2w → winner to the side championship
|
||||||
* G6 (Side Championship): G3W vs G5W → loser eliminated (sideLoser)
|
* Elimination bracket (a loss here is final)
|
||||||
|
* ER1-1 OP2l v OP3l ER1-2 OP1l v OP4l
|
||||||
|
* ER2-1 WR2-1l v ER1-1w ER2-2 WR2-2l v ER1-2w
|
||||||
|
* ER3-1 WSF1l v ER2-2w ER3-2 WSF2l v ER2-1w (cross-over)
|
||||||
|
* ER4 ER3-2w v ER3-1w → loser is the 7th–8th tier
|
||||||
|
* EF WFl v ER4w → loser is the 5th–6th tier
|
||||||
|
* Side championship: WFw v EFw → loser drops to the consolation game
|
||||||
*
|
*
|
||||||
* Returns: { sideChampion, sideLoser }
|
* Note the double-chance path: the Winners Final loser is NOT out, it drops to the
|
||||||
* bracketLosers (2) are bumped into counts directly.
|
* Elimination Final. There is no "if necessary" game, so the side championship is
|
||||||
|
* decided in one game.
|
||||||
|
*
|
||||||
|
* Returns { sideChampion, sideLoser }; the two scoring elimination losers are
|
||||||
|
* bumped into the counts directly.
|
||||||
*/
|
*/
|
||||||
function simulateSideBracket(
|
function simulateSideBracket(
|
||||||
poolA1: Team,
|
slots: Team[],
|
||||||
poolA2: Team,
|
|
||||||
poolB1: Team,
|
|
||||||
poolB2: Team,
|
|
||||||
bump: (id: string, key: keyof PlacementCounts) => void
|
bump: (id: string, key: keyof PlacementCounts) => void
|
||||||
): { sideChampion: Team; sideLoser: Team } {
|
): { sideChampion: Team; sideLoser: Team } {
|
||||||
|
// ── Winners bracket ────────────────────────────────────────────────────────
|
||||||
|
const op1 = simGame(slots[0], slots[1]);
|
||||||
|
const op2 = simGame(slots[2], slots[3]);
|
||||||
|
const op3 = simGame(slots[4], slots[5]);
|
||||||
|
const op4 = simGame(slots[6], slots[7]);
|
||||||
|
|
||||||
// Winners bracket
|
const wr21 = simGame(slots[8], op1.winner);
|
||||||
const g1 = simGame(poolA1, poolB2);
|
const wr22 = simGame(slots[9], op2.winner);
|
||||||
const g2 = simGame(poolB1, poolA2);
|
|
||||||
const g3 = simGame(g1.winner, g2.winner); // WB Final
|
|
||||||
|
|
||||||
// Losers bracket
|
const wsf1 = simGame(op3.winner, wr21.winner);
|
||||||
const g4 = simGame(g1.loser, g2.loser); // LB R1 — g4.loser eliminated
|
const wsf2 = simGame(wr22.winner, op4.winner);
|
||||||
bump(g4.loser.participantId, "bracketLoser");
|
|
||||||
|
|
||||||
const g5 = simGame(g3.loser, g4.winner); // LB Final — g5.loser eliminated
|
const wf = simGame(wsf1.winner, wsf2.winner);
|
||||||
bump(g5.loser.participantId, "bracketLoser");
|
|
||||||
|
|
||||||
// Side championship
|
// ── Elimination bracket ────────────────────────────────────────────────────
|
||||||
const g6 = simGame(g3.winner, g5.winner);
|
const er11 = simGame(op2.loser, op3.loser);
|
||||||
|
const er12 = simGame(op1.loser, op4.loser);
|
||||||
|
|
||||||
return { sideChampion: g6.winner, sideLoser: g6.loser };
|
const er21 = simGame(wr21.loser, er11.winner);
|
||||||
|
const er22 = simGame(wr22.loser, er12.winner);
|
||||||
|
|
||||||
|
// Cross-over: each semifinal loser meets the winner from the opposite half.
|
||||||
|
const er31 = simGame(wsf1.loser, er22.winner);
|
||||||
|
const er32 = simGame(wsf2.loser, er21.winner);
|
||||||
|
|
||||||
|
const er4 = simGame(er32.winner, er31.winner);
|
||||||
|
bump(er4.loser.participantId, "elimRound4Loser"); // 7th–8th tier
|
||||||
|
|
||||||
|
// The Winners Final loser gets its second chance here.
|
||||||
|
const ef = simGame(wf.loser, er4.winner);
|
||||||
|
bump(ef.loser.participantId, "elimFinalLoser"); // 5th–6th tier
|
||||||
|
|
||||||
|
// ── Side championship ──────────────────────────────────────────────────────
|
||||||
|
const sideChampionship = simGame(wf.winner, ef.winner);
|
||||||
|
|
||||||
|
return { sideChampion: sideChampionship.winner, sideLoser: sideChampionship.loser };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Validation helpers ───────────────────────────────────────────────────────
|
// ─── Validation helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
type PoolSuffix = "A" | "B" | null;
|
/**
|
||||||
|
* Parse a participant's externalId into a side.
|
||||||
function parseExternalId(raw: string | null): { side: Side; pool: PoolSuffix } | null {
|
*
|
||||||
|
* The legacy pool-play suffixes ("US:A", "Intl:B", …) are still accepted so seasons
|
||||||
|
* configured before the format change keep loading; the pool part is ignored because
|
||||||
|
* the tournament no longer has pools.
|
||||||
|
*/
|
||||||
|
function parseExternalId(raw: string | null): { side: Side } | null {
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const upper = raw.toUpperCase();
|
const side = raw.toUpperCase().split(":")[0];
|
||||||
if (upper === "US") return { side: "US", pool: null };
|
if (side === "US") return { side: "US" };
|
||||||
if (upper === "INTL") return { side: "Intl", pool: null };
|
if (side === "INTL") return { side: "Intl" };
|
||||||
if (upper === "US:A") return { side: "US", pool: "A" };
|
|
||||||
if (upper === "US:B") return { side: "US", pool: "B" };
|
|
||||||
if (upper === "INTL:A") return { side: "Intl", pool: "A" };
|
|
||||||
if (upper === "INTL:B") return { side: "Intl", pool: "B" };
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,36 +216,6 @@ function inferExternalIdFromName(name: string): string {
|
||||||
return upper === "US" || upper.startsWith("US ") ? "US" : "Intl";
|
return upper === "US" || upper.startsWith("US ") ? "US" : "Intl";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine whether pool assignments should be randomized for one side.
|
|
||||||
* - If ALL teams on the side have a pool suffix → fixed pools (returns false).
|
|
||||||
* - If NO teams have a pool suffix → randomized (returns true).
|
|
||||||
* - Mixed → throws.
|
|
||||||
* Also validates that fixed pools are split exactly POOL_SIZE / POOL_SIZE.
|
|
||||||
*/
|
|
||||||
function determineRandomized(sideTeams: Team[], sideName: string): boolean {
|
|
||||||
const withPool = sideTeams.filter((t) => t.fixedPool !== null);
|
|
||||||
const withoutPool = sideTeams.filter((t) => t.fixedPool === null);
|
|
||||||
if (withPool.length > 0 && withoutPool.length > 0) {
|
|
||||||
throw new Error(
|
|
||||||
`${sideName} teams have mixed externalId formats: some have pool suffixes (e.g. "US:A") ` +
|
|
||||||
`and some don't. Either all ${sideName} teams must have pool suffixes or none should.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (withPool.length === sideTeams.length) {
|
|
||||||
const poolA = sideTeams.filter((t) => t.fixedPool === "A");
|
|
||||||
const poolB = sideTeams.filter((t) => t.fixedPool === "B");
|
|
||||||
if (poolA.length !== POOL_SIZE || poolB.length !== POOL_SIZE) {
|
|
||||||
throw new Error(
|
|
||||||
`${sideName} fixed pools must have exactly ${POOL_SIZE} teams each. ` +
|
|
||||||
`Found Pool A: ${poolA.length}, Pool B: ${poolB.length}.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return false; // fixed pools
|
|
||||||
}
|
|
||||||
return true; // randomized
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class LLWSSimulator implements Simulator {
|
export class LLWSSimulator implements Simulator {
|
||||||
|
|
@ -304,7 +263,7 @@ export class LLWSSimulator implements Simulator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Parse externalId for each participant to determine side and fixed pool.
|
// 4. Parse externalId for each participant to determine which side they're on.
|
||||||
const teams: Team[] = [];
|
const teams: Team[] = [];
|
||||||
for (const p of participants) {
|
for (const p of participants) {
|
||||||
const raw = p.externalId ?? inferExternalIdFromName(p.name);
|
const raw = p.externalId ?? inferExternalIdFromName(p.name);
|
||||||
|
|
@ -312,13 +271,12 @@ export class LLWSSimulator implements Simulator {
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Participant ${p.id} has invalid externalId "${p.externalId}". ` +
|
`Participant ${p.id} has invalid externalId "${p.externalId}". ` +
|
||||||
`Expected: "US", "Intl", "US:A", "US:B", "Intl:A", or "Intl:B".`
|
`Expected: "US" or "Intl".`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
teams.push({
|
teams.push({
|
||||||
participantId: p.id,
|
participantId: p.id,
|
||||||
side: parsed.side,
|
side: parsed.side,
|
||||||
fixedPool: parsed.pool,
|
|
||||||
oddsProb: normalizedOddsMap.get(p.id) ?? 0,
|
oddsProb: normalizedOddsMap.get(p.id) ?? 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -334,15 +292,9 @@ export class LLWSSimulator implements Simulator {
|
||||||
throw new Error(`Expected ${INTL_TEAM_COUNT} International teams, found ${intlTeams.length}.`);
|
throw new Error(`Expected ${INTL_TEAM_COUNT} International teams, found ${intlTeams.length}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine pool assignment mode for each side.
|
|
||||||
const usRandomized = determineRandomized(usTeams, "US");
|
|
||||||
const intlRandomized = determineRandomized(intlTeams, "International");
|
|
||||||
|
|
||||||
// 5. Initialise placement count accumulators for all participants.
|
// 5. Initialise placement count accumulators for all participants.
|
||||||
const allIds = participants.map((p) => p.id);
|
const allIds = participants.map((p) => p.id);
|
||||||
const counts = new Map<string, PlacementCounts>(
|
const counts = new Map<string, PlacementCounts>(allIds.map((id) => [id, zeroCounts()]));
|
||||||
allIds.map((id) => [id, { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, bracketLoser: 0 }])
|
|
||||||
);
|
|
||||||
const bump = (id: string, key: keyof PlacementCounts) => {
|
const bump = (id: string, key: keyof PlacementCounts) => {
|
||||||
const entry = counts.get(id);
|
const entry = counts.get(id);
|
||||||
if (entry) entry[key]++;
|
if (entry) entry[key]++;
|
||||||
|
|
@ -350,42 +302,35 @@ export class LLWSSimulator implements Simulator {
|
||||||
|
|
||||||
// 6. Run Monte Carlo simulations.
|
// 6. Run Monte Carlo simulations.
|
||||||
for (let s = 0; s < numSimulations; s++) {
|
for (let s = 0; s < numSimulations; s++) {
|
||||||
// Assign pools for this simulation.
|
// The draw is modelled as random: shuffle each side into the 10 bracket slots
|
||||||
const [usPoolA, usPoolB] = assignPools(usTeams, usRandomized);
|
// (8 opening-round teams, then the 2 bye teams).
|
||||||
const [intlPoolA, intlPoolB] = assignPools(intlTeams, intlRandomized);
|
|
||||||
|
|
||||||
// Pool play: top 2 from each pool advance.
|
|
||||||
const [usA1, usA2] = simulatePoolPlay(usPoolA);
|
|
||||||
const [usB1, usB2] = simulatePoolPlay(usPoolB);
|
|
||||||
const [intlA1, intlA2] = simulatePoolPlay(intlPoolA);
|
|
||||||
const [intlB1, intlB2] = simulatePoolPlay(intlPoolB);
|
|
||||||
|
|
||||||
// Double-elimination bracket per side.
|
|
||||||
const { sideChampion: usChamp, sideLoser: usLose } =
|
const { sideChampion: usChamp, sideLoser: usLose } =
|
||||||
simulateSideBracket(usA1, usA2, usB1, usB2, bump);
|
simulateSideBracket(shuffle([...usTeams]), bump);
|
||||||
const { sideChampion: intlChamp, sideLoser: intlLose } =
|
const { sideChampion: intlChamp, sideLoser: intlLose } =
|
||||||
simulateSideBracket(intlA1, intlA2, intlB1, intlB2, bump);
|
simulateSideBracket(shuffle([...intlTeams]), bump);
|
||||||
|
|
||||||
// Consolation game: 3rd / 4th place.
|
// Consolation game: 3rd / 4th place.
|
||||||
const consolation = simGame(usLose, intlLose);
|
const consolation = simGame(usLose, intlLose);
|
||||||
bump(consolation.winner.participantId, "thirdPlace");
|
bump(consolation.winner.participantId, "thirdPlace");
|
||||||
bump(consolation.loser.participantId, "fourthPlace");
|
bump(consolation.loser.participantId, "fourthPlace");
|
||||||
|
|
||||||
// World Series: 1st / 2nd place.
|
// World Championship: 1st / 2nd place.
|
||||||
const ws = simGame(usChamp, intlChamp);
|
const ws = simGame(usChamp, intlChamp);
|
||||||
bump(ws.winner.participantId, "champion");
|
bump(ws.winner.participantId, "champion");
|
||||||
bump(ws.loser.participantId, "finalist");
|
bump(ws.loser.participantId, "finalist");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Convert counts to probability distributions.
|
// 7. Convert counts to probability distributions.
|
||||||
// bracketLosers: 4 per sim (2 US + 2 Intl) → split evenly.
|
// Each of the two 5–8 tiers takes exactly 2 teams per sim (one per side), and
|
||||||
const bracketLosersPerSim = 4;
|
// the teams within a tier are tied, so the tier probability is split across
|
||||||
const bracketDivisor = bracketLosersPerSim * numSimulations;
|
// its two positions.
|
||||||
|
const tierDivisor = 2 * numSimulations;
|
||||||
|
|
||||||
const zeroCounts: PlacementCounts = { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, bracketLoser: 0 };
|
const empty = zeroCounts();
|
||||||
return allIds.map((id) => {
|
return allIds.map((id) => {
|
||||||
const c = counts.get(id) ?? zeroCounts;
|
const c = counts.get(id) ?? empty;
|
||||||
const bracketProb = c.bracketLoser / bracketDivisor;
|
const upperTier = c.elimFinalLoser / tierDivisor; // 5th–6th
|
||||||
|
const lowerTier = c.elimRound4Loser / tierDivisor; // 7th–8th
|
||||||
return {
|
return {
|
||||||
participantId: id,
|
participantId: id,
|
||||||
probabilities: {
|
probabilities: {
|
||||||
|
|
@ -393,10 +338,10 @@ export class LLWSSimulator implements Simulator {
|
||||||
probSecond: c.finalist / numSimulations,
|
probSecond: c.finalist / numSimulations,
|
||||||
probThird: c.thirdPlace / numSimulations,
|
probThird: c.thirdPlace / numSimulations,
|
||||||
probFourth: c.fourthPlace / numSimulations,
|
probFourth: c.fourthPlace / numSimulations,
|
||||||
probFifth: bracketProb,
|
probFifth: upperTier,
|
||||||
probSixth: bracketProb,
|
probSixth: upperTier,
|
||||||
probSeventh: bracketProb,
|
probSeventh: lowerTier,
|
||||||
probEighth: bracketProb,
|
probEighth: lowerTier,
|
||||||
},
|
},
|
||||||
source: "llws_monte_carlo",
|
source: "llws_monte_carlo",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -183,7 +183,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
setupSections: ["participants", "eloRatings", "futuresOdds", "bracket"],
|
setupSections: ["participants", "eloRatings", "futuresOdds", "bracket"],
|
||||||
},
|
},
|
||||||
llws_bracket: {
|
llws_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, usTeamCount: 10, internationalTeamCount: 10, poolSize: 5 },
|
defaultConfig: { ...BASE_CONFIG, usTeamCount: 10, internationalTeamCount: 10 },
|
||||||
requiredInputs: ["sourceOdds"],
|
requiredInputs: ["sourceOdds"],
|
||||||
optionalInputs: ["metadata"],
|
optionalInputs: ["metadata"],
|
||||||
setupSections: ["participants", "futuresOdds"],
|
setupSections: ["participants", "futuresOdds"],
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,7 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
|
||||||
llws_bracket: {
|
llws_bracket: {
|
||||||
info: {
|
info: {
|
||||||
name: "LLWS Bracket Monte Carlo",
|
name: "LLWS Bracket Monte Carlo",
|
||||||
description: "Simulates the 20-team Little League World Series: pool play round-robin (5 teams/pool, top 2 advance) → 4-team double-elimination bracket per side (US & International) → consolation game (3rd/4th) → World Series. Uses championship futures odds for all win probabilities. Set externalId to 'US'/'Intl' (randomized pools) or 'US:A'/'US:B'/'Intl:A'/'Intl:B' (fixed pools).",
|
description: "Simulates the 20-team Little League World Series: a 10-team double-elimination bracket per side (US & International), each producing a side champion, then the consolation game (3rd/4th) and the World Championship (1st/2nd). Uses championship futures odds for all win probabilities. Set externalId to 'US' or 'Intl'.",
|
||||||
},
|
},
|
||||||
create: () => new LLWSSimulator(),
|
create: () => new LLWSSimulator(),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue