Add LLWS bracket Monte Carlo simulator (#280)

* Add LLWS bracket Monte Carlo simulator

Simulates the 20-team Little League World Series: pool play round-robin
(5 teams/pool, top 2 advance) → 4-team double-elimination per side
(US and International) → consolation game → World Series final.

Uses championship futures odds as the sole win-probability signal.
Pool assignments are auto-detected from externalId: bare "US"/"Intl"
randomizes pools each simulation (pre-draw mode); "US:A"/"Intl:B" etc.
uses fixed assignments (post-draw mode). Sides can differ.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix oxlint violations in LLWS simulator

- Replace non-null assertions with null-safe access (! → ?? / optional chaining)
- Replace .sort() with .toSorted() per linting rules
- Promote bump() to simulate() scope and pass it into simulateSideBracket,
  removing the need to pass counts into that function

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-04-08 16:52:10 -04:00 committed by GitHub
parent 4d5fea05ab
commit 0da80bd2c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 5152 additions and 0 deletions

View file

@ -0,0 +1,272 @@
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import { LLWSSimulator } from "../llws-simulator";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/services/probability-engine", async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>;
return { ...actual };
});
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const US_IDS = Array.from({ length: 10 }, (_, i) => `us-${i + 1}`);
const INTL_IDS = Array.from({ length: 10 }, (_, i) => `intl-${i + 1}`);
const ALL_IDS = [...US_IDS, ...INTL_IDS];
/**
* Build EV rows with descending odds favouring the first team per side.
* ids[0] is the strongest (best odds lowest American number for favorites).
*/
function makeEvRows(ids: string[], opts: { includeOdds?: boolean } = {}) {
return ids.map((participantId, i) => ({
participantId,
sourceOdds: opts.includeOdds ? (i === 0 ? -300 : 200 + i * 100) : null,
}));
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("LLWSSimulator", () => {
let mockDb: { select: MockInstance };
let selectCallCount: number;
beforeEach(async () => {
selectCallCount = 0;
const { database } = await import("~/database/context");
mockDb = { select: vi.fn() };
(database as unknown as MockInstance).mockReturnValue(mockDb);
});
function setupMockDb(
participants: { id: string; externalId: string }[],
evRows: { participantId: string; sourceOdds: number | null }[]
) {
mockDb.select.mockImplementation(() => {
const callIndex = selectCallCount++;
const data = callIndex === 0 ? participants : evRows;
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) };
});
}
function defaultParticipants(mode: "randomized" | "fixed" = "randomized") {
if (mode === "fixed") {
const usA = US_IDS.slice(0, 5).map((id) => ({ id, externalId: "US:A" }));
const usB = US_IDS.slice(5).map((id) => ({ id, externalId: "US:B" }));
const intlA = INTL_IDS.slice(0, 5).map((id) => ({ id, externalId: "Intl:A" }));
const intlB = INTL_IDS.slice(5).map((id) => ({ id, externalId: "Intl:B" }));
return [...usA, ...usB, ...intlA, ...intlB];
}
return [
...US_IDS.map((id) => ({ id, externalId: "US" })),
...INTL_IDS.map((id) => ({ id, externalId: "Intl" })),
];
}
// ── Core output structure ─────────────────────────────────────────────────
describe("output structure", () => {
it("returns one result per participant (20 total)", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator().simulate("season-1");
expect(results).toHaveLength(20);
});
it("every result has source 'llws_monte_carlo'", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator().simulate("season-1");
for (const r of results) {
expect(r.source).toBe("llws_monte_carlo");
}
});
it("all probability values are between 0 and 1", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator().simulate("season-1");
for (const r of results) {
const p = r.probabilities;
for (const v of Object.values(p)) {
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThanOrEqual(1);
}
}
});
});
// ── Probability conservation ──────────────────────────────────────────────
describe("probability conservation (one winner per sim)", () => {
it("probFirst sums to ~1.0 across all participants", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator().simulate("season-1");
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("probSecond sums to ~1.0 across all participants", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator().simulate("season-1");
const total = results.reduce((s, r) => s + r.probabilities.probSecond, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("probThird sums to ~1.0 across all participants", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator().simulate("season-1");
const total = results.reduce((s, r) => s + r.probabilities.probThird, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("probFourth sums to ~1.0 across all participants", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator().simulate("season-1");
const total = results.reduce((s, r) => s + r.probabilities.probFourth, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("sum of probFifth across participants equals ~1.0 (4 bracket losers, split evenly)", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator().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);
expect(total).toBeCloseTo(1.0, 1);
});
it("probFifth through probEighth are equal for every participant (even bracket-loser split)", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator().simulate("season-1");
for (const r of results) {
const p = r.probabilities;
expect(p.probFifth).toBeCloseTo(p.probSixth, 10);
expect(p.probSixth).toBeCloseTo(p.probSeventh, 10);
expect(p.probSeventh).toBeCloseTo(p.probEighth, 10);
}
});
});
// ── Odds-driven probability ───────────────────────────────────────────────
describe("odds-driven win probability", () => {
it("strong favourite (us-1) has higher probFirst than a weak team", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS, { includeOdds: true }));
const results = await new LLWSSimulator().simulate("season-1");
const byId = new Map(results.map((r) => [r.participantId, r]));
const us1prob = byId.get("us-1")?.probabilities.probFirst ?? 0;
const us10prob = byId.get("us-10")?.probabilities.probFirst ?? 0;
expect(us1prob).toBeGreaterThan(us10prob);
});
it("works when no odds are entered (all 50/50 fallback)", async () => {
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS)); // no odds
const results = await new LLWSSimulator().simulate("season-1");
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("with equal odds, each team wins the championship roughly equally", async () => {
// Equal positive odds (+5000 for every team) → vig-removed prob ≈ 1/20 each.
const eqOddsRows = ALL_IDS.map((id) => ({ participantId: id, sourceOdds: 5000 }));
setupMockDb(defaultParticipants(), eqOddsRows);
const results = await new LLWSSimulator().simulate("season-1");
for (const r of results) {
// With equal odds and random pools, each team should win ~5% of the time.
// Allow a generous band given Monte Carlo variance.
expect(r.probabilities.probFirst).toBeGreaterThan(0.01);
expect(r.probabilities.probFirst).toBeLessThan(0.15);
}
});
});
// ── Pool assignment modes ─────────────────────────────────────────────────
describe("pool assignment modes", () => {
it("fixed pools (US:A / US:B / Intl:A / Intl:B) produce valid results", async () => {
setupMockDb(defaultParticipants("fixed"), makeEvRows(ALL_IDS));
const results = await new LLWSSimulator().simulate("season-1");
expect(results).toHaveLength(20);
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("mixed mode: US fixed pools, Intl randomized", async () => {
const participants = [
...US_IDS.slice(0, 5).map((id) => ({ id, externalId: "US:A" })),
...US_IDS.slice(5).map((id) => ({ id, externalId: "US:B" })),
...INTL_IDS.map((id) => ({ id, externalId: "Intl" })),
];
setupMockDb(participants, makeEvRows(ALL_IDS));
const results = await new LLWSSimulator().simulate("season-1");
expect(results).toHaveLength(20);
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 1);
});
});
// ── Error cases ───────────────────────────────────────────────────────────
describe("error cases", () => {
it("throws when participant count is not 20", async () => {
const nineteen = [...US_IDS, ...INTL_IDS.slice(0, 9)];
const participants = nineteen.map((id, i) => ({
id,
externalId: i < 10 ? "US" : "Intl",
}));
setupMockDb(participants, makeEvRows(nineteen));
await expect(new LLWSSimulator().simulate("season-1")).rejects.toThrow(/exactly 20/);
});
it("throws when a participant has a missing externalId", async () => {
const participants = [
...US_IDS.map((id) => ({ id, externalId: "US" })),
...INTL_IDS.slice(0, 9).map((id) => ({ id, externalId: "Intl" })),
{ id: "intl-10", externalId: null as unknown as string }, // missing
];
setupMockDb(participants, makeEvRows(ALL_IDS));
await expect(new LLWSSimulator().simulate("season-1")).rejects.toThrow(/invalid or missing externalId/);
});
it("throws when a participant has an unrecognized externalId", async () => {
const participants = [
...US_IDS.map((id) => ({ id, externalId: "US" })),
...INTL_IDS.slice(0, 9).map((id) => ({ id, externalId: "Intl" })),
{ id: "intl-10", externalId: "CANADA" }, // unrecognized
];
setupMockDb(participants, makeEvRows(ALL_IDS));
await expect(new LLWSSimulator().simulate("season-1")).rejects.toThrow(/invalid or missing externalId/);
});
it("throws when US team count is not 10", async () => {
// 11 US teams, 9 International
const participants = [
...Array.from({ length: 11 }, (_, i) => ({ id: `us-${i + 1}`, externalId: "US" })),
...Array.from({ length: 9 }, (_, i) => ({ id: `intl-${i + 1}`, externalId: "Intl" })),
];
setupMockDb(participants, makeEvRows(ALL_IDS));
await expect(new LLWSSimulator().simulate("season-1")).rejects.toThrow(/10 US teams/);
});
it("throws when fixed pools have unequal A/B split", async () => {
const participants = [
// 6 in Pool A, 4 in Pool B
...US_IDS.slice(0, 6).map((id) => ({ id, externalId: "US:A" })),
...US_IDS.slice(6).map((id) => ({ id, externalId: "US:B" })),
...INTL_IDS.map((id) => ({ id, externalId: "Intl" })),
];
setupMockDb(participants, makeEvRows(ALL_IDS));
await expect(new LLWSSimulator().simulate("season-1")).rejects.toThrow(/exactly 5 teams each/);
});
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, externalId: "US:A" })),
...US_IDS.slice(5).map((id) => ({ id, externalId: "US" })), // no pool
...INTL_IDS.map((id) => ({ id, externalId: "Intl" })),
];
setupMockDb(participants, makeEvRows(ALL_IDS));
await expect(new LLWSSimulator().simulate("season-1")).rejects.toThrow(/mixed externalId formats/);
});
});
});

View file

@ -0,0 +1,389 @@
/**
* Little League World Series (LLWS) Bracket Simulator
*
* Monte Carlo simulation of the LLWS (20-team format, 2022present).
*
* 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)
* probFifthprobEighth = 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:
* Pre-draw: "US" or "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 (01, 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;
}
/**
* 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, 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 parsed = parseExternalId(p.externalId);
if (!parsed) {
throw new Error(
`Participant ${p.id} has invalid or missing 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",
};
});
}
}

View file

@ -25,6 +25,7 @@ import { NFLSimulator } from "./nfl-simulator";
import { WNBASimulator } from "./wnba-simulator";
import { WorldCupSimulator } from "./world-cup-simulator";
import { NCAAFootballSimulator } from "./ncaa-football-simulator";
import { LLWSSimulator } from "./llws-simulator";
export const SIMULATOR_TYPES = [
"f1_standings",
@ -46,6 +47,7 @@ export const SIMULATOR_TYPES = [
"darts_bracket",
"cs2_major_qualifying_points",
"ncaa_football_bracket",
"llws_bracket",
] as const;
export type SimulatorType = typeof SIMULATOR_TYPES[number];
@ -149,6 +151,13 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
info: { name: "NCAA Football CFP Monte Carlo", description: "Simulates the 12-team College Football Playoff bracket using Elo/FPI ratings entered via Admin → Elo Ratings. Optionally blends with championship futures odds (60% Elo / 40% odds). Seeds 14 receive first-round byes; First Round losers score 0 points." },
create: () => new NCAAFootballSimulator(),
},
llws_bracket: {
info: {
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).",
},
create: () => new LLWSSimulator(),
},
};
export function getSimulator(simulatorType: SimulatorType): Simulator {

View file

@ -102,6 +102,7 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [
"darts_bracket",
"cs2_major_qualifying_points",
"ncaa_football_bracket",
"llws_bracket",
]);
export const playoffMatchGameStatusEnum = pgEnum("playoff_match_game_status", [

View file

@ -0,0 +1 @@
ALTER TYPE "public"."simulator_type" ADD VALUE 'llws_bracket';

File diff suppressed because it is too large Load diff

View file

@ -512,6 +512,13 @@
"when": 1775652883157,
"tag": "0072_jittery_steve_rogers",
"breakpoints": true
},
{
"idx": 73,
"version": "7",
"when": 1775679862606,
"tag": "0073_blushing_lady_vermin",
"breakpoints": true
}
]
}