brackt/app/services/simulations/ncaa-basketball-simulator.ts
2026-05-12 22:26:25 -07:00

659 lines
24 KiB
TypeScript

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<typeof database>;
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<number, Team>;
playIns: Map<number, [Team, Team]>;
}
interface NCAABasketballSimulatorOptions {
source: string;
missingRatingName: string;
selectionRatingDivisor: number;
winProbability: (ratingA: number, ratingB: number) => number;
getWinProbability?: (config: Record<string, unknown>) => (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<T extends { matchNumber: number }>(matches: T[]): T[] {
return [...matches].toSorted((a, b) => a.matchNumber - b.matchNumber);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function getSeasonSimulatorConfig(db: Db, sportsSeasonId: string): Promise<Record<string, unknown>> {
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<string, PlacementCounts>, 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<string, PlacementCounts> {
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<string, number> {
const raw = new Map<string, number>();
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<string, number>();
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<string, number> {
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<string, number>();
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<string, number>();
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<number, Team>(),
playIns: new Map<number, [Team, Team]>(),
}));
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<string, PlacementCounts>,
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<string, PlacementCounts>, 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<Team[]> {
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<string, PlayoffMatch[]>();
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<number, number> {
const ffToR64 = new Map<number, number>();
const r64NullSlots = new Set<number>(
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<string, Team>, 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<string, Team>,
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<SimulationResult[]> {
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<SimulationResult[]> {
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<SimulationResult[]> {
const rounds = groupMatchesByRound(allMatches);
const regions = bracketRegions ?? NCAA_68.regions ?? [];
const ffToR64 = validateFirstFour(rounds.firstFourMatches, rounds.r64Matches, regions);
const participantIdSet = new Set<string>();
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<number, string>();
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);
}
}