Participants are created with externalId=null by default, causing the LLWS simulator to crash at runtime. Fix in two parts: 1. Name-prefix inference: if externalId is null, participants whose name starts with "US " or equals "US" are assigned to the US side; all others are assigned to Intl. Pools are always randomized when inferred (no pool suffix). 2. Admin UI: add an inline-editable "Group" column to the Manage Participants page so admins can explicitly set externalId for any simulator that uses it (LLWS, and future cases like NHL conferences). Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
401 lines
16 KiB
TypeScript
401 lines
16 KiB
TypeScript
/**
|
||
* Little League World Series (LLWS) Bracket Simulator
|
||
*
|
||
* Monte Carlo simulation of the LLWS (20-team format, 2022–present).
|
||
*
|
||
* 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. Determine pool assignment mode from externalId:
|
||
* - Fixed pools: externalId is "US:A", "US:B", "Intl:A", or "Intl:B"
|
||
* → use these exact pool assignments every simulation.
|
||
* - Randomized pools: externalId is "US" or "Intl" only
|
||
* → randomly shuffle each side into Pool A / Pool B each simulation.
|
||
* 5. Per simulation:
|
||
* a. Assign pools (fixed or random)
|
||
* b. Simulate pool play round-robin within each pool (10 games/pool)
|
||
* Top 2 by W-L record advance. Ties broken randomly.
|
||
* c. Simulate 4-team double-elimination bracket per side:
|
||
* 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):
|
||
* "US:A" / "US:B" / "Intl:A" / "Intl:B" → fixed pools (post-draw mode)
|
||
* "US" / "Intl" → randomized pools (pre-draw mode)
|
||
* Mixed: if ANY US or Intl team has a pool suffix, ALL teams on that side must
|
||
* have one (throws otherwise). Sides can differ — US fixed while Intl randomized.
|
||
*
|
||
* Placement tiers → SimulationProbabilities mapping:
|
||
* probFirst = World Series Champion (1 per sim)
|
||
* probSecond = World Series Runner-up (1 per sim)
|
||
* probThird = Consolation game winner / 3rd place (1 per sim)
|
||
* probFourth = Consolation game loser / 4th place (1 per sim)
|
||
* probFifth–probEighth = Double-elim bracket losers before side championships
|
||
* (4 per sim — split evenly: 2 US + 2 Intl)
|
||
* Pool play losers → all 0 (12 teams, did not advance from pool play)
|
||
*
|
||
* 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 (optional if names follow the convention):
|
||
* Pre-draw: "US" or "Intl" (or leave null — 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)
|
||
* 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";
|
||
|
||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||
|
||
const NUM_SIMULATIONS = 50_000;
|
||
const US_TEAM_COUNT = 10;
|
||
const INTL_TEAM_COUNT = 10;
|
||
const POOL_SIZE = 5; // teams per pool within each side
|
||
|
||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||
|
||
type Side = "US" | "Intl";
|
||
|
||
interface Team {
|
||
participantId: string;
|
||
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). */
|
||
oddsProb: number;
|
||
}
|
||
|
||
interface PlacementCounts {
|
||
champion: number;
|
||
finalist: number;
|
||
thirdPlace: number;
|
||
fourthPlace: number;
|
||
bracketLoser: number;
|
||
}
|
||
|
||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Assign teams to Pool A / Pool B for one side.
|
||
* 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):
|
||
* poolA1 = Pool A winner, poolA2 = Pool A runner-up
|
||
* poolB1 = Pool B winner, poolB2 = Pool B runner-up
|
||
*
|
||
* Bracket:
|
||
* G1 (WB): A1 vs B2
|
||
* G2 (WB): B1 vs A2
|
||
* G3 (WB Final): G1W vs G2W
|
||
* G4 (LB R1): G1L vs G2L → loser eliminated (bracketLoser)
|
||
* G5 (LB Final): G3L vs G4W → loser eliminated (bracketLoser)
|
||
* G6 (Side Championship): G3W vs G5W → loser eliminated (sideLoser)
|
||
*
|
||
* Returns: { sideChampion, sideLoser }
|
||
* bracketLosers (2) are bumped into counts directly.
|
||
*/
|
||
function simulateSideBracket(
|
||
poolA1: Team,
|
||
poolA2: Team,
|
||
poolB1: Team,
|
||
poolB2: Team,
|
||
bump: (id: string, key: keyof PlacementCounts) => void
|
||
): { sideChampion: Team; sideLoser: Team } {
|
||
|
||
// Winners bracket
|
||
const g1 = simGame(poolA1, poolB2);
|
||
const g2 = simGame(poolB1, poolA2);
|
||
const g3 = simGame(g1.winner, g2.winner); // WB Final
|
||
|
||
// Losers bracket
|
||
const g4 = simGame(g1.loser, g2.loser); // LB R1 — g4.loser eliminated
|
||
bump(g4.loser.participantId, "bracketLoser");
|
||
|
||
const g5 = simGame(g3.loser, g4.winner); // LB Final — g5.loser eliminated
|
||
bump(g5.loser.participantId, "bracketLoser");
|
||
|
||
// Side championship
|
||
const g6 = simGame(g3.winner, g5.winner);
|
||
|
||
return { sideChampion: g6.winner, sideLoser: g6.loser };
|
||
}
|
||
|
||
// ─── Validation helpers ───────────────────────────────────────────────────────
|
||
|
||
type PoolSuffix = "A" | "B" | null;
|
||
|
||
function parseExternalId(raw: string | null): { side: Side; pool: PoolSuffix } | null {
|
||
if (!raw) return null;
|
||
const upper = raw.toUpperCase();
|
||
if (upper === "US") return { side: "US", pool: null };
|
||
if (upper === "INTL") return { side: "Intl", pool: null };
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 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";
|
||
}
|
||
|
||
/**
|
||
* 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 ────────────────────────────────────────────────────────────────
|
||
|
||
export class LLWSSimulator implements Simulator {
|
||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
||
const db = database();
|
||
|
||
// 1. Load all participants.
|
||
const participants = await db
|
||
.select({ id: schema.participants.id, name: schema.participants.name, externalId: schema.participants.externalId })
|
||
.from(schema.participants)
|
||
.where(eq(schema.participants.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.participantExpectedValues.participantId,
|
||
sourceOdds: schema.participantExpectedValues.sourceOdds,
|
||
})
|
||
.from(schema.participantExpectedValues)
|
||
.where(eq(schema.participantExpectedValues.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 side and fixed pool.
|
||
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", "Intl", "US:A", "US:B", "Intl:A", or "Intl:B".`
|
||
);
|
||
}
|
||
teams.push({
|
||
participantId: p.id,
|
||
side: parsed.side,
|
||
fixedPool: parsed.pool,
|
||
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}.`);
|
||
}
|
||
|
||
// 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.
|
||
const allIds = participants.map((p) => p.id);
|
||
const counts = new Map<string, PlacementCounts>(
|
||
allIds.map((id) => [id, { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, bracketLoser: 0 }])
|
||
);
|
||
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 < NUM_SIMULATIONS; s++) {
|
||
// Assign pools for this simulation.
|
||
const [usPoolA, usPoolB] = assignPools(usTeams, usRandomized);
|
||
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 } =
|
||
simulateSideBracket(usA1, usA2, usB1, usB2, bump);
|
||
const { sideChampion: intlChamp, sideLoser: intlLose } =
|
||
simulateSideBracket(intlA1, intlA2, intlB1, intlB2, bump);
|
||
|
||
// Consolation game: 3rd / 4th place.
|
||
const consolation = simGame(usLose, intlLose);
|
||
bump(consolation.winner.participantId, "thirdPlace");
|
||
bump(consolation.loser.participantId, "fourthPlace");
|
||
|
||
// World Series: 1st / 2nd place.
|
||
const ws = simGame(usChamp, intlChamp);
|
||
bump(ws.winner.participantId, "champion");
|
||
bump(ws.loser.participantId, "finalist");
|
||
}
|
||
|
||
// 7. Convert counts to probability distributions.
|
||
// bracketLosers: 4 per sim (2 US + 2 Intl) → split evenly.
|
||
const bracketLosersPerSim = 4;
|
||
const bracketDivisor = bracketLosersPerSim * NUM_SIMULATIONS;
|
||
|
||
const zeroCounts: PlacementCounts = { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, bracketLoser: 0 };
|
||
return allIds.map((id) => {
|
||
const c = counts.get(id) ?? zeroCounts;
|
||
const bracketProb = c.bracketLoser / bracketDivisor;
|
||
return {
|
||
participantId: id,
|
||
probabilities: {
|
||
probFirst: c.champion / NUM_SIMULATIONS,
|
||
probSecond: c.finalist / NUM_SIMULATIONS,
|
||
probThird: c.thirdPlace / NUM_SIMULATIONS,
|
||
probFourth: c.fourthPlace / NUM_SIMULATIONS,
|
||
probFifth: bracketProb,
|
||
probSixth: bracketProb,
|
||
probSeventh: bracketProb,
|
||
probEighth: bracketProb,
|
||
},
|
||
source: "llws_monte_carlo",
|
||
};
|
||
});
|
||
}
|
||
}
|