Add NLL box lacrosse simulator implementation plan and preseason-draftability guidance (#417)

* docs: plan NLL preseason simulator

* Add NLL Season + Playoffs Monte Carlo simulator

14-team regular season (18 games) projects top-8 via Elo + decaying
projectedWins prior (adjusts for games already played). Playoff bracket:
QF single-game (1v8, 2v7, 3v6, 4v5), SF and Finals best-of-3. Three
runtime modes: bracket-aware → known-seed → regular-season projection.

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-05-12 14:22:34 -07:00 committed by GitHub
parent 4f111820ec
commit 5b261bf258
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 7586 additions and 0 deletions

View file

@ -0,0 +1,506 @@
import { describe, it, expect } from "vitest";
import {
nllGameWinProbability,
simulateNllGame,
simulateBestOfThree,
buildNllBracketFromSeeds,
simulateNllPlayoffs,
simulateRegularSeasonSeeds,
resolveQfMatch,
resolveBo3Match,
type SeedEntry,
type SimTeam,
type MatchSnapshot,
type GameSnapshot,
} from "../nll-simulator";
import { SIMULATOR_TYPES } from "../registry";
import { SIMULATOR_MANIFEST } from "../manifest";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function makeSeed(participantId: string, seed: number, elo = 1500): SeedEntry {
return { participantId, elo, seed };
}
function make14Teams(eloFn = (_i: number) => 1500) {
return Array.from({ length: 14 }, (_, i) => ({
participantId: `team-${i + 1}`,
elo: eloFn(i),
currentWins: 0,
gamesPlayed: 0,
projectedWins: null as number | null,
}));
}
// ─── nllGameWinProbability ─────────────────────────────────────────────────────
describe("nllGameWinProbability", () => {
it("returns 0.5 for equal Elo", () => {
expect(nllGameWinProbability(1500, 1500)).toBeCloseTo(0.5);
});
it("favours the higher-Elo team", () => {
expect(nllGameWinProbability(1600, 1400)).toBeGreaterThan(0.5);
});
it("returns a probability in [0, 1]", () => {
const p = nllGameWinProbability(2000, 1000);
expect(p).toBeGreaterThan(0);
expect(p).toBeLessThan(1);
});
it("uses the supplied parityFactor", () => {
const pLoose = nllGameWinProbability(1600, 1400, 1000);
const pTight = nllGameWinProbability(1600, 1400, 200);
expect(pLoose).toBeLessThan(pTight);
});
});
// ─── simulateNllGame ──────────────────────────────────────────────────────────
describe("simulateNllGame", () => {
it("returns one winner and one loser from the two teams", () => {
const a: SimTeam = { participantId: "A", elo: 1500 };
const b: SimTeam = { participantId: "B", elo: 1500 };
const result = simulateNllGame(a, b);
const ids = [result.winner.participantId, result.loser.participantId].toSorted();
expect(ids).toEqual(["A", "B"]);
});
it("winner and loser are different", () => {
const a: SimTeam = { participantId: "A", elo: 1500 };
const b: SimTeam = { participantId: "B", elo: 1500 };
const result = simulateNllGame(a, b);
expect(result.winner.participantId).not.toBe(result.loser.participantId);
});
it("over many trials, higher-Elo team wins more often", () => {
const strong: SimTeam = { participantId: "strong", elo: 1700 };
const weak: SimTeam = { participantId: "weak", elo: 1300 };
let wins = 0;
for (let i = 0; i < 10_000; i++) {
if (simulateNllGame(strong, weak).winner.participantId === "strong") wins++;
}
expect(wins).toBeGreaterThan(7000);
});
});
// ─── simulateBestOfThree ──────────────────────────────────────────────────────
describe("simulateBestOfThree", () => {
it("returns one winner and one loser", () => {
const a: SimTeam = { participantId: "A", elo: 1500 };
const b: SimTeam = { participantId: "B", elo: 1500 };
const result = simulateBestOfThree(a, b);
expect(result.winner.participantId).not.toBe(result.loser.participantId);
const ids = [result.winner.participantId, result.loser.participantId].toSorted();
expect(ids).toEqual(["A", "B"]);
});
it("gamesPlayed is between 2 and 3", () => {
const a: SimTeam = { participantId: "A", elo: 1500 };
const b: SimTeam = { participantId: "B", elo: 1500 };
for (let i = 0; i < 1000; i++) {
const { gamesPlayed } = simulateBestOfThree(a, b);
expect(gamesPlayed).toBeGreaterThanOrEqual(2);
expect(gamesPlayed).toBeLessThanOrEqual(3);
}
});
it("from a 1-1 state always plays exactly 1 more game", () => {
const a: SimTeam = { participantId: "A", elo: 1500 };
const b: SimTeam = { participantId: "B", elo: 1500 };
for (let i = 0; i < 1000; i++) {
const { gamesPlayed } = simulateBestOfThree(a, b, 400, { winsA: 1, winsB: 1 });
expect(gamesPlayed).toBe(1);
}
});
it("respects existing series state: team with 2 wins is declared winner immediately", () => {
const a: SimTeam = { participantId: "A", elo: 1500 };
const b: SimTeam = { participantId: "B", elo: 1500 };
const result = simulateBestOfThree(a, b, 400, { winsA: 2, winsB: 0 });
expect(result.winner.participantId).toBe("A");
});
it("simulates from a 1-0 state correctly", () => {
const a: SimTeam = { participantId: "A", elo: 1900 };
const b: SimTeam = { participantId: "B", elo: 1100 };
let aWins = 0;
for (let i = 0; i < 5_000; i++) {
const r = simulateBestOfThree(a, b, 400, { winsA: 1, winsB: 0 });
if (r.winner.participantId === "A") aWins++;
}
// A is heavily favoured and already leads 1-0
expect(aWins).toBeGreaterThan(4000);
});
});
// ─── buildNllBracketFromSeeds ─────────────────────────────────────────────────
describe("buildNllBracketFromSeeds", () => {
const seeds = Array.from({ length: 8 }, (_, i) => makeSeed(`t${i + 1}`, i + 1));
it("produces 4 matchups", () => {
const bracket = buildNllBracketFromSeeds(seeds);
expect(bracket).toHaveLength(4);
});
it("QF1 is seed 1 vs seed 8", () => {
const [[a, b]] = buildNllBracketFromSeeds(seeds);
expect(a.seed).toBe(1);
expect(b.seed).toBe(8);
});
it("QF2 is seed 4 vs seed 5", () => {
const [, [a, b]] = buildNllBracketFromSeeds(seeds);
expect(a.seed).toBe(4);
expect(b.seed).toBe(5);
});
it("QF3 is seed 2 vs seed 7", () => {
const [, , [a, b]] = buildNllBracketFromSeeds(seeds);
expect(a.seed).toBe(2);
expect(b.seed).toBe(7);
});
it("QF4 is seed 3 vs seed 6", () => {
const [, , , [a, b]] = buildNllBracketFromSeeds(seeds);
expect(a.seed).toBe(3);
expect(b.seed).toBe(6);
});
it("throws if a required seed is missing", () => {
const incomplete = seeds.slice(0, 7); // missing seed 8
expect(() => buildNllBracketFromSeeds(incomplete)).toThrow();
});
});
// ─── simulateNllPlayoffs ──────────────────────────────────────────────────────
describe("simulateNllPlayoffs", () => {
const seeds = Array.from({ length: 8 }, (_, i) => makeSeed(`t${i + 1}`, i + 1));
it("returns exactly one champion", () => {
const result = simulateNllPlayoffs(seeds);
expect(typeof result.champion).toBe("string");
});
it("returns exactly one finalist", () => {
const result = simulateNllPlayoffs(seeds);
expect(typeof result.finalist).toBe("string");
});
it("champion and finalist are different", () => {
const result = simulateNllPlayoffs(seeds);
expect(result.champion).not.toBe(result.finalist);
});
it("returns 2 SF losers", () => {
const result = simulateNllPlayoffs(seeds);
expect(result.sfLosers).toHaveLength(2);
});
it("returns 4 QF losers", () => {
const result = simulateNllPlayoffs(seeds);
expect(result.qfLosers).toHaveLength(4);
});
it("champion comes from the playoff field (seeds 18)", () => {
const ids = new Set(seeds.map((s) => s.participantId));
const result = simulateNllPlayoffs(seeds);
expect(ids.has(result.champion)).toBe(true);
});
it("all 8 participants appear exactly once across all placements", () => {
const result = simulateNllPlayoffs(seeds);
const all = [
result.champion,
result.finalist,
...result.sfLosers,
...result.qfLosers,
];
expect(all).toHaveLength(8);
expect(new Set(all).size).toBe(8);
});
it("higher-Elo team (seed 1) wins more often than lower-Elo team (seed 8)", () => {
const strongSeeds = Array.from({ length: 8 }, (_, i) =>
makeSeed(`t${i + 1}`, i + 1, 1800 - i * 50)
);
let seed1Wins = 0;
for (let i = 0; i < 5_000; i++) {
if (simulateNllPlayoffs(strongSeeds).champion === "t1") seed1Wins++;
}
expect(seed1Wins).toBeGreaterThan(1500);
});
});
// ─── simulateRegularSeasonSeeds ───────────────────────────────────────────────
describe("simulateRegularSeasonSeeds", () => {
it("returns exactly 8 seeds from a 14-team field", () => {
const teams = make14Teams();
const seeds = simulateRegularSeasonSeeds(teams);
expect(seeds).toHaveLength(8);
});
it("seeds are numbered 18 with no duplicates", () => {
const teams = make14Teams();
const seeds = simulateRegularSeasonSeeds(teams);
const seedNums = seeds.map((s) => s.seed).toSorted((a, b) => a - b);
expect(seedNums).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
});
it("all returned participant IDs are from the input teams", () => {
const teams = make14Teams();
const ids = new Set(teams.map((t) => t.participantId));
const seeds = simulateRegularSeasonSeeds(teams);
for (const s of seeds) {
expect(ids.has(s.participantId)).toBe(true);
}
});
it("higher-Elo teams qualify for playoffs more often", () => {
const teams = make14Teams((i) => i === 0 ? 1900 : 1400);
let qualifiedCount = 0;
for (let i = 0; i < 1000; i++) {
const seeds = simulateRegularSeasonSeeds(teams);
if (seeds.some((s) => s.participantId === "team-1")) qualifiedCount++;
}
expect(qualifiedCount).toBeGreaterThan(900);
});
it("team with higher projectedWins qualifies more often", () => {
const base = make14Teams(() => 1500);
base[0] = { ...base[0], projectedWins: 17 }; // 17/18 projected wins
base[1] = { ...base[1], projectedWins: 2 }; // 2/18 projected wins
let t1Qualifies = 0;
let t2Qualifies = 0;
for (let i = 0; i < 1000; i++) {
const seeds = simulateRegularSeasonSeeds(base);
const ids = seeds.map((s) => s.participantId);
if (ids.includes("team-1")) t1Qualifies++;
if (ids.includes("team-2")) t2Qualifies++;
}
expect(t1Qualifies).toBeGreaterThan(t2Qualifies);
});
it("mid-season: prior adjusts for remaining wins — team with more projected wins still ahead qualifies more", () => {
// All teams are at the halfway point with 0 currentWins so currentWins don't confound.
// team-1 has a very high remaining projection; team-2 has a very low one.
// With equal Elo, the prior blend should make team-1 qualify more.
const teams = make14Teams(() => 1500).map((t) => ({
...t, currentWins: 0, gamesPlayed: 9,
}));
teams[0] = { ...teams[0], projectedWins: 17 }; // priorRate = min(1, 17/9) = 1.0
teams[1] = { ...teams[1], projectedWins: 2 }; // priorRate = 2/9 ≈ 0.22
let t1Qualifies = 0;
let t2Qualifies = 0;
for (let i = 0; i < 1000; i++) {
const seeds = simulateRegularSeasonSeeds(teams);
const ids = seeds.map((s) => s.participantId);
if (ids.includes("team-1")) t1Qualifies++;
if (ids.includes("team-2")) t2Qualifies++;
}
expect(t1Qualifies).toBeGreaterThan(t2Qualifies);
});
it("works with a full pre-season 0-0 state", () => {
const teams = make14Teams(() => 1500);
expect(() => simulateRegularSeasonSeeds(teams)).not.toThrow();
});
it("respects existing wins as a floor (current wins carry over)", () => {
// Team with 16 wins already can't go below 16.
const teams = make14Teams(() => 1500);
teams[0] = { ...teams[0], currentWins: 16, gamesPlayed: 17 };
for (let i = 0; i < 100; i++) {
const seeds = simulateRegularSeasonSeeds(teams);
// team-1 must be seeded (has ≥16 wins, max 18 total)
expect(seeds.some((s) => s.participantId === "team-1")).toBe(true);
}
});
});
// ─── Output probability column sums ──────────────────────────────────────────
describe("simulateNllPlayoffs probability column sums", () => {
it("probFirst sums to 1 across all participants", () => {
const N = 1000;
const seeds = Array.from({ length: 8 }, (_, i) =>
makeSeed(`t${i + 1}`, i + 1, 1500)
);
const counts = new Map(seeds.map((s) => [s.participantId, 0]));
for (let i = 0; i < N; i++) {
const r = simulateNllPlayoffs(seeds);
counts.set(r.champion, (counts.get(r.champion) ?? 0) + 1);
}
const total = [...counts.values()].reduce((s, c) => s + c, 0);
expect(total).toBe(N);
});
it("probThird+Fourth sums: 2 SF losers per sim → total = 2*N", () => {
const N = 1000;
const seeds = Array.from({ length: 8 }, (_, i) =>
makeSeed(`t${i + 1}`, i + 1, 1500)
);
let total = 0;
for (let i = 0; i < N; i++) {
total += simulateNllPlayoffs(seeds).sfLosers.length;
}
expect(total).toBe(2 * N);
});
it("probFifthEighth sums: 4 QF losers per sim → total = 4*N", () => {
const N = 1000;
const seeds = Array.from({ length: 8 }, (_, i) =>
makeSeed(`t${i + 1}`, i + 1, 1500)
);
let total = 0;
for (let i = 0; i < N; i++) {
total += simulateNllPlayoffs(seeds).qfLosers.length;
}
expect(total).toBe(4 * N);
});
});
// ─── resolveQfMatch ───────────────────────────────────────────────────────────
function makeMatch(overrides: Partial<MatchSnapshot> = {}): MatchSnapshot {
return {
id: "match-1",
isComplete: false,
winnerId: null,
loserId: null,
participant1Id: "A",
participant2Id: "B",
...overrides,
};
}
describe("resolveQfMatch", () => {
it("returns stored result deterministically when match is complete", () => {
const match = makeMatch({ isComplete: true, winnerId: "A", loserId: "B" });
const eloMap = new Map([["A", 1500], ["B", 1500]]);
for (let i = 0; i < 100; i++) {
const result = resolveQfMatch(match, eloMap);
expect(result.winner).toBe("A");
expect(result.loser).toBe("B");
}
});
it("simulates a game when match is incomplete", () => {
const match = makeMatch();
const eloMap = new Map([["A", 1500], ["B", 1500]]);
const result = resolveQfMatch(match, eloMap);
const ids = [result.winner, result.loser].toSorted();
expect(ids).toEqual(["A", "B"]);
});
it("throws when participant slots are missing", () => {
const match = makeMatch({ participant1Id: null });
const eloMap = new Map<string, number>();
expect(() => resolveQfMatch(match, eloMap)).toThrow();
});
it("throws when match is undefined", () => {
const eloMap = new Map<string, number>();
expect(() => resolveQfMatch(undefined, eloMap)).toThrow();
});
});
// ─── resolveBo3Match ──────────────────────────────────────────────────────────
function noGames(): GameSnapshot[] {
return [];
}
describe("resolveBo3Match", () => {
it("returns stored result deterministically when match is complete", () => {
const match = makeMatch({ isComplete: true, winnerId: "B", loserId: "A" });
const eloMap = new Map([["A", 1500], ["B", 1500]]);
for (let i = 0; i < 100; i++) {
const result = resolveBo3Match(match, noGames(), eloMap);
expect(result.winner).toBe("B");
expect(result.loser).toBe("A");
}
});
it("simulates from scratch when no games played", () => {
const match = makeMatch();
const eloMap = new Map([["A", 1500], ["B", 1500]]);
const result = resolveBo3Match(match, noGames(), eloMap);
const ids = [result.winner, result.loser].toSorted();
expect(ids).toEqual(["A", "B"]);
});
it("returns winner deterministically when game rows show 2 wins for p1", () => {
const match = makeMatch();
const games: GameSnapshot[] = [{ winnerId: "A" }, { winnerId: "A" }];
const eloMap = new Map([["A", 1500], ["B", 1500]]);
for (let i = 0; i < 100; i++) {
const result = resolveBo3Match(match, games, eloMap);
expect(result.winner).toBe("A");
expect(result.loser).toBe("B");
}
});
it("simulates correctly from a 1-1 game state", () => {
const match = makeMatch();
const games: GameSnapshot[] = [{ winnerId: "A" }, { winnerId: "B" }];
const eloMap = new Map([["A", 1900], ["B", 1100]]);
let aWins = 0;
for (let i = 0; i < 5_000; i++) {
if (resolveBo3Match(match, games, eloMap).winner === "A") aWins++;
}
expect(aWins).toBeGreaterThan(4000);
});
it("accepts p1/p2 overrides when match slots are null", () => {
const match = makeMatch({ participant1Id: null, participant2Id: null });
const eloMap = new Map([["X", 1500], ["Y", 1500]]);
const result = resolveBo3Match(match, noGames(), eloMap, "X", "Y");
const ids = [result.winner, result.loser].toSorted();
expect(ids).toEqual(["X", "Y"]);
});
it("throws when participant slots are missing and no overrides given", () => {
const match = makeMatch({ participant1Id: null });
const eloMap = new Map<string, number>();
expect(() => resolveBo3Match(match, noGames(), eloMap)).toThrow();
});
});
// ─── Manifest, registry, and schema enum sync ────────────────────────────────
describe("nll_bracket simulator sync", () => {
it("nll_bracket is in SIMULATOR_TYPES", () => {
expect(SIMULATOR_TYPES).toContain("nll_bracket");
});
it("nll_bracket has a manifest profile", () => {
expect(SIMULATOR_MANIFEST["nll_bracket"]).toBeDefined();
});
it("manifest profile requires sourceElo", () => {
expect(SIMULATOR_MANIFEST["nll_bracket"].requiredInputs).toContain("sourceElo");
});
it("manifest profile has projectedWins as optional input", () => {
expect(SIMULATOR_MANIFEST["nll_bracket"].optionalInputs).toContain("projectedWins");
});
it("manifest profile lists regularStandings and bracket setup sections", () => {
const sections = SIMULATOR_MANIFEST["nll_bracket"].setupSections;
expect(sections).toContain("regularStandings");
expect(sections).toContain("bracket");
});
it("manifest profile has sourceElo derivable from projectedWins", () => {
const derivable = SIMULATOR_MANIFEST["nll_bracket"].derivableInputs;
expect(derivable?.sourceElo).toContain("projectedWins");
});
});

View file

@ -191,6 +191,23 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
optionalInputs: [],
setupSections: ["participants"],
},
nll_bracket: {
defaultConfig: {
...BASE_CONFIG,
seasonGames: 18,
parityFactor: 400,
bracketSize: 8,
playoffTeams: 8,
regularSeasonTeamCount: 14,
homeFieldElo: 0,
regularSeasonMode: "project_remaining_games",
regularSeasonNoise: 0.9,
},
requiredInputs: ["sourceElo"],
optionalInputs: ["projectedWins", "sourceOdds", "seed"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
},
};
export const SIMULATOR_MANIFEST: Record<SimulatorType, SimulatorManifestProfile> =

View file

@ -0,0 +1,644 @@
/**
* NLL Season + Playoffs Simulator
*
* Monte Carlo simulation of the NLL regular season and playoffs.
*
* Three modes, auto-detected at runtime:
*
* Mode 1: Bracket-Aware
* Used when a playoff_game scoring event with generated matches exists.
* Reads completed QF/SF/Finals results; simulates remaining games and series.
* Best-of-3 series respect completed playoff_match_games rows.
*
* Mode 2: Known-Seed
* Used when no bracket exists but all 8 seeds 1-8 are set via the seed input.
* Simulates the playoff bracket directly from those seeds.
*
* Mode 3: Regular-Season Projection (default)
* Simulates remaining regular season games per team using Elo vs average (1500),
* blending with projectedWins when provided. Top 8 teams qualify. Then simulates
* the NLL playoff bracket.
*
* NLL playoff bracket format:
* Quarterfinals (single game): 1v8, 2v7, 3v6, 4v5
* Semifinal arms: (1/8 winner vs 4/5 winner), (2/7 winner vs 3/6 winner)
* Semifinals (best-of-3) and Finals (best-of-3)
*
* Probability mapping:
* probFirst = NLL champion (1 per sim)
* probSecond = Finals loser (1 per sim)
* probThird/Fourth = Semifinal losers (2 per sim split evenly)
* probFifthEighth = Quarterfinal losers (4 per sim split evenly)
* Missed playoffs all 0
*
* Round name constants (ROUND_*) must match the bracket template used to
* generate playoff matches. If the template changes round names, update these.
*/
import { database } from "~/database/context";
import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { eloWinProbabilityWithParity } from "~/services/probability-engine";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { getParticipantSimulatorInputs } from "~/models/simulator";
import { logger } from "~/lib/logger";
// ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 50_000;
const NLL_REGULAR_SEASON_GAMES = 18;
const NLL_PLAYOFF_TEAMS = 8;
const PARITY_FACTOR = 400;
const AVERAGE_OPPONENT_ELO = 1500;
/** Maximum preseason weight given to projectedWins in the blend. Decays to 0 by season end. */
const PROJECTED_WINS_WEIGHT = 0.65;
/** Fallback Elo used when a bracket participant has no sourceElo on record. */
const FALLBACK_ELO = 1400;
// ─── Round name constants ─────────────────────────────────────────────────────
// These must match the round strings generated by the nll_bracket bracket template.
export const ROUND_QUARTERFINALS = "Quarterfinals" as const;
export const ROUND_SEMIFINALS = "Semifinals" as const;
export const ROUND_FINALS = "Finals" as const;
// ─── Structural types used by resolve helpers ─────────────────────────────────
/** Minimal match fields needed by the resolve helpers. */
export interface MatchSnapshot {
id: string;
isComplete: boolean;
winnerId: string | null;
loserId: string | null;
participant1Id: string | null;
participant2Id: string | null;
}
/** Minimal game result needed by the resolve helpers. */
export interface GameSnapshot {
winnerId: string | null;
}
// ─── Public helpers (exported for testability) ────────────────────────────────
export function nllGameWinProbability(
eloA: number,
eloB: number,
parityFactor = PARITY_FACTOR
): number {
return eloWinProbabilityWithParity(eloA, eloB, parityFactor);
}
export interface SimTeam {
participantId: string;
elo: number;
seed?: number;
}
export function simulateNllGame(
teamA: SimTeam,
teamB: SimTeam,
parityFactor = PARITY_FACTOR
): { winner: SimTeam; loser: SimTeam } {
const pA = nllGameWinProbability(teamA.elo, teamB.elo, parityFactor);
if (Math.random() < pA) return { winner: teamA, loser: teamB };
return { winner: teamB, loser: teamA };
}
export interface BestOfThreeState {
winsA: number;
winsB: number;
}
/** Simulate a best-of-3 series from an optional existing state. First to 2 wins. */
export function simulateBestOfThree(
teamA: SimTeam,
teamB: SimTeam,
parityFactor = PARITY_FACTOR,
existing: BestOfThreeState = { winsA: 0, winsB: 0 }
): { winner: SimTeam; loser: SimTeam; gamesPlayed: number } {
let wA = existing.winsA;
let wB = existing.winsB;
let gamesPlayed = 0;
const pA = nllGameWinProbability(teamA.elo, teamB.elo, parityFactor);
while (wA < 2 && wB < 2) {
if (Math.random() < pA) wA++; else wB++;
gamesPlayed++;
}
return wA === 2
? { winner: teamA, loser: teamB, gamesPlayed }
: { winner: teamB, loser: teamA, gamesPlayed };
}
export interface SeedEntry {
participantId: string;
elo: number;
seed: number;
}
/**
* Build the 4 NLL Quarterfinal matchups from seeds 1-8.
* Bracket arms are fixed:
* QF1: 1 vs 8 SF arm A
* QF2: 4 vs 5 SF arm A
* QF3: 2 vs 7 SF arm B
* QF4: 3 vs 6 SF arm B
*/
export function buildNllBracketFromSeeds(seeds: SeedEntry[]): [
[SeedEntry, SeedEntry], // QF1: 1 vs 8
[SeedEntry, SeedEntry], // QF2: 4 vs 5
[SeedEntry, SeedEntry], // QF3: 2 vs 7
[SeedEntry, SeedEntry], // QF4: 3 vs 6
] {
const bySeeds = new Map(seeds.map((s) => [s.seed, s]));
const s = (n: number) => {
const entry = bySeeds.get(n);
if (!entry) throw new Error(`Seed ${n} not found in bracket seeds.`);
return entry;
};
return [
[s(1), s(8)],
[s(4), s(5)],
[s(2), s(7)],
[s(3), s(6)],
];
}
export interface NllPlayoffResult {
champion: string;
finalist: string;
sfLosers: [string, string];
qfLosers: [string, string, string, string];
}
/** Simulate the full NLL playoff bracket from 8 seeded teams. */
export function simulateNllPlayoffs(
seeds: SeedEntry[],
parityFactor = PARITY_FACTOR
): NllPlayoffResult {
const [[qf1a, qf1b], [qf2a, qf2b], [qf3a, qf3b], [qf4a, qf4b]] =
buildNllBracketFromSeeds(seeds);
const qf1 = simulateNllGame(qf1a, qf1b, parityFactor);
const qf2 = simulateNllGame(qf2a, qf2b, parityFactor);
const qf3 = simulateNllGame(qf3a, qf3b, parityFactor);
const qf4 = simulateNllGame(qf4a, qf4b, parityFactor);
// SF arm A: QF1 winner vs QF2 winner (1/8 vs 4/5)
const sfA = simulateBestOfThree(qf1.winner, qf2.winner, parityFactor);
// SF arm B: QF3 winner vs QF4 winner (2/7 vs 3/6)
const sfB = simulateBestOfThree(qf3.winner, qf4.winner, parityFactor);
const final = simulateBestOfThree(sfA.winner, sfB.winner, parityFactor);
return {
champion: final.winner.participantId,
finalist: final.loser.participantId,
sfLosers: [sfA.loser.participantId, sfB.loser.participantId],
qfLosers: [
qf1.loser.participantId,
qf2.loser.participantId,
qf3.loser.participantId,
qf4.loser.participantId,
],
};
}
// ─── Bracket-aware resolve helpers (exported for testability) ─────────────────
/**
* Resolve a single-game QF match.
* Returns the stored result deterministically when the match is complete;
* simulates a single game otherwise.
*/
export function resolveQfMatch(
match: MatchSnapshot | undefined,
eloMap: Map<string, number>
): { winner: string; loser: string } {
if (match?.isComplete && match.winnerId && match.loserId) {
return { winner: match.winnerId, loser: match.loserId };
}
const p1 = match?.participant1Id;
const p2 = match?.participant2Id;
if (!p1 || !p2) {
throw new Error(
`NLL QF match ${match?.id ?? "(undefined)"} is missing participant slots. ` +
`Ensure the bracket is fully generated before simulating.`
);
}
const result = simulateNllGame(
{ participantId: p1, elo: eloMap.get(p1) ?? FALLBACK_ELO },
{ participantId: p2, elo: eloMap.get(p2) ?? FALLBACK_ELO }
);
return { winner: result.winner.participantId, loser: result.loser.participantId };
}
/**
* Resolve a best-of-3 SF or Finals match.
* Returns the stored result deterministically when the match is complete.
* Counts completed game rows (GameSnapshot[]) for partial series state; if a
* team already has 2 wins in the game rows, treats the series as done even if
* playoffMatches.isComplete has not been toggled yet.
* Simulates remaining games from the current series score otherwise.
*/
export function resolveBo3Match(
match: MatchSnapshot | undefined,
games: GameSnapshot[],
eloMap: Map<string, number>,
p1Override?: string,
p2Override?: string
): { winner: string; loser: string } {
if (match?.isComplete && match.winnerId && match.loserId) {
return { winner: match.winnerId, loser: match.loserId };
}
const p1 = match?.participant1Id ?? p1Override;
const p2 = match?.participant2Id ?? p2Override;
if (!p1 || !p2) {
throw new Error(
`NLL series match ${match?.id ?? "(undefined)"} is missing participant slots. ` +
`Ensure the bracket is fully generated before simulating.`
);
}
let winsP1 = 0;
let winsP2 = 0;
for (const g of games) {
if (g.winnerId === null) continue;
if (g.winnerId === p1) winsP1++;
else if (g.winnerId === p2) winsP2++;
}
// A team with 2 game wins has clinched the series.
if (winsP1 >= 2) return { winner: p1, loser: p2 };
if (winsP2 >= 2) return { winner: p2, loser: p1 };
const { winner, loser } = simulateBestOfThree(
{ participantId: p1, elo: eloMap.get(p1) ?? FALLBACK_ELO },
{ participantId: p2, elo: eloMap.get(p2) ?? FALLBACK_ELO },
PARITY_FACTOR,
{ winsA: winsP1, winsB: winsP2 }
);
return { winner: winner.participantId, loser: loser.participantId };
}
// ─── Regular-season projection helper ────────────────────────────────────────
export interface TeamProjection {
participantId: string;
elo: number;
currentWins: number;
gamesPlayed: number;
projectedWins: number | null;
}
/** Simulate remaining regular season games and return the top-8 seeds. */
export function simulateRegularSeasonSeeds(
teams: TeamProjection[],
parityFactor = PARITY_FACTOR
): SeedEntry[] {
// Pre-compute tiebreaker jitter once per simulation for sort stability.
const jitter = new Map(teams.map((t) => [t.participantId, Math.random()]));
const projected = teams.map((t) => {
const remaining = Math.max(0, NLL_REGULAR_SEASON_GAMES - t.gamesPlayed);
const eloRate = eloWinProbabilityWithParity(t.elo, AVERAGE_OPPONENT_ELO, parityFactor);
// Decay the preseason projectedWins prior linearly as the season progresses.
// At gamesPlayed=0 it carries PROJECTED_WINS_WEIGHT (65%); by gamesPlayed=18
// it carries 0% so late-season projections rely purely on Elo. This prevents
// a stale preseason estimate from dominating when actual standings are available.
//
// The prior rate is computed over *remaining* games (not the full season) so
// that wins already accumulated are subtracted from the preseason expectation.
// If currentWins already meets or exceeds the projection, the prior is clamped
// to 0 and Elo takes over fully.
const completionFraction = t.gamesPlayed / NLL_REGULAR_SEASON_GAMES;
const projectedBlend =
t.projectedWins !== null ? PROJECTED_WINS_WEIGHT * (1 - completionFraction) : 0;
const priorRate =
t.projectedWins !== null && remaining > 0
? Math.min(1, Math.max(0, t.projectedWins - t.currentWins) / remaining)
: 0;
const winRate =
projectedBlend > 0 && priorRate > 0
? projectedBlend * priorRate + (1 - projectedBlend) * eloRate
: eloRate;
let additionalWins = 0;
for (let g = 0; g < remaining; g++) {
if (Math.random() < winRate) additionalWins++;
}
return {
participantId: t.participantId,
elo: t.elo,
finalWins: t.currentWins + additionalWins,
};
});
const sorted = projected.toSorted(
(a, b) =>
b.finalWins - a.finalWins ||
(jitter.get(b.participantId) ?? 0) - (jitter.get(a.participantId) ?? 0)
);
return sorted.slice(0, NLL_PLAYOFF_TEAMS).map((t, i) => ({
participantId: t.participantId,
elo: t.elo,
seed: i + 1,
}));
}
// ─── Simulator class ──────────────────────────────────────────────────────────
export class NLLSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
// ── Load participants ─────────────────────────────────────────────────────
const participants = await db.query.seasonParticipants.findMany({
where: eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId),
});
if (participants.length === 0) {
throw new Error(`No participants found for sports season ${sportsSeasonId}.`);
}
// ── Load Elo ratings from EV table (populated by prepareSimulatorInputsForRun) ──
const evRows = await db
.select({
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
})
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
const eloMap = new Map<string, number>();
for (const r of evRows) {
if (r.sourceElo !== null && r.sourceElo !== undefined) {
eloMap.set(r.participantId, r.sourceElo);
}
}
if (eloMap.size === 0) {
throw new Error(
`No Elo ratings found for sports season ${sportsSeasonId}. ` +
`Enter sourceElo or projectedWins via Admin → Elo Ratings before simulating.`
);
}
// ── Load simulator inputs (projectedWins + seed) ──────────────────────────
const simInputs = await getParticipantSimulatorInputs(sportsSeasonId);
const projectedWinsMap = new Map<string, number | null>();
const seedMap = new Map<string, number>();
for (const input of simInputs) {
projectedWinsMap.set(input.participantId, input.projectedWins);
if (input.seed !== null && input.seed !== undefined) {
seedMap.set(input.participantId, input.seed);
}
}
// ── All participant IDs for counting ─────────────────────────────────────
const allIds = participants.map((p) => p.id);
// ── Mode detection ────────────────────────────────────────────────────────
// Mode 1: bracket-aware — a playoff_game scoring event with matches exists.
const bracketEvent = await db.query.scoringEvents.findFirst({
where: and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.eventType, "playoff_game")
),
});
if (bracketEvent) {
const bracketMatches = await db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
orderBy: (m, { asc }) => [asc(m.matchNumber)],
});
if (bracketMatches.length > 0) {
return this.simulateBracketAware(allIds, eloMap, bracketMatches);
}
}
// Mode 2: known-seed — all 8 seeds 1-8 are explicitly set.
const knownSeeds: SeedEntry[] = [];
for (const p of participants) {
const elo = eloMap.get(p.id);
const seed = seedMap.get(p.id);
if (elo !== undefined && seed !== undefined && seed >= 1 && seed <= 8) {
knownSeeds.push({ participantId: p.id, elo, seed });
}
}
if (
knownSeeds.length === NLL_PLAYOFF_TEAMS &&
new Set(knownSeeds.map((s) => s.seed)).size === NLL_PLAYOFF_TEAMS
) {
return this.simulateKnownSeeds(allIds, knownSeeds);
}
// Mode 3: regular-season projection (default, including preseason).
const standingsRows = await getRegularSeasonStandings(sportsSeasonId, db);
const standingsMap = new Map(standingsRows.map((r) => [r.participantId, r]));
const teams: TeamProjection[] = [];
for (const p of participants) {
const elo = eloMap.get(p.id);
if (elo === undefined) continue;
const standing = standingsMap.get(p.id);
teams.push({
participantId: p.id,
elo,
currentWins: standing?.wins ?? 0,
gamesPlayed: standing?.gamesPlayed ?? 0,
projectedWins: projectedWinsMap.get(p.id) ?? null,
});
}
if (teams.length === 0) {
throw new Error(
`No participants with Elo ratings found for season ${sportsSeasonId}.`
);
}
return this.simulateRegularSeason(allIds, teams);
}
// ── Mode 1: Bracket-Aware ─────────────────────────────────────────────────
private async simulateBracketAware(
allIds: string[],
eloMap: Map<string, number>,
allMatches: typeof schema.playoffMatches.$inferSelect[]
): Promise<SimulationResult[]> {
const db = database();
const qfMatches = allMatches
.filter((m) => m.round === ROUND_QUARTERFINALS)
.toSorted((a, b) => a.matchNumber - b.matchNumber);
const sfMatches = allMatches
.filter((m) => m.round === ROUND_SEMIFINALS)
.toSorted((a, b) => a.matchNumber - b.matchNumber);
const finalMatches = allMatches.filter((m) => m.round === ROUND_FINALS);
if (qfMatches.length !== 4 || sfMatches.length !== 2 || finalMatches.length !== 1) {
throw new Error(
`NLL bracket has unexpected structure. Expected QF×4, SF×2, Finals×1. ` +
`Got QF×${qfMatches.length}, SF×${sfMatches.length}, Finals×${finalMatches.length}. ` +
`Ensure the bracket was generated with the nll_bracket template.`
);
}
// Warn about any bracket participants without Elo ratings before the hot loop.
const bracketParticipantIds = new Set<string>();
for (const m of allMatches) {
if (m.participant1Id) bracketParticipantIds.add(m.participant1Id);
if (m.participant2Id) bracketParticipantIds.add(m.participant2Id);
if (m.winnerId) bracketParticipantIds.add(m.winnerId);
if (m.loserId) bracketParticipantIds.add(m.loserId);
}
for (const id of bracketParticipantIds) {
if (!eloMap.has(id)) {
logger.warn(
`NLL bracket simulator: no Elo rating for participant ${id}. ` +
`Using fallback Elo ${FALLBACK_ELO}. Enter sourceElo via Admin → Elo Ratings.`
);
}
}
// Load playoff_match_games for best-of-3 series state.
const sfAndFinalIds = [...sfMatches, ...finalMatches].map((m) => m.id);
const allGames = sfAndFinalIds.length > 0
? await db.query.playoffMatchGames.findMany({
where: (g, { inArray }) => inArray(g.playoffMatchId, sfAndFinalIds),
})
: [];
const gamesByMatch = new Map<string, typeof schema.playoffMatchGames.$inferSelect[]>();
for (const game of allGames) {
const list = gamesByMatch.get(game.playoffMatchId) ?? [];
list.push(game);
gamesByMatch.set(game.playoffMatchId, list);
}
const championCounts = new Map(allIds.map((id) => [id, 0]));
const finalistCounts = new Map(allIds.map((id) => [id, 0]));
const sfLoserCounts = new Map(allIds.map((id) => [id, 0]));
const qfLoserCounts = new Map(allIds.map((id) => [id, 0]));
const [qf1, qf2, qf3, qf4] = qfMatches;
const [sf1, sf2] = sfMatches;
const finalMatch = finalMatches[0];
for (let s = 0; s < NUM_SIMULATIONS; s++) {
const r_qf1 = resolveQfMatch(qf1, eloMap);
const r_qf2 = resolveQfMatch(qf2, eloMap);
const r_qf3 = resolveQfMatch(qf3, eloMap);
const r_qf4 = resolveQfMatch(qf4, eloMap);
// SF arm A: QF1 winner vs QF2 winner (1/8 vs 4/5 bracket arm)
const r_sf1 = resolveBo3Match(sf1, gamesByMatch.get(sf1.id) ?? [], eloMap, r_qf1.winner, r_qf2.winner);
// SF arm B: QF3 winner vs QF4 winner (2/7 vs 3/6 bracket arm)
const r_sf2 = resolveBo3Match(sf2, gamesByMatch.get(sf2.id) ?? [], eloMap, r_qf3.winner, r_qf4.winner);
const r_final = resolveBo3Match(finalMatch, gamesByMatch.get(finalMatch.id) ?? [], eloMap, r_sf1.winner, r_sf2.winner);
championCounts.set(r_final.winner, (championCounts.get(r_final.winner) ?? 0) + 1);
finalistCounts.set(r_final.loser, (finalistCounts.get(r_final.loser) ?? 0) + 1);
sfLoserCounts.set(r_sf1.loser, (sfLoserCounts.get(r_sf1.loser) ?? 0) + 1);
sfLoserCounts.set(r_sf2.loser, (sfLoserCounts.get(r_sf2.loser) ?? 0) + 1);
qfLoserCounts.set(r_qf1.loser, (qfLoserCounts.get(r_qf1.loser) ?? 0) + 1);
qfLoserCounts.set(r_qf2.loser, (qfLoserCounts.get(r_qf2.loser) ?? 0) + 1);
qfLoserCounts.set(r_qf3.loser, (qfLoserCounts.get(r_qf3.loser) ?? 0) + 1);
qfLoserCounts.set(r_qf4.loser, (qfLoserCounts.get(r_qf4.loser) ?? 0) + 1);
}
return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts);
}
// ── Mode 2: Known-Seed ────────────────────────────────────────────────────
private simulateKnownSeeds(
allIds: string[],
seeds: SeedEntry[]
): SimulationResult[] {
const championCounts = new Map(allIds.map((id) => [id, 0]));
const finalistCounts = new Map(allIds.map((id) => [id, 0]));
const sfLoserCounts = new Map(allIds.map((id) => [id, 0]));
const qfLoserCounts = new Map(allIds.map((id) => [id, 0]));
for (let s = 0; s < NUM_SIMULATIONS; s++) {
const result = simulateNllPlayoffs(seeds);
championCounts.set(result.champion, (championCounts.get(result.champion) ?? 0) + 1);
finalistCounts.set(result.finalist, (finalistCounts.get(result.finalist) ?? 0) + 1);
for (const id of result.sfLosers) {
sfLoserCounts.set(id, (sfLoserCounts.get(id) ?? 0) + 1);
}
for (const id of result.qfLosers) {
qfLoserCounts.set(id, (qfLoserCounts.get(id) ?? 0) + 1);
}
}
return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts);
}
// ── Mode 3: Regular-Season Projection ────────────────────────────────────
private simulateRegularSeason(
allIds: string[],
teams: TeamProjection[]
): SimulationResult[] {
const championCounts = new Map(allIds.map((id) => [id, 0]));
const finalistCounts = new Map(allIds.map((id) => [id, 0]));
const sfLoserCounts = new Map(allIds.map((id) => [id, 0]));
const qfLoserCounts = new Map(allIds.map((id) => [id, 0]));
for (let s = 0; s < NUM_SIMULATIONS; s++) {
const seeds = simulateRegularSeasonSeeds(teams);
const result = simulateNllPlayoffs(seeds);
championCounts.set(result.champion, (championCounts.get(result.champion) ?? 0) + 1);
finalistCounts.set(result.finalist, (finalistCounts.get(result.finalist) ?? 0) + 1);
for (const id of result.sfLosers) {
sfLoserCounts.set(id, (sfLoserCounts.get(id) ?? 0) + 1);
}
for (const id of result.qfLosers) {
qfLoserCounts.set(id, (qfLoserCounts.get(id) ?? 0) + 1);
}
}
return this.buildResults(allIds, championCounts, finalistCounts, sfLoserCounts, qfLoserCounts);
}
// ── Shared result builder ─────────────────────────────────────────────────
private buildResults(
allIds: string[],
championCounts: Map<string, number>,
finalistCounts: Map<string, number>,
sfLoserCounts: Map<string, number>,
qfLoserCounts: Map<string, number>
): SimulationResult[] {
const N = NUM_SIMULATIONS;
return allIds.map((id) => ({
participantId: id,
probabilities: {
probFirst: (championCounts.get(id) ?? 0) / N,
probSecond: (finalistCounts.get(id) ?? 0) / N,
// 2 SF losers per sim — split evenly across 3rd/4th
probThird: (sfLoserCounts.get(id) ?? 0) / N / 2,
probFourth: (sfLoserCounts.get(id) ?? 0) / N / 2,
// 4 QF losers per sim — split evenly across 5th8th
probFifth: (qfLoserCounts.get(id) ?? 0) / N / 4,
probSixth: (qfLoserCounts.get(id) ?? 0) / N / 4,
probSeventh: (qfLoserCounts.get(id) ?? 0) / N / 4,
probEighth: (qfLoserCounts.get(id) ?? 0) / N / 4,
},
source: "nll_bracket_monte_carlo",
}));
}
}

View file

@ -29,6 +29,7 @@ import { NCAAFootballSimulator } from "./ncaa-football-simulator";
import { LLWSSimulator } from "./llws-simulator";
import { CollegeHockeySimulator } from "./college-hockey-simulator";
import { BracktSimulator } from "./brackt-simulator";
import { NLLSimulator } from "./nll-simulator";
export const SIMULATOR_TYPES = [
"f1_standings",
@ -54,6 +55,7 @@ export const SIMULATOR_TYPES = [
"llws_bracket",
"college_hockey_bracket",
"brackt",
"nll_bracket",
] as const;
export type SimulatorType = typeof SIMULATOR_TYPES[number];
@ -182,6 +184,13 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
},
create: () => new BracktSimulator(),
},
nll_bracket: {
info: {
name: "NLL Season + Playoffs Monte Carlo",
description: "Projects NLL regular season standings via Elo (14 teams, 18 games) to determine the top-8 playoff field, then simulates the bracket (QF single-game; SF and Finals best-of-3). Reads current standings from DB; falls back to full-season projection pre-season.",
},
create: () => new NLLSimulator(),
},
};
export function getSimulator(simulatorType: SimulatorType): Simulator {

View file

@ -63,6 +63,11 @@ const CONFIG: Record<SimulatorType, SimulatorConfig | null> = {
parityFactor: 400,
averageOpponentElo: 1500,
},
nll_bracket: {
seasonGames: 18,
parityFactor: 400,
averageOpponentElo: 1500,
},
// Simulators below don't use projected-wins → Elo conversion.
// They can be populated later if needed.
f1_standings: null,

View file

@ -154,6 +154,7 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [
"llws_bracket",
"college_hockey_bracket",
"brackt",
"nll_bracket",
]);
export const tournamentStatusEnum = pgEnum("tournament_status", [

View file

@ -0,0 +1,272 @@
# NLL Box Lacrosse Simulator Implementation Plan
## Goal
Implement National Lacrosse League (NLL) box lacrosse as a preseason-draftable sport. Brackt leagues should be able to draft NLL teams before the regular season starts, while the simulator projects both:
1. the 18-game regular season and top-eight playoff qualification; and
2. the NLL playoff bracket: single-elimination Quarterfinals followed by best-of-three Semifinals and best-of-three NLL Finals.
This is **not** a playoff-only MVP. The simulator must support all NLL teams in preseason and in-season states, then converge naturally to bracket-only behavior once the playoff field is known.
## Current NLL format assumptions
Use these assumptions until the league changes format or an admin overrides season config:
- NLL uses unified single-table standings.
- The league has 14 franchises.
- Each team plays an 18-game regular season.
- Top eight teams qualify for the playoffs.
- Quarterfinal matchups are `1 vs 8`, `2 vs 7`, `3 vs 6`, and `4 vs 5`.
- Quarterfinals are single elimination.
- Semifinals and NLL Finals are best-of-three series.
- Bracket arms should be fixed as `(1 vs 8) winner vs (4 vs 5) winner` and `(2 vs 7) winner vs (3 vs 6) winner`, matching the published NLL bracket layout.
References checked May 12, 2026:
- Official NLL playoff bracket page: <https://www.nll.com/playoff-bracket/>
- Official 2026 playoff schedule/format announcement: <https://www.nll.com/news/national-lacrosse-league-unveils-2026-playoff-schedule-and-quarterfinal-matchups/>
- Official unified standings/playoff structure announcement: <https://www.nll.com/news/nll-adopts-unified-standings-format-and-updated-playoff-structure-for-the-2023-24-season/>
- Official NLL standings page: <https://www.nll.com/standings/>
## Architecture fit
Follow the simulator guide:
- Add a dedicated `nll_bracket` `simulatorType`.
- Keep mutable season inputs in `season_participant_simulator_inputs`; do not hardcode current teams, current standings, odds, seeds, or ratings in the simulator.
- Query simulator config and inputs through the simulator model/runner pathways already used by admin routes.
- Use the existing input-policy system so direct Elo can be derived from projected wins or futures odds.
- Return the standard top-eight probability columns used by expected values.
## Data model and config changes
### Simulator type
Add `nll_bracket` in all simulator type locations:
- `database/schema.ts` `simulatorTypeEnum`
- `app/services/simulations/registry.ts` `SIMULATOR_TYPES`
- `app/services/simulations/registry.ts` registry entry
- `app/services/simulations/manifest.ts` profile map
- `app/services/simulations/simulator-config.ts` projected-wins config
Generate the enum migration with `npm run db:generate`; never hand-write migration SQL.
### Manifest profile
Recommended profile:
```ts
nll_bracket: {
defaultConfig: {
iterations: 50_000,
seasonGames: 18,
parityFactor: 400,
bracketSize: 8,
playoffTeams: 8,
regularSeasonTeamCount: 14,
regularSeasonMode: "project_remaining_games",
regularSeasonNoise: 0.9,
homeFieldElo: 0,
},
requiredInputs: ["sourceElo"],
optionalInputs: ["projectedWins", "sourceOdds", "seed"],
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
setupSections: ["participants", "eloRatings", "regularStandings", "bracket"],
}
```
`projectedWins` is the preferred preseason admin input. Direct `sourceElo` should override it, and futures odds can remain a fallback/alternative.
### Simulator config
Add `nll_bracket` to `app/services/simulations/simulator-config.ts`:
```ts
nll_bracket: {
seasonGames: 18,
parityFactor: 400,
averageOpponentElo: 1500,
}
```
This enables generic projected-wins-to-Elo handling in admin setup.
## Simulator algorithm
Create `app/services/simulations/nll-simulator.ts` with an `NLLSimulator implements Simulator`.
### Inputs loaded per run
Load:
- all `seasonParticipants` for the sports season;
- resolved `sourceElo` values from simulator inputs / EV compatibility bridge;
- optional `projectedWins` for preseason regular-season projection;
- optional `seed` values for known playoff seeds;
- regular-season standings from the existing regular standings table when available;
- playoff bracket event/matches/games when available.
Fail readiness through the manifest/input policy if required Elo cannot be resolved for every participant that can affect the playoff field. Do not silently assign Elo unless the season config explicitly enables an input-policy fallback.
### Mode selection
The simulator should choose the most complete available state:
1. **Bracket-aware mode**: if a populated NLL playoff bracket exists, honor it.
- Use completed match winners/losers.
- Use completed game rows inside best-of-three matches when present.
- Simulate unresolved games/matches.
2. **Known-seed mode**: if no bracket exists but eight teams have seeds 1-8, simulate the playoff bracket directly from those seeds.
3. **Regular-season projection mode**: otherwise, simulate the full regular season each iteration, derive top-eight seeds, then simulate playoffs.
Regular-season projection mode is required for preseason draftability.
### Regular-season projection mode
For each Monte Carlo iteration:
1. Start each team from current standings if present:
- `wins`, `losses`, and `gamesPlayed` from the regular standings table;
- if standings are absent, start all teams at `0-0`.
2. Determine remaining games:
- `remainingGames = max(0, seasonGames - gamesPlayed)`.
3. Convert Elo to game win probability against an average opponent:
- `p = 1 / (1 + 10 ** ((averageOpponentElo - teamElo) / parityFactor))`.
4. Project additional wins:
- preseason/default option: sample `remainingGames` Bernoulli trials at `p`;
- if `projectedWins` exists, calibrate the team's mean final wins toward that projection by blending Elo-derived win rate with projected-win rate;
- preserve current wins as a floor, so projected final wins cannot fall below already-earned wins.
5. Rank all teams by simulated final wins, with random tiebreaker for teams tied on wins.
6. Take the top eight as seeds 1-8.
7. Simulate the playoff bracket for those seeds.
Recommended projection blend for phase one:
```ts
projectedRate = projectedWins / seasonGames
eloRate = eloGameWinProbability(teamElo, averageOpponentElo, parityFactor)
regularSeasonWinRate = projectedWins == null
? eloRate
: 0.65 * projectedRate + 0.35 * eloRate
```
Keep `0.65` configurable as `projectedWinsWeight` if added to `defaultConfig`.
### Playoff simulation
For playoff games, use team-vs-team Elo probability:
```ts
pA = 1 / (1 + 10 ** ((eloB - eloA) / parityFactor))
```
Then simulate:
- Quarterfinals: one game.
- Semifinals: best of three, first team to two wins.
- Finals: best of three, first team to two wins.
Export small helpers for testability:
- `nllGameWinProbability(eloA, eloB, parityFactor)`
- `simulateNllGame(teamA, teamB, parityFactor)`
- `simulateBestOfThree(teamA, teamB, parityFactor, existingWins?)`
- `buildNllBracketFromSeeds(seedEntries)`
- `simulateNllPlayoffs(seedEntries, options)`
- `simulateRegularSeasonSeeds(teams, options)`
### Completed results and partial series
When a playoff bracket exists:
- A completed match with `winnerId` should be deterministic.
- For best-of-three matches, completed `playoff_match_games` rows should set current series wins.
- If a team already has two series wins, treat the series as complete even if `playoffMatches.isComplete` has not yet been toggled.
- If only one game is complete, simulate the remaining game(s) from the current 1-0 state.
### Probability mapping
Map each iteration to Brackt's standard top-eight shape:
- champion: `probFirst`
- finalist: `probSecond`
- two semifinal losers: split evenly across `probThird` and `probFourth`
- four quarterfinal losers: split evenly across `probFifth`, `probSixth`, `probSeventh`, `probEighth`
- non-playoff teams in a given iteration receive no placement count for that iteration
Across all participants:
- `probFirst` sums to 1;
- `probSecond` sums to 1;
- `probThird` and `probFourth` each sum to 1;
- `probFifth` through `probEighth` each sum to 1.
## Admin and data setup
### Preseason setup workflow
1. Create the NLL sport with `simulatorType = "nll_bracket"`.
2. Create the NLL sports season with all 14 teams as participants.
3. Import preseason `projectedWins` for every team through the generic simulator CSV importer.
4. Optionally import championship futures odds.
5. Run the simulator before draft rooms open; all 14 teams should receive EV based on playoff qualification and bracket outcomes.
### In-season workflow
1. Sync or manually update regular standings.
2. Re-run the simulator as standings change.
3. `projectedWins` can remain a preseason prior, but current standings should increasingly dominate as games are played.
### Playoff workflow
1. Once seeds are final, enter seeds or create the bracket.
2. If bracket exists, the simulator should stop projecting regular-season qualification and use bracket-aware mode.
3. As playoff games complete, update match/game results and re-run the simulator.
## Testing plan
Add `app/services/simulations/__tests__/nll-simulator.test.ts`.
Required tests:
- projected-wins-to-Elo readiness works for `nll_bracket`;
- regular-season projection can run from a preseason `0-0` state with 14 teams;
- higher projected wins increase playoff qualification and title probability;
- only eight teams qualify in each simulated iteration;
- seed ordering builds fixed bracket arms correctly: `(1,8)/(4,5)` and `(2,7)/(3,6)`;
- Quarterfinals are single-game;
- Semifinals and Finals are best-of-three;
- partial best-of-three state is honored;
- completed matches are deterministic;
- all output probability columns sum correctly;
- manifest, registry, and schema enum stay in sync.
Run at minimum:
```bash
npm run typecheck
npm run lint
npm run test:run
```
## Implementation task breakdown
1. Add schema enum and migration for `nll_bracket`.
2. Add manifest/config/registry entries.
3. Implement pure NLL simulator helpers.
4. Implement regular-season projection mode.
5. Implement known-seed playoff mode.
6. Implement bracket-aware mode with completed match/game support.
7. Add unit tests and readiness/manifest regression coverage.
8. Seed/create admin data for the NLL sport and current season outside of production code.
9. Run required checks.
10. Verify `/admin/simulators` shows actionable setup state and can run preseason NLL seasons.
## Explicit non-goals
- Do not hardcode current NLL standings, team Elo values, futures odds, or playoff seeds into production simulator code.
- Do not make NLL playoff-only; preseason draftability requires regular-season simulation.
- Do not add route-level Drizzle queries for simulator setup; use model/service layers.
- Do not create migration SQL manually.

View file

@ -23,6 +23,18 @@ Before running older Elo-based simulators, the runner materializes resolved Elo
The runner also materializes derived `rating` values for rating-based simulators that declare that support, such as preseason NCAAM/NCAAW using futures odds before KenPom/Barttorvik-style ratings are available.
## Preseason Draftability
Brackt sports are expected to be draftable before their real-world season starts. When adding or revising a team-sport simulator, design for preseason and in-season runs first:
- Include all draftable teams/participants, not only the eventual playoff field.
- Simulate regular-season qualification or standings when playoff participation is not known yet.
- Let current standings override or blend with preseason projections as the season progresses.
- Use projected wins, projected table points, futures odds, direct ratings, or sport-specific ratings as season-scoped inputs that can drive preseason EVs.
- Switch to bracket-aware simulation only when a real bracket or finalized seeds exist.
A playoff-only simulator is not sufficient for team sports that Brackt drafts in preseason. If a sport ever has a true exception, that exception must be explicit in the sport-specific implementation plan and product requirements.
## Season Scoping
Never store mutable simulator state on `sports`. A sport can have multiple active seasons at once, such as NHL playoffs for one season and preseason drafting for the next. Each sports season needs independent:

View file

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

File diff suppressed because it is too large Load diff

View file

@ -722,6 +722,13 @@
"when": 1778539362096,
"tag": "0102_wild_wiccan",
"breakpoints": true
},
{
"idx": 103,
"version": "7",
"when": 1778611567910,
"tag": "0103_salty_runaways",
"breakpoints": true
}
]
}