import { and, eq, inArray } from "drizzle-orm"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { buildNCAA68SlotMap, matchIndexForSeedSlot, NCAA_68, STANDARD_BRACKET_SEEDING, type BracketRegion, } from "~/lib/bracket-templates"; import { convertAmericanOddsToProbability } from "~/services/probability-engine"; import type { Simulator, SimulationResult } from "./types"; const NUM_SIMULATIONS = 50_000; const FIELD_SIZE = 68; const ODDS_SELECTION_BONUS = 0.1; type PlayoffMatch = typeof schema.playoffMatches.$inferSelect; type Db = ReturnType; interface Team { participantId: string; name: string; rating: number; sourceOdds: number | null; } interface PlacementCounts { champion: number; finalist: number; finalFourLoser: number; eliteEightLoser: number; } interface NCAARegionAssignment { directSeeds: Map; playIns: Map; } interface NCAABasketballSimulatorOptions { source: string; missingRatingName: string; selectionRatingDivisor: number; winProbability: (ratingA: number, ratingB: number) => number; getWinProbability?: (config: Record) => (ratingA: number, ratingB: number) => number; } export interface FirstFourMapping { firstFourMatchNumber: number; r64MatchNumber: number; seedSlot: number; regionName: string; } export function buildFirstFourMappings(regions: BracketRegion[] = NCAA_68.regions ?? []): FirstFourMapping[] { const slotMap = buildNCAA68SlotMap(regions); return slotMap.playInOffsets.map((playIn, index) => ({ firstFourMatchNumber: index + 1, r64MatchNumber: playIn.regionIndex * 8 + matchIndexForSeedSlot(playIn.seedSlot) + 1, seedSlot: playIn.seedSlot, regionName: regions[playIn.regionIndex]?.name ?? `Region ${playIn.regionIndex + 1}`, })); } function sortByMatchNumber(matches: T[]): T[] { return [...matches].toSorted((a, b) => a.matchNumber - b.matchNumber); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } async function getSeasonSimulatorConfig(db: Db, sportsSeasonId: string): Promise> { const season = await db.query.sportsSeasons.findFirst({ where: eq(schema.sportsSeasons.id, sportsSeasonId), with: { sport: true, simulatorConfig: true, }, }); const simulatorType = season?.simulatorConfig?.simulatorType ?? season?.sport?.simulatorType; if (!simulatorType) return {}; const profile = await db.query.simulatorProfiles.findFirst({ where: eq(schema.simulatorProfiles.simulatorType, simulatorType), columns: { defaultConfig: true }, }); return { ...(isRecord(profile?.defaultConfig) ? profile.defaultConfig : {}), ...(isRecord(season?.simulatorConfig?.config) ? season.simulatorConfig.config : {}), }; } function bump(counts: Map, participantId: string | null | undefined, key: keyof PlacementCounts): void { if (!participantId) return; const entry = counts.get(participantId); if (entry) entry[key]++; } function makeCounts(participantIds: string[]): Map { return new Map( participantIds.map((participantId) => [ participantId, { champion: 0, finalist: 0, finalFourLoser: 0, eliteEightLoser: 0 }, ]) ); } function completedLoser(match: PlayoffMatch): string { if (match.loserId) return match.loserId; if (match.participant1Id === match.winnerId && match.participant2Id) return match.participant2Id; if (match.participant2Id === match.winnerId && match.participant1Id) return match.participant1Id; throw new Error(`Completed ${match.round} match ${match.matchNumber} is missing a loser.`); } function normalizeOdds(teams: Team[]): Map { const raw = new Map(); for (const team of teams) { if (team.sourceOdds !== null) { raw.set(team.participantId, convertAmericanOddsToProbability(team.sourceOdds)); } } const total = [...raw.values()].reduce((sum, value) => sum + value, 0); const normalized = new Map(); if (total <= 0) return normalized; for (const [participantId, probability] of raw) { normalized.set(participantId, probability / total); } return normalized; } function buildSelectionWeights(teams: Team[], selectionRatingDivisor: number): Map { const normalizedOdds = normalizeOdds(teams); const hasOdds = normalizedOdds.size > 0; const maxRating = Math.max(...teams.map((team) => team.rating)); const divisor = Math.max(selectionRatingDivisor, 0.0001); const ratingWeights = new Map(); for (const team of teams) { ratingWeights.set(team.participantId, Math.max(Math.exp((team.rating - maxRating) / divisor), 0.000001)); } const ratingTotal = [...ratingWeights.values()].reduce((sum, value) => sum + value, 0); const weights = new Map(); for (const team of teams) { const ratingShare = (ratingWeights.get(team.participantId) ?? 0.000001) / ratingTotal; const weight = hasOdds ? (normalizedOdds.get(team.participantId) ?? 0) + ODDS_SELECTION_BONUS * ratingShare : ratingShare; weights.set(team.participantId, Math.max(weight, 0.000001)); } return weights; } function sampleField(teams: Team[], fieldSize: number, selectionRatingDivisor: number): Team[] { const weights = buildSelectionWeights(teams, selectionRatingDivisor); const remaining = [...teams]; const selected: Team[] = []; for (let i = 0; i < fieldSize; i++) { const totalWeight = remaining.reduce((sum, team) => sum + (weights.get(team.participantId) ?? 0), 0); let cursor = Math.random() * totalWeight; let index = 0; for (; index < remaining.length - 1; index++) { cursor -= weights.get(remaining[index].participantId) ?? 0; if (cursor <= 0) break; } selected.push(remaining[index]); remaining.splice(index, 1); } return selected; } export function assignFieldToNCAA68Regions( field: Team[], regions: BracketRegion[] = NCAA_68.regions ?? [] ): NCAARegionAssignment[] { if (field.length !== FIELD_SIZE) { throw new Error(`Expected ${FIELD_SIZE} teams for NCAA preseason field, received ${field.length}.`); } const assignments = regions.map((): NCAARegionAssignment => ({ directSeeds: new Map(), playIns: new Map(), })); const sorted = field .map((team) => ({ team, tiebreaker: Math.random() })) .toSorted((a, b) => b.team.rating - a.team.rating || b.tiebreaker - a.tiebreaker) .map((entry) => entry.team); let cursor = 0; for (const seed of Array.from({ length: 16 }, (_, index) => index + 1)) { for (let regionIndex = 0; regionIndex < regions.length; regionIndex++) { const region = regions[regionIndex]; const playIn = region.playIns.find((entry) => entry.seedSlot === seed); if (playIn) { assignments[regionIndex].playIns.set(seed, [sorted[cursor], sorted[cursor + 1]]); cursor += 2; } else { assignments[regionIndex].directSeeds.set(seed, sorted[cursor]); cursor++; } } } return assignments; } function simulateGame( team1: Team, team2: Team, winProbability: (ratingA: number, ratingB: number) => number ): { winner: Team; loser: Team } { const winner = Math.random() < winProbability(team1.rating, team2.rating) ? team1 : team2; return { winner, loser: winner === team1 ? team2 : team1 }; } function buildPreseasonRoundOf64( assignments: NCAARegionAssignment[], winProbability: (ratingA: number, ratingB: number) => number ): Array<[Team, Team]> { const r64: Array<[Team, Team]> = []; for (const region of assignments) { for (const [seedA, seedB] of STANDARD_BRACKET_SEEDING) { const directA = region.directSeeds.get(seedA); const directB = region.directSeeds.get(seedB); const playInA = region.playIns.get(seedA); const playInB = region.playIns.get(seedB); const teamA = directA ?? (playInA ? simulateGame(playInA[0], playInA[1], winProbability).winner : null); const teamB = directB ?? (playInB ? simulateGame(playInB[0], playInB[1], winProbability).winner : null); if (!teamA || !teamB) { throw new Error(`NCAA preseason field is missing a Round of 64 team for ${seedA} vs ${seedB}.`); } r64.push([teamA, teamB]); } } return r64; } function simulateRoundOf64Bracket( r64Pairs: Array<[Team, Team]>, counts: Map, winProbability: (ratingA: number, ratingB: number) => number ): void { const r64Winners = r64Pairs.map(([team1, team2]) => simulateGame(team1, team2, winProbability).winner); const r32Winners: Team[] = []; for (let i = 0; i < 16; i++) { r32Winners.push(simulateGame(r64Winners[i * 2], r64Winners[i * 2 + 1], winProbability).winner); } const s16Winners: Team[] = []; for (let i = 0; i < 8; i++) { s16Winners.push(simulateGame(r32Winners[i * 2], r32Winners[i * 2 + 1], winProbability).winner); } const e8Winners: Team[] = []; for (let i = 0; i < 4; i++) { const result = simulateGame(s16Winners[i * 2], s16Winners[i * 2 + 1], winProbability); e8Winners.push(result.winner); bump(counts, result.loser.participantId, "eliteEightLoser"); } const ffWinners: Team[] = []; for (let i = 0; i < 2; i++) { const result = simulateGame(e8Winners[i * 2], e8Winners[i * 2 + 1], winProbability); ffWinners.push(result.winner); bump(counts, result.loser.participantId, "finalFourLoser"); } const championship = simulateGame(ffWinners[0], ffWinners[1], winProbability); bump(counts, championship.winner.participantId, "champion"); bump(counts, championship.loser.participantId, "finalist"); } function countsToResults(participantIds: string[], counts: Map, source: string): SimulationResult[] { const N = NUM_SIMULATIONS; return participantIds.map((participantId) => { const count = counts.get(participantId) ?? { champion: 0, finalist: 0, finalFourLoser: 0, eliteEightLoser: 0 }; return { participantId, probabilities: { probFirst: count.champion / N, probSecond: count.finalist / N, probThird: count.finalFourLoser / (2 * N), probFourth: count.finalFourLoser / (2 * N), probFifth: count.eliteEightLoser / (4 * N), probSixth: count.eliteEightLoser / (4 * N), probSeventh: count.eliteEightLoser / (4 * N), probEighth: count.eliteEightLoser / (4 * N), }, source, }; }); } async function loadTeams( db: Db, sportsSeasonId: string, participantIds?: string[], missingRatingName = "rating" ): Promise { const participantRows = participantIds ? await db .select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name }) .from(schema.seasonParticipants) .where(inArray(schema.seasonParticipants.id, participantIds)) : await db .select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name }) .from(schema.seasonParticipants) .where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId)); const simulatorInputs = await db .select({ participantId: schema.seasonParticipantSimulatorInputs.participantId, rating: schema.seasonParticipantSimulatorInputs.rating, sourceOdds: schema.seasonParticipantSimulatorInputs.sourceOdds, }) .from(schema.seasonParticipantSimulatorInputs) .where(eq(schema.seasonParticipantSimulatorInputs.sportsSeasonId, sportsSeasonId)); const inputById = new Map(simulatorInputs.map((input) => [input.participantId, input])); const foundIds = new Set(participantRows.map((row) => row.id)); const missingParticipants = participantIds?.filter((participantId) => !foundIds.has(participantId)) ?? []; if (missingParticipants.length > 0) { throw new Error(`Bracket includes ${missingParticipants.length} participant(s) that are not in this sports season.`); } const teams = participantRows.map((participant): Team => { const input = inputById.get(participant.id); const rating = input?.rating !== null && input?.rating !== undefined ? Number(input.rating) : null; if (rating === null || !Number.isFinite(rating)) { throw new Error( `${participant.name} is missing a prepared ${missingRatingName}. ` + `Enter direct ratings, futures odds, or configure an explicit rating fallback before running this simulator.` ); } return { participantId: participant.id, name: participant.name, rating, sourceOdds: input?.sourceOdds ?? null, }; }); return participantIds ? participantIds.map((participantId) => { const team = teams.find((candidate) => candidate.participantId === participantId); if (!team) throw new Error(`Participant ${participantId} is missing from the prepared team map.`); return team; }) : teams; } function groupMatchesByRound(matches: PlayoffMatch[]): { firstFourMatches: PlayoffMatch[]; r64Matches: PlayoffMatch[]; r32Matches: PlayoffMatch[]; s16Matches: PlayoffMatch[]; e8Matches: PlayoffMatch[]; ffMatches: PlayoffMatch[]; champMatch: PlayoffMatch; } { const byRound = new Map(); for (const match of matches) { if (!byRound.has(match.round)) byRound.set(match.round, []); byRound.get(match.round)?.push(match); } const firstFourMatches = sortByMatchNumber(byRound.get("First Four") ?? []); const r64Matches = sortByMatchNumber(byRound.get("Round of 64") ?? []); const r32Matches = sortByMatchNumber(byRound.get("Round of 32") ?? []); const s16Matches = sortByMatchNumber(byRound.get("Sweet Sixteen") ?? []); const e8Matches = sortByMatchNumber(byRound.get("Elite Eight") ?? []); const ffMatches = sortByMatchNumber(byRound.get("Final Four") ?? []); const champMatches = sortByMatchNumber(byRound.get("Championship") ?? []); if ( r64Matches.length !== 32 || r32Matches.length !== 16 || s16Matches.length !== 8 || e8Matches.length !== 4 || ffMatches.length !== 2 || champMatches.length !== 1 ) { const found = [...byRound.keys()].join(", "); throw new Error( `Missing expected NCAA rounds. Found: [${found}]. ` + `Required: Round of 64, Round of 32, Sweet Sixteen, Elite Eight, Final Four, Championship.` ); } return { firstFourMatches, r64Matches, r32Matches, s16Matches, e8Matches, ffMatches, champMatch: champMatches[0] }; } function validateFirstFour( firstFourMatches: PlayoffMatch[], r64Matches: PlayoffMatch[], regions: BracketRegion[] ): Map { const ffToR64 = new Map(); const r64NullSlots = new Set( r64Matches.filter((match) => !match.participant2Id).map((match) => match.matchNumber) ); if (firstFourMatches.length === 0) { for (const match of r64Matches) { if (!match.participant1Id || !match.participant2Id) { throw new Error( `Round of 64 match ${match.matchNumber} is missing participants. ` + `Assign all 64 teams to the bracket before running simulation.` ); } } return ffToR64; } for (const mapping of buildFirstFourMappings(regions)) { ffToR64.set(mapping.firstFourMatchNumber, mapping.r64MatchNumber); } for (const match of firstFourMatches) { if (!match.participant1Id || !match.participant2Id) { throw new Error(`First Four match ${match.matchNumber} is missing participants. Assign both teams before running simulation.`); } } const r64SlotsCoveredByFF = new Set(ffToR64.values()); for (const nullSlot of r64NullSlots) { if (!r64SlotsCoveredByFF.has(nullSlot)) { throw new Error(`Round of 64 match ${nullSlot} has no participant2 but is not covered by any First Four mapping.`); } } for (const [ffMatchNumber, r64MatchNumber] of ffToR64) { if (!r64NullSlots.has(r64MatchNumber)) { const ffMatch = firstFourMatches.find((match) => match.matchNumber === ffMatchNumber); if (!ffMatch?.isComplete) { throw new Error( `First Four maps to Round of 64 match ${r64MatchNumber}, but that slot is already filled. Check the bracket configuration.` ); } } } for (const match of r64Matches) { if (!match.participant1Id) { throw new Error(`Round of 64 match ${match.matchNumber} is missing participant1. Check the bracket configuration.`); } } return ffToR64; } function getTeam(teamById: Map, participantId: string): Team { const team = teamById.get(participantId); if (!team) throw new Error(`Participant ${participantId} is missing a prepared rating row.`); return team; } function simulateMatchFromIds( participant1Id: string, participant2Id: string, match: PlayoffMatch | undefined, teamById: Map, winProbability: (ratingA: number, ratingB: number) => number ): { winner: Team; loser: Team } { if (match?.isComplete && match.winnerId) { return { winner: getTeam(teamById, match.winnerId), loser: getTeam(teamById, completedLoser(match)) }; } return simulateGame(getTeam(teamById, participant1Id), getTeam(teamById, participant2Id), winProbability); } export class NCAABasketballSimulator implements Simulator { constructor(private readonly options: NCAABasketballSimulatorOptions) {} async simulate(sportsSeasonId: string): Promise { const db = database(); const config = await getSeasonSimulatorConfig(db, sportsSeasonId); const winProbability = this.options.getWinProbability?.(config) ?? this.options.winProbability; const bracketEvent = await db.query.scoringEvents.findFirst({ where: and( eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), eq(schema.scoringEvents.eventType, "playoff_game") ), }); if (!bracketEvent) { return this.simulatePreseason(db, sportsSeasonId, winProbability); } const allMatches = await db.query.playoffMatches.findMany({ where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id), orderBy: (match, { asc }) => [asc(match.matchNumber)], }); if (allMatches.length === 0) { return this.simulatePreseason(db, sportsSeasonId, winProbability); } return this.simulateExistingBracket( db, sportsSeasonId, bracketEvent.bracketRegionConfig as BracketRegion[] | null, allMatches, winProbability ); } private async simulatePreseason( db: Db, sportsSeasonId: string, winProbability: (ratingA: number, ratingB: number) => number ): Promise { const teams = await loadTeams(db, sportsSeasonId, undefined, this.options.missingRatingName); if (teams.length < FIELD_SIZE) { throw new Error(`NCAA preseason mode requires at least ${FIELD_SIZE} draftable participants, found ${teams.length}.`); } const participantIds = teams.map((team) => team.participantId); const counts = makeCounts(participantIds); for (let sim = 0; sim < NUM_SIMULATIONS; sim++) { const field = sampleField(teams, FIELD_SIZE, this.options.selectionRatingDivisor); const assignments = assignFieldToNCAA68Regions(field); const r64Pairs = buildPreseasonRoundOf64(assignments, winProbability); simulateRoundOf64Bracket(r64Pairs, counts, winProbability); } return countsToResults(participantIds, counts, `${this.options.source}_preseason`); } private async simulateExistingBracket( db: Db, sportsSeasonId: string, bracketRegions: BracketRegion[] | null, allMatches: PlayoffMatch[], winProbability: (ratingA: number, ratingB: number) => number ): Promise { const rounds = groupMatchesByRound(allMatches); const regions = bracketRegions ?? NCAA_68.regions ?? []; const ffToR64 = validateFirstFour(rounds.firstFourMatches, rounds.r64Matches, regions); const participantIdSet = new Set(); for (const match of rounds.r64Matches) { if (match.participant1Id) participantIdSet.add(match.participant1Id); if (match.participant2Id) participantIdSet.add(match.participant2Id); } for (const match of rounds.firstFourMatches) { if (match.participant1Id) participantIdSet.add(match.participant1Id); if (match.participant2Id) participantIdSet.add(match.participant2Id); } const participantIds = [...participantIdSet]; const teams = await loadTeams(db, sportsSeasonId, participantIds, this.options.missingRatingName); const teamById = new Map(teams.map((team) => [team.participantId, team])); const counts = makeCounts(participantIds); const firstFourByNum = new Map(rounds.firstFourMatches.map((match) => [match.matchNumber, match])); const r64ByNum = new Map(rounds.r64Matches.map((match) => [match.matchNumber, match])); const r32ByNum = new Map(rounds.r32Matches.map((match) => [match.matchNumber, match])); const s16ByNum = new Map(rounds.s16Matches.map((match) => [match.matchNumber, match])); const e8ByNum = new Map(rounds.e8Matches.map((match) => [match.matchNumber, match])); const ffByNum = new Map(rounds.ffMatches.map((match) => [match.matchNumber, match])); for (let sim = 0; sim < NUM_SIMULATIONS; sim++) { const ffSimWinners = new Map(); for (const [ffMatchNumber, r64MatchNumber] of ffToR64) { const match = firstFourByNum.get(ffMatchNumber); if (!match?.participant1Id || !match.participant2Id) continue; const winner = match.isComplete && match.winnerId ? match.winnerId : simulateMatchFromIds(match.participant1Id, match.participant2Id, match, teamById, winProbability).winner.participantId; ffSimWinners.set(r64MatchNumber, winner); } const r64Winners: string[] = []; for (let i = 1; i <= 32; i++) { const match = r64ByNum.get(i); if (!match?.participant1Id) continue; const participant2Id = match.participant2Id ?? ffSimWinners.get(i); if (!participant2Id) throw new Error(`Round of 64 match ${i} is missing its second participant.`); const result = simulateMatchFromIds(match.participant1Id, participant2Id, match, teamById, winProbability); r64Winners.push(result.winner.participantId); } const r32Winners: string[] = []; for (let i = 1; i <= 16; i++) { const result = simulateMatchFromIds( r64Winners[(i - 1) * 2], r64Winners[(i - 1) * 2 + 1], r32ByNum.get(i), teamById, winProbability ); r32Winners.push(result.winner.participantId); } const s16Winners: string[] = []; for (let i = 1; i <= 8; i++) { const result = simulateMatchFromIds( r32Winners[(i - 1) * 2], r32Winners[(i - 1) * 2 + 1], s16ByNum.get(i), teamById, winProbability ); s16Winners.push(result.winner.participantId); } const e8Winners: string[] = []; for (let i = 1; i <= 4; i++) { const result = simulateMatchFromIds( s16Winners[(i - 1) * 2], s16Winners[(i - 1) * 2 + 1], e8ByNum.get(i), teamById, winProbability ); e8Winners.push(result.winner.participantId); bump(counts, result.loser.participantId, "eliteEightLoser"); } const ffWinners: string[] = []; for (let i = 1; i <= 2; i++) { const result = simulateMatchFromIds( e8Winners[(i - 1) * 2], e8Winners[(i - 1) * 2 + 1], ffByNum.get(i), teamById, winProbability ); ffWinners.push(result.winner.participantId); bump(counts, result.loser.participantId, "finalFourLoser"); } const final = simulateMatchFromIds( ffWinners[0], ffWinners[1], rounds.champMatch, teamById, winProbability ); bump(counts, final.winner.participantId, "champion"); bump(counts, final.loser.participantId, "finalist"); } return countsToResults(participantIds, counts, this.options.source); } }