659 lines
24 KiB
TypeScript
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);
|
|
}
|
|
}
|