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:
parent
4d5fea05ab
commit
0da80bd2c0
7 changed files with 5152 additions and 0 deletions
272
app/services/simulations/__tests__/llws-simulator.test.ts
Normal file
272
app/services/simulations/__tests__/llws-simulator.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
389
app/services/simulations/llws-simulator.ts
Normal file
389
app/services/simulations/llws-simulator.ts
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
/**
|
||||
* 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:
|
||||
* 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 (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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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",
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 1–4 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 {
|
||||
|
|
|
|||
|
|
@ -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", [
|
||||
|
|
|
|||
1
drizzle/0073_blushing_lady_vermin.sql
Normal file
1
drizzle/0073_blushing_lady_vermin.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TYPE "public"."simulator_type" ADD VALUE 'llws_bracket';
|
||||
4473
drizzle/meta/0073_snapshot.json
Normal file
4473
drizzle/meta/0073_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue