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
350 lines
15 KiB
TypeScript
350 lines
15 KiB
TypeScript
/**
|
||
* Little League World Series (LLWS) Bracket Simulator
|
||
*
|
||
* 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:
|
||
* 1. Load all 20 participants for the sports season from DB
|
||
* (must be exactly 10 US + 10 International, identified by externalId)
|
||
* 2. Load championship futures odds from participantExpectedValues.sourceOdds
|
||
* (entered via Admin → Futures Odds; American format)
|
||
* 3. Convert odds to normalized championship probabilities (vig removed).
|
||
* These drive per-game win probability: p1 / (p1 + p2). Falls back to 50/50.
|
||
* 4. Per simulation:
|
||
* a. Shuffle each side's 10 teams into the 10 bracket slots (8 opening-round
|
||
* teams + 2 byes). The draw is modelled as random — a specific known draw
|
||
* is not yet expressible in participant config.
|
||
* b. Simulate the 10-team double-elimination bracket for each side
|
||
* (see simulateSideBracket for the exact game-by-game structure)
|
||
* c. Consolation game: US side loser vs Intl side loser → 3rd / 4th
|
||
* d. World Championship: US champion vs Intl champion → 1st / 2nd
|
||
* 5. Track placement counts across all simulations.
|
||
* 6. Convert counts to probability distributions.
|
||
*
|
||
* Side assignment (externalId): "US" or "Intl". The legacy pool suffixes
|
||
* ("US:A", "US:B", "Intl:A", "Intl:B") are still accepted and read as the side
|
||
* alone, so seasons configured for the old pool-play format keep working — pools
|
||
* no longer exist, so the suffix has no effect.
|
||
*
|
||
* Placement tiers → SimulationProbabilities mapping (matches llws_20's scoring):
|
||
* probFirst = World Championship winner (1 per sim)
|
||
* probSecond = World Championship loser (1 per sim)
|
||
* probThird = Consolation winner (1 per sim)
|
||
* probFourth = Consolation loser (1 per sim)
|
||
* probFifth/probSixth = Elimination Final losers (2 per sim — 1 per side)
|
||
* probSeventh/probEighth = Elimination Round 4 losers (2 per sim — 1 per side)
|
||
* Everyone else → all 0 (12 teams out in Elimination Rounds 1–3)
|
||
*
|
||
* Admin setup:
|
||
* 1. Create a Sport with simulatorType = "llws_bracket"
|
||
* 2. Create a Sports Season and add exactly 20 participants (10 US, 10 International)
|
||
* 3. Set externalId on each participant via Admin → Manage Participants to "US" or
|
||
* "Intl" (optional — names starting with "US " infer US, all others infer Intl)
|
||
* 4. Enter championship futures odds via Admin → Futures Odds (sourceOdds)
|
||
* 5. Run simulation via Admin → Simulate
|
||
*/
|
||
|
||
import { database } from "~/database/context";
|
||
import { eq } from "drizzle-orm";
|
||
import * as schema from "~/database/schema";
|
||
import { convertAmericanOddsToProbability } from "~/services/probability-engine";
|
||
import type { Simulator, SimulationResult } from "./types";
|
||
import { positiveConfigNumber } from "./config-access";
|
||
|
||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||
|
||
const NUM_SIMULATIONS = 50_000;
|
||
const US_TEAM_COUNT = 10;
|
||
const INTL_TEAM_COUNT = 10;
|
||
|
||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||
|
||
type Side = "US" | "Intl";
|
||
|
||
interface Team {
|
||
participantId: string;
|
||
side: Side;
|
||
/** Normalized championship win probability (0–1, vig removed). */
|
||
oddsProb: number;
|
||
}
|
||
|
||
interface PlacementCounts {
|
||
champion: number;
|
||
finalist: number;
|
||
thirdPlace: number;
|
||
fourthPlace: 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 ─────────────────────────────────────────────────────────────────
|
||
|
||
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 } {
|
||
// 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)
|
||
// because oddsProb=0 would otherwise give the unknown team a 0% win rate.
|
||
let p1Win: number;
|
||
if (t1.oddsProb === 0 || t2.oddsProb === 0) {
|
||
p1Win = 0.5;
|
||
} else {
|
||
p1Win = t1.oddsProb / (t1.oddsProb + t2.oddsProb);
|
||
}
|
||
return Math.random() < p1Win ? { winner: t1, loser: t2 } : { winner: t2, loser: t1 };
|
||
}
|
||
|
||
/**
|
||
* Fisher-Yates shuffle (in-place).
|
||
*/
|
||
function shuffle<T>(arr: T[]): T[] {
|
||
for (let i = arr.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||
}
|
||
return arr;
|
||
}
|
||
|
||
/**
|
||
* Simulate one side's 10-team double-elimination bracket.
|
||
*
|
||
* `slots` holds the side's teams in bracket order, matching the llws_20 participant
|
||
* layout: slots[0..7] are the four opening-round games (two teams each) and
|
||
* slots[8], slots[9] are the two bye teams entering Winners Round 2.
|
||
*
|
||
* Structure (side-local, mirroring LLWS_ADVANCEMENT in models/playoff-match):
|
||
* Winners bracket
|
||
* OP1 s0 v s1 OP2 s2 v s3 OP3 s4 v s5 OP4 s6 v s7
|
||
* WR2-1 s8 v OP1w WR2-2 s9 v OP2w
|
||
* WSF1 OP3w v WR2-1w WSF2 WR2-2w v OP4w
|
||
* WF WSF1w v WSF2w → winner to the side championship
|
||
* 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
|
||
*
|
||
* Note the double-chance path: the Winners Final loser is NOT out, it drops to the
|
||
* 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(
|
||
slots: Team[],
|
||
bump: (id: string, key: keyof PlacementCounts) => void
|
||
): { 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]);
|
||
|
||
const wr21 = simGame(slots[8], op1.winner);
|
||
const wr22 = simGame(slots[9], op2.winner);
|
||
|
||
const wsf1 = simGame(op3.winner, wr21.winner);
|
||
const wsf2 = simGame(wr22.winner, op4.winner);
|
||
|
||
const wf = simGame(wsf1.winner, wsf2.winner);
|
||
|
||
// ── Elimination bracket ────────────────────────────────────────────────────
|
||
const er11 = simGame(op2.loser, op3.loser);
|
||
const er12 = simGame(op1.loser, op4.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 ───────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Parse a participant's externalId into a side.
|
||
*
|
||
* 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;
|
||
const side = raw.toUpperCase().split(":")[0];
|
||
if (side === "US") return { side: "US" };
|
||
if (side === "INTL") return { side: "Intl" };
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Infer an externalId from a participant name when none is stored.
|
||
* Teams whose name is exactly "US" or starts with "US " (case-insensitive)
|
||
* are assigned to the US side; all others are assigned to Intl.
|
||
* The inferred value never has a pool suffix, so pools will be randomized.
|
||
*/
|
||
function inferExternalIdFromName(name: string): string {
|
||
const upper = name.trim().toUpperCase();
|
||
return upper === "US" || upper.startsWith("US ") ? "US" : "Intl";
|
||
}
|
||
|
||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||
|
||
export class LLWSSimulator implements Simulator {
|
||
constructor(private numSimulations = NUM_SIMULATIONS) {}
|
||
|
||
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", this.numSimulations));
|
||
const db = database();
|
||
|
||
// 1. Load all participants.
|
||
const participants = await db
|
||
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name, externalId: schema.seasonParticipants.externalId })
|
||
.from(schema.seasonParticipants)
|
||
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId));
|
||
|
||
if (participants.length !== US_TEAM_COUNT + INTL_TEAM_COUNT) {
|
||
throw new Error(
|
||
`LLWS simulator requires exactly ${US_TEAM_COUNT + INTL_TEAM_COUNT} participants, ` +
|
||
`found ${participants.length}.`
|
||
);
|
||
}
|
||
|
||
// 2. Load championship futures odds.
|
||
const evRows = await db
|
||
.select({
|
||
participantId: schema.seasonParticipantExpectedValues.participantId,
|
||
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
|
||
})
|
||
.from(schema.seasonParticipantExpectedValues)
|
||
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
|
||
|
||
const rawOddsMap = new Map<string, number>();
|
||
for (const row of evRows) {
|
||
if (row.sourceOdds !== null) {
|
||
rawOddsMap.set(row.participantId, convertAmericanOddsToProbability(row.sourceOdds));
|
||
}
|
||
}
|
||
|
||
// 3. Normalize odds (remove vig) to get championship probability per team.
|
||
const normalizedOddsMap = new Map<string, number>();
|
||
if (rawOddsMap.size > 0) {
|
||
const rawSum = [...rawOddsMap.values()].reduce((a, b) => a + b, 0);
|
||
for (const [id, prob] of rawOddsMap) {
|
||
normalizedOddsMap.set(id, rawSum > 0 ? prob / rawSum : 0);
|
||
}
|
||
}
|
||
|
||
// 4. Parse externalId for each participant to determine which side they're on.
|
||
const teams: Team[] = [];
|
||
for (const p of participants) {
|
||
const raw = p.externalId ?? inferExternalIdFromName(p.name);
|
||
const parsed = parseExternalId(raw);
|
||
if (!parsed) {
|
||
throw new Error(
|
||
`Participant ${p.id} has invalid externalId "${p.externalId}". ` +
|
||
`Expected: "US" or "Intl".`
|
||
);
|
||
}
|
||
teams.push({
|
||
participantId: p.id,
|
||
side: parsed.side,
|
||
oddsProb: normalizedOddsMap.get(p.id) ?? 0,
|
||
});
|
||
}
|
||
|
||
// Validate team counts per side.
|
||
const usTeams = teams.filter((t) => t.side === "US");
|
||
const intlTeams = teams.filter((t) => t.side === "Intl");
|
||
|
||
if (usTeams.length !== US_TEAM_COUNT) {
|
||
throw new Error(`Expected ${US_TEAM_COUNT} US teams, found ${usTeams.length}.`);
|
||
}
|
||
if (intlTeams.length !== INTL_TEAM_COUNT) {
|
||
throw new Error(`Expected ${INTL_TEAM_COUNT} International teams, found ${intlTeams.length}.`);
|
||
}
|
||
|
||
// 5. Initialise placement count accumulators for all participants.
|
||
const allIds = participants.map((p) => p.id);
|
||
const counts = new Map<string, PlacementCounts>(allIds.map((id) => [id, zeroCounts()]));
|
||
const bump = (id: string, key: keyof PlacementCounts) => {
|
||
const entry = counts.get(id);
|
||
if (entry) entry[key]++;
|
||
};
|
||
|
||
// 6. Run Monte Carlo simulations.
|
||
for (let s = 0; s < numSimulations; s++) {
|
||
// The draw is modelled as random: shuffle each side into the 10 bracket slots
|
||
// (8 opening-round teams, then the 2 bye teams).
|
||
const { sideChampion: usChamp, sideLoser: usLose } =
|
||
simulateSideBracket(shuffle([...usTeams]), bump);
|
||
const { sideChampion: intlChamp, sideLoser: intlLose } =
|
||
simulateSideBracket(shuffle([...intlTeams]), bump);
|
||
|
||
// Consolation game: 3rd / 4th place.
|
||
const consolation = simGame(usLose, intlLose);
|
||
bump(consolation.winner.participantId, "thirdPlace");
|
||
bump(consolation.loser.participantId, "fourthPlace");
|
||
|
||
// World Championship: 1st / 2nd place.
|
||
const ws = simGame(usChamp, intlChamp);
|
||
bump(ws.winner.participantId, "champion");
|
||
bump(ws.loser.participantId, "finalist");
|
||
}
|
||
|
||
// 7. Convert counts to probability distributions.
|
||
// Each of the two 5–8 tiers takes exactly 2 teams per sim (one per side), and
|
||
// the teams within a tier are tied, so the tier probability is split across
|
||
// its two positions.
|
||
const tierDivisor = 2 * numSimulations;
|
||
|
||
const empty = zeroCounts();
|
||
return allIds.map((id) => {
|
||
const c = counts.get(id) ?? empty;
|
||
const upperTier = c.elimFinalLoser / tierDivisor; // 5th–6th
|
||
const lowerTier = c.elimRound4Loser / tierDivisor; // 7th–8th
|
||
return {
|
||
participantId: id,
|
||
probabilities: {
|
||
probFirst: c.champion / numSimulations,
|
||
probSecond: c.finalist / numSimulations,
|
||
probThird: c.thirdPlace / numSimulations,
|
||
probFourth: c.fourthPlace / numSimulations,
|
||
probFifth: upperTier,
|
||
probSixth: upperTier,
|
||
probSeventh: lowerTier,
|
||
probEighth: lowerTier,
|
||
},
|
||
source: "llws_monte_carlo",
|
||
};
|
||
});
|
||
}
|
||
}
|