2026-05-08 12:29:39 -07:00
|
|
|
/**
|
|
|
|
|
* NCAA Men's College Hockey Tournament Simulator
|
|
|
|
|
*
|
|
|
|
|
* Supports two modes with admin-editable inputs only:
|
|
|
|
|
* 1. Pre-bracket mode: samples a 16-team tournament field from all season
|
|
|
|
|
* participants using futures odds, NPI rank, and/or Elo entered in Admin.
|
|
|
|
|
* 2. Bracket-aware mode: when a 16-team playoff bracket exists, simulates the
|
|
|
|
|
* actual bracket and honors completed match results.
|
|
|
|
|
*
|
|
|
|
|
* No team ratings are hardcoded. Strength comes from season_participant_expected_values:
|
|
|
|
|
* - sourceElo: optional direct Elo / admin power rating
|
|
|
|
|
* - worldRanking: optional NPI rank (1 = best)
|
|
|
|
|
* - sourceOdds: optional American championship futures odds
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { database } from "~/database/context";
|
|
|
|
|
import { and, eq, inArray } from "drizzle-orm";
|
|
|
|
|
import * as schema from "~/database/schema";
|
|
|
|
|
import {
|
|
|
|
|
convertAmericanOddsToProbability,
|
|
|
|
|
convertFuturesToElo,
|
|
|
|
|
eloWinProbabilityWithParity,
|
|
|
|
|
} from "~/services/probability-engine";
|
|
|
|
|
import type { Simulator, SimulationResult } from "./types";
|
2026-06-30 23:24:48 +00:00
|
|
|
import { positiveConfigNumber } from "./config-access";
|
2026-05-08 12:29:39 -07:00
|
|
|
|
2026-06-30 23:24:48 +00:00
|
|
|
const DEFAULT_NUM_SIMULATIONS = 50_000;
|
2026-05-08 12:29:39 -07:00
|
|
|
const BRACKET_SIZE = 16;
|
|
|
|
|
const HOCKEY_PARITY_FACTOR = 850;
|
|
|
|
|
const DEFAULT_ELO = 1500;
|
|
|
|
|
const RANK_ELO_SCALE = 125;
|
|
|
|
|
const RANK_ZIPF_EXPONENT = 1.1;
|
|
|
|
|
const ODDS_ELO_WEIGHT = 0.25;
|
|
|
|
|
const DIRECT_ELO_WEIGHT = 1 - ODDS_ELO_WEIGHT;
|
|
|
|
|
const COLLEGE_HOCKEY_TEMPLATE_ID = "college_hockey_16";
|
|
|
|
|
|
|
|
|
|
const ROUND_OF_16_NAMES = new Set(["Round of 16", "Regional Semifinals"]);
|
|
|
|
|
const QUARTERFINAL_NAMES = new Set(["Quarterfinals", "Regional Finals"]);
|
|
|
|
|
const SEMIFINAL_NAMES = new Set(["Semifinals", "Frozen Four Semifinals"]);
|
|
|
|
|
const FINAL_NAMES = new Set(["Finals", "National Championship"]);
|
|
|
|
|
|
|
|
|
|
interface Team {
|
|
|
|
|
participantId: string;
|
|
|
|
|
elo: number;
|
|
|
|
|
seedScore: number;
|
|
|
|
|
oddsProb: number;
|
|
|
|
|
selectionWeight: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface EvInputRow {
|
|
|
|
|
participantId: string;
|
|
|
|
|
sourceElo: number | null;
|
|
|
|
|
sourceOdds: number | null;
|
|
|
|
|
worldRanking: number | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface PlacementCounts {
|
|
|
|
|
champion: number;
|
|
|
|
|
finalist: number;
|
|
|
|
|
semifinalLoser: number;
|
|
|
|
|
quarterfinalLoser: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
|
|
|
|
|
|
|
|
|
|
export function npiRankToElo(rank: number, fieldSize: number): number {
|
|
|
|
|
if (!Number.isFinite(rank) || rank <= 0) return DEFAULT_ELO;
|
|
|
|
|
const safeFieldSize = Math.max(fieldSize, BRACKET_SIZE);
|
|
|
|
|
return Math.round(DEFAULT_ELO + RANK_ELO_SCALE * Math.log(safeFieldSize / rank));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function npiRankSelectionWeight(rank: number | null): number {
|
|
|
|
|
if (!rank || rank <= 0) return 0;
|
|
|
|
|
return 1 / Math.pow(rank, RANK_ZIPF_EXPONENT);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeOdds(evRows: EvInputRow[]): Map<string, number> {
|
|
|
|
|
const raw = new Map<string, number>();
|
|
|
|
|
for (const row of evRows) {
|
|
|
|
|
if (row.sourceOdds !== null) {
|
|
|
|
|
raw.set(row.participantId, convertAmericanOddsToProbability(row.sourceOdds));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sum = [...raw.values()].reduce((acc, p) => acc + p, 0);
|
|
|
|
|
const normalized = new Map<string, number>();
|
|
|
|
|
if (sum <= 0) return normalized;
|
|
|
|
|
|
|
|
|
|
for (const [id, prob] of raw) normalized.set(id, prob / sum);
|
|
|
|
|
return normalized;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildOddsEloMap(evRows: EvInputRow[]): Map<string, number> {
|
|
|
|
|
const oddsInput = evRows
|
|
|
|
|
.filter((row) => row.sourceOdds !== null)
|
|
|
|
|
.map((row) => ({ participantId: row.participantId, odds: row.sourceOdds ?? 0 }));
|
|
|
|
|
|
|
|
|
|
if (oddsInput.length < 2) return new Map();
|
|
|
|
|
return convertFuturesToElo(oddsInput, "american", {
|
|
|
|
|
exponent: 0.33,
|
|
|
|
|
eloMin: 1325,
|
|
|
|
|
eloMax: 1725,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function buildCollegeHockeyTeams(
|
|
|
|
|
participantIds: string[],
|
|
|
|
|
evRows: EvInputRow[]
|
|
|
|
|
): Team[] {
|
|
|
|
|
const evById = new Map(evRows.map((row) => [row.participantId, row]));
|
|
|
|
|
const normalizedOdds = normalizeOdds(evRows);
|
|
|
|
|
const oddsEloMap = buildOddsEloMap(evRows);
|
|
|
|
|
const hasOdds = normalizedOdds.size > 0;
|
|
|
|
|
const hasAnyRank = evRows.some((row) => row.worldRanking !== null);
|
|
|
|
|
const hasAnyDirectElo = evRows.some((row) => row.sourceElo !== null);
|
|
|
|
|
|
|
|
|
|
const teams: Team[] = participantIds.map((participantId) => {
|
|
|
|
|
const ev = evById.get(participantId);
|
|
|
|
|
const rankElo = ev?.worldRanking ? npiRankToElo(ev.worldRanking, participantIds.length) : null;
|
|
|
|
|
const baseElo = ev?.sourceElo ?? rankElo ?? oddsEloMap.get(participantId) ?? DEFAULT_ELO;
|
|
|
|
|
const oddsElo = oddsEloMap.get(participantId);
|
|
|
|
|
const elo = oddsElo !== undefined && ((ev?.sourceElo !== null && ev?.sourceElo !== undefined) || rankElo !== null)
|
|
|
|
|
? Math.round(DIRECT_ELO_WEIGHT * baseElo + ODDS_ELO_WEIGHT * oddsElo)
|
|
|
|
|
: baseElo;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
participantId,
|
|
|
|
|
elo,
|
|
|
|
|
seedScore: rankElo ?? ev?.sourceElo ?? elo,
|
|
|
|
|
oddsProb: normalizedOdds.get(participantId) ?? 0,
|
|
|
|
|
selectionWeight: 0,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const maxElo = Math.max(...teams.map((team) => team.elo));
|
|
|
|
|
const fallbackWeights = new Map<string, number>();
|
|
|
|
|
for (const team of teams) {
|
|
|
|
|
const rank = evById.get(team.participantId)?.worldRanking ?? null;
|
|
|
|
|
const fallbackWeight = hasAnyRank
|
|
|
|
|
? npiRankSelectionWeight(rank)
|
|
|
|
|
: hasAnyDirectElo
|
|
|
|
|
? Math.exp((team.elo - maxElo) / 140)
|
|
|
|
|
: 1;
|
|
|
|
|
fallbackWeights.set(team.participantId, fallbackWeight > 0 ? fallbackWeight : 0.01);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (hasOdds) {
|
|
|
|
|
const fallbackTotal = [...fallbackWeights.values()].reduce((sum, weight) => sum + weight, 0);
|
|
|
|
|
for (const team of teams) {
|
|
|
|
|
const fallbackShare = (fallbackWeights.get(team.participantId) ?? 0.01) / fallbackTotal;
|
|
|
|
|
team.selectionWeight = team.oddsProb + 0.1 * fallbackShare;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
for (const team of teams) team.selectionWeight = fallbackWeights.get(team.participantId) ?? 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return teams;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-30 23:24:48 +00:00
|
|
|
function simGame(team1: Team, team2: Team, parityFactor = HOCKEY_PARITY_FACTOR): { winner: Team; loser: Team } {
|
|
|
|
|
const p1Wins = Math.random() < eloWinProbabilityWithParity(team1.elo, team2.elo, parityFactor);
|
2026-05-08 12:29:39 -07:00
|
|
|
return p1Wins ? { winner: team1, loser: team2 } : { winner: team2, loser: team1 };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function bump(counts: Map<string, PlacementCounts>, id: string, key: keyof PlacementCounts): void {
|
|
|
|
|
const entry = counts.get(id);
|
|
|
|
|
if (entry) entry[key]++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sampleField(pool: Team[], n: number): Team[] {
|
|
|
|
|
const remaining = [...pool];
|
|
|
|
|
const selected: Team[] = [];
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
|
|
|
const totalWeight = remaining.reduce((sum, team) => sum + team.selectionWeight, 0);
|
|
|
|
|
let r = Math.random() * totalWeight;
|
|
|
|
|
let index = 0;
|
|
|
|
|
for (; index < remaining.length - 1; index++) {
|
|
|
|
|
r -= remaining[index].selectionWeight;
|
|
|
|
|
if (r <= 0) break;
|
|
|
|
|
}
|
|
|
|
|
selected.push(remaining[index]);
|
|
|
|
|
remaining.splice(index, 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return seedField(selected);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function seedField(teams: Team[]): Team[] {
|
|
|
|
|
return [...teams].toSorted((a, b) => b.seedScore - a.seedScore || b.elo - a.elo);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-30 23:24:48 +00:00
|
|
|
function simulateSeeded16TeamBracket(teams: Team[], counts: Map<string, PlacementCounts>, parityFactor = HOCKEY_PARITY_FACTOR): void {
|
2026-05-08 12:29:39 -07:00
|
|
|
const r16Pairs: Array<[number, number]> = [
|
|
|
|
|
[0, 15], [7, 8], [4, 11], [3, 12], [5, 10], [2, 13], [6, 9], [1, 14],
|
|
|
|
|
];
|
2026-06-30 23:24:48 +00:00
|
|
|
const game = (a: Team, b: Team) => simGame(a, b, parityFactor);
|
2026-05-08 12:29:39 -07:00
|
|
|
|
2026-06-30 23:24:48 +00:00
|
|
|
const r16Winners = r16Pairs.map(([a, b]) => game(teams[a], teams[b]).winner);
|
2026-05-08 12:29:39 -07:00
|
|
|
|
|
|
|
|
const qfWinners: Team[] = [];
|
|
|
|
|
for (let i = 0; i < 4; i++) {
|
2026-06-30 23:24:48 +00:00
|
|
|
const result = game(r16Winners[i * 2], r16Winners[i * 2 + 1]);
|
2026-05-08 12:29:39 -07:00
|
|
|
qfWinners.push(result.winner);
|
|
|
|
|
bump(counts, result.loser.participantId, "quarterfinalLoser");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-30 23:24:48 +00:00
|
|
|
const sf1 = game(qfWinners[0], qfWinners[1]);
|
|
|
|
|
const sf2 = game(qfWinners[2], qfWinners[3]);
|
2026-05-08 12:29:39 -07:00
|
|
|
bump(counts, sf1.loser.participantId, "semifinalLoser");
|
|
|
|
|
bump(counts, sf2.loser.participantId, "semifinalLoser");
|
|
|
|
|
|
2026-06-30 23:24:48 +00:00
|
|
|
const final = game(sf1.winner, sf2.winner);
|
2026-05-08 12:29:39 -07:00
|
|
|
bump(counts, final.winner.participantId, "champion");
|
|
|
|
|
bump(counts, final.loser.participantId, "finalist");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function findRound(matches: PlayoffMatch[], names: Set<string>): PlayoffMatch[] {
|
|
|
|
|
return matches
|
|
|
|
|
.filter((match) => names.has(match.round) || (match.templateRound ? names.has(match.templateRound) : false))
|
|
|
|
|
.toSorted((a, b) => a.matchNumber - b.matchNumber);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 getTeam(teamById: Map<string, Team>, participantId: string): Team {
|
|
|
|
|
const team = teamById.get(participantId);
|
|
|
|
|
if (!team) throw new Error(`Participant ${participantId} is missing a saved rating row.`);
|
|
|
|
|
return team;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function simulateMatchFromIds(
|
|
|
|
|
p1: string,
|
|
|
|
|
p2: string,
|
|
|
|
|
match: PlayoffMatch | undefined,
|
2026-06-30 23:24:48 +00:00
|
|
|
teamById: Map<string, Team>,
|
|
|
|
|
parityFactor = HOCKEY_PARITY_FACTOR
|
2026-05-08 12:29:39 -07:00
|
|
|
): { winner: Team; loser: Team } {
|
|
|
|
|
if (match?.isComplete && match.winnerId) {
|
|
|
|
|
const loserId = completedLoser(match);
|
|
|
|
|
return { winner: getTeam(teamById, match.winnerId), loser: getTeam(teamById, loserId) };
|
|
|
|
|
}
|
2026-06-30 23:24:48 +00:00
|
|
|
return simGame(getTeam(teamById, p1), getTeam(teamById, p2), parityFactor);
|
2026-05-08 12:29:39 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function simulateExistingBracket(
|
|
|
|
|
rounds: { r16: PlayoffMatch[]; qf: PlayoffMatch[]; sf: PlayoffMatch[]; final: PlayoffMatch },
|
|
|
|
|
teamById: Map<string, Team>,
|
2026-06-30 23:24:48 +00:00
|
|
|
counts: Map<string, PlacementCounts>,
|
|
|
|
|
parityFactor = HOCKEY_PARITY_FACTOR
|
2026-05-08 12:29:39 -07:00
|
|
|
): void {
|
|
|
|
|
const r16Winners: Team[] = [];
|
|
|
|
|
for (let i = 0; i < 8; i++) {
|
|
|
|
|
const match = rounds.r16[i];
|
|
|
|
|
if (!match.participant1Id || !match.participant2Id) {
|
|
|
|
|
throw new Error(`${match.round} match ${match.matchNumber} is missing participants.`);
|
|
|
|
|
}
|
2026-06-30 23:24:48 +00:00
|
|
|
r16Winners.push(simulateMatchFromIds(match.participant1Id, match.participant2Id, match, teamById, parityFactor).winner);
|
2026-05-08 12:29:39 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const qfWinners: Team[] = [];
|
|
|
|
|
for (let i = 0; i < 4; i++) {
|
|
|
|
|
const match = rounds.qf[i];
|
|
|
|
|
const p1 = r16Winners[i * 2]?.participantId;
|
|
|
|
|
const p2 = r16Winners[i * 2 + 1]?.participantId;
|
|
|
|
|
if (!p1 || !p2) throw new Error(`${match.round} match ${match.matchNumber} cannot resolve participants.`);
|
2026-06-30 23:24:48 +00:00
|
|
|
const result = simulateMatchFromIds(p1, p2, match, teamById, parityFactor);
|
2026-05-08 12:29:39 -07:00
|
|
|
qfWinners.push(result.winner);
|
|
|
|
|
bump(counts, result.loser.participantId, "quarterfinalLoser");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sfWinners: Team[] = [];
|
|
|
|
|
for (let i = 0; i < 2; i++) {
|
|
|
|
|
const match = rounds.sf[i];
|
|
|
|
|
const p1 = qfWinners[i * 2]?.participantId;
|
|
|
|
|
const p2 = qfWinners[i * 2 + 1]?.participantId;
|
|
|
|
|
if (!p1 || !p2) throw new Error(`${match.round} match ${match.matchNumber} cannot resolve participants.`);
|
2026-06-30 23:24:48 +00:00
|
|
|
const result = simulateMatchFromIds(p1, p2, match, teamById, parityFactor);
|
2026-05-08 12:29:39 -07:00
|
|
|
sfWinners.push(result.winner);
|
|
|
|
|
bump(counts, result.loser.participantId, "semifinalLoser");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const p1 = sfWinners[0]?.participantId;
|
|
|
|
|
const p2 = sfWinners[1]?.participantId;
|
|
|
|
|
if (!p1 || !p2) throw new Error(`${rounds.final.round} match ${rounds.final.matchNumber} cannot resolve participants.`);
|
2026-06-30 23:24:48 +00:00
|
|
|
const final = simulateMatchFromIds(p1, p2, rounds.final, teamById, parityFactor);
|
2026-05-08 12:29:39 -07:00
|
|
|
bump(counts, final.winner.participantId, "champion");
|
|
|
|
|
bump(counts, final.loser.participantId, "finalist");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-30 23:24:48 +00:00
|
|
|
function toResults(participantIds: string[], counts: Map<string, PlacementCounts>, numSimulations: number): SimulationResult[] {
|
2026-05-08 12:29:39 -07:00
|
|
|
const results = participantIds.map((participantId) => {
|
|
|
|
|
const c = counts.get(participantId) ?? { champion: 0, finalist: 0, semifinalLoser: 0, quarterfinalLoser: 0 };
|
2026-06-30 23:24:48 +00:00
|
|
|
const sf = c.semifinalLoser / (2 * numSimulations);
|
|
|
|
|
const qf = c.quarterfinalLoser / (4 * numSimulations);
|
2026-05-08 12:29:39 -07:00
|
|
|
return {
|
|
|
|
|
participantId,
|
|
|
|
|
probabilities: {
|
2026-06-30 23:24:48 +00:00
|
|
|
probFirst: c.champion / numSimulations,
|
|
|
|
|
probSecond: c.finalist / numSimulations,
|
2026-05-08 12:29:39 -07:00
|
|
|
probThird: sf,
|
|
|
|
|
probFourth: sf,
|
|
|
|
|
probFifth: qf,
|
|
|
|
|
probSixth: qf,
|
|
|
|
|
probSeventh: qf,
|
|
|
|
|
probEighth: qf,
|
|
|
|
|
},
|
|
|
|
|
source: "college_hockey_monte_carlo",
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
for (const key of ["probFirst", "probSecond", "probThird", "probFourth", "probFifth", "probSixth", "probSeventh", "probEighth"] as const) {
|
|
|
|
|
const colSum = results.reduce((sum, result) => sum + result.probabilities[key], 0);
|
|
|
|
|
const residual = 1 - colSum;
|
|
|
|
|
if (Math.abs(residual) > 0 && Math.abs(residual) < 1e-9) {
|
|
|
|
|
const maxResult = results.reduce((best, result) =>
|
|
|
|
|
result.probabilities[key] > best.probabilities[key] ? result : best
|
|
|
|
|
);
|
|
|
|
|
maxResult.probabilities[key] += residual;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return results;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class CollegeHockeySimulator implements Simulator {
|
2026-06-30 23:24:48 +00:00
|
|
|
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
2026-05-08 12:29:39 -07:00
|
|
|
const db = database();
|
2026-06-30 23:24:48 +00:00
|
|
|
const parityFactor = positiveConfigNumber(config, "parityFactor", HOCKEY_PARITY_FACTOR);
|
|
|
|
|
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
2026-05-08 12:29:39 -07:00
|
|
|
|
|
|
|
|
const bracketEvent = await db.query.scoringEvents.findFirst({
|
|
|
|
|
where: and(
|
|
|
|
|
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
|
|
|
|
eq(schema.scoringEvents.eventType, "playoff_game")
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const bracketMatches = bracketEvent
|
|
|
|
|
? await db.query.playoffMatches.findMany({
|
|
|
|
|
where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
|
|
|
|
|
orderBy: (m, { asc }) => [asc(m.matchNumber)],
|
|
|
|
|
})
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
const existingBracket = this.getExistingBracketRounds(bracketMatches);
|
|
|
|
|
const participantIds = existingBracket
|
|
|
|
|
? this.getParticipantIdsFromBracket(existingBracket)
|
|
|
|
|
: (await db
|
|
|
|
|
.select({ id: schema.seasonParticipants.id })
|
|
|
|
|
.from(schema.seasonParticipants)
|
|
|
|
|
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId))).map((row) => row.id);
|
|
|
|
|
|
|
|
|
|
if (participantIds.length < BRACKET_SIZE) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`College hockey simulator requires at least ${BRACKET_SIZE} participants, found ${participantIds.length}.`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const evRows = await db
|
|
|
|
|
.select({
|
|
|
|
|
participantId: schema.seasonParticipantExpectedValues.participantId,
|
|
|
|
|
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
|
|
|
|
|
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
|
|
|
|
|
worldRanking: schema.seasonParticipantExpectedValues.worldRanking,
|
|
|
|
|
})
|
|
|
|
|
.from(schema.seasonParticipantExpectedValues)
|
|
|
|
|
.where(inArray(schema.seasonParticipantExpectedValues.participantId, participantIds));
|
|
|
|
|
|
|
|
|
|
const teams = buildCollegeHockeyTeams(participantIds, evRows);
|
|
|
|
|
const teamById = new Map(teams.map((team) => [team.participantId, team]));
|
|
|
|
|
const counts = new Map<string, PlacementCounts>(
|
|
|
|
|
participantIds.map((id) => [id, { champion: 0, finalist: 0, semifinalLoser: 0, quarterfinalLoser: 0 }])
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (existingBracket) {
|
2026-06-30 23:24:48 +00:00
|
|
|
for (let i = 0; i < numSimulations; i++) {
|
|
|
|
|
simulateExistingBracket(existingBracket, teamById, counts, parityFactor);
|
2026-05-08 12:29:39 -07:00
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
const preBracketMode = participantIds.length > BRACKET_SIZE;
|
|
|
|
|
const deterministicField = preBracketMode ? null : seedField(teams);
|
2026-06-30 23:24:48 +00:00
|
|
|
for (let i = 0; i < numSimulations; i++) {
|
2026-05-08 12:29:39 -07:00
|
|
|
const field = preBracketMode ? sampleField(teams, BRACKET_SIZE) : deterministicField ?? [];
|
2026-06-30 23:24:48 +00:00
|
|
|
simulateSeeded16TeamBracket(field, counts, parityFactor);
|
2026-05-08 12:29:39 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-30 23:24:48 +00:00
|
|
|
return toResults(participantIds, counts, numSimulations);
|
2026-05-08 12:29:39 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getExistingBracketRounds(matches: PlayoffMatch[]): { r16: PlayoffMatch[]; qf: PlayoffMatch[]; sf: PlayoffMatch[]; final: PlayoffMatch } | null {
|
|
|
|
|
if (matches.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
const r16 = findRound(matches, ROUND_OF_16_NAMES);
|
|
|
|
|
const qf = findRound(matches, QUARTERFINAL_NAMES);
|
|
|
|
|
const sf = findRound(matches, SEMIFINAL_NAMES);
|
|
|
|
|
const final = findRound(matches, FINAL_NAMES);
|
|
|
|
|
|
|
|
|
|
if (r16.length === 0 && qf.length === 0 && sf.length === 0 && final.length === 0) return null;
|
|
|
|
|
if (r16.length !== 8 || qf.length !== 4 || sf.length !== 2 || final.length !== 1) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`College hockey bracket must have 8 Round of 16, 4 Quarterfinal, 2 Semifinal, and 1 Final match. ` +
|
|
|
|
|
`Found ${r16.length}/${qf.length}/${sf.length}/${final.length}.`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { r16, qf, sf, final: final[0] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getParticipantIdsFromBracket(rounds: { r16: PlayoffMatch[]; qf: PlayoffMatch[]; sf: PlayoffMatch[]; final: PlayoffMatch }): string[] {
|
|
|
|
|
const ids = new Set<string>();
|
|
|
|
|
const allMatches = [...rounds.r16, ...rounds.qf, ...rounds.sf, rounds.final];
|
|
|
|
|
for (const match of allMatches) {
|
|
|
|
|
if (match.participant1Id) ids.add(match.participant1Id);
|
|
|
|
|
if (match.participant2Id) ids.add(match.participant2Id);
|
|
|
|
|
if (match.winnerId) ids.add(match.winnerId);
|
|
|
|
|
if (match.loserId) ids.add(match.loserId);
|
|
|
|
|
}
|
|
|
|
|
return [...ids];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export { COLLEGE_HOCKEY_TEMPLATE_ID };
|