Fix college hockey bracket resolution (#396)

This commit is contained in:
Chris Parsons 2026-05-08 12:29:39 -07:00 committed by GitHub
parent 777315541b
commit 79092f0409
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 6560 additions and 44 deletions

View file

@ -350,6 +350,44 @@ export const SIMPLE_32: BracketTemplate = {
], ],
}; };
/**
* NCAA Men's College Hockey Tournament (16 teams)
* Regional Semifinals Regional Finals Frozen Four Semifinals National Championship
* Only Regional Finals and beyond score points (top 8).
*/
export const COLLEGE_HOCKEY_16: BracketTemplate = {
id: "college_hockey_16",
name: "NCAA Men's Hockey Tournament (16 teams)",
totalTeams: 16,
scoringStartsAtRound: "Regional Finals",
rounds: [
{
name: "Regional Semifinals",
matchCount: 8,
feedsInto: "Regional Finals",
isScoring: false,
},
{
name: "Regional Finals",
matchCount: 4,
feedsInto: "Frozen Four Semifinals",
isScoring: true,
},
{
name: "Frozen Four Semifinals",
matchCount: 2,
feedsInto: "National Championship",
isScoring: true,
},
{
name: "National Championship",
matchCount: 1,
feedsInto: null,
isScoring: true,
},
],
};
/** /**
* PDC World Darts Championship (128 players) * PDC World Darts Championship (128 players)
* R1 (128) R2 (64) R3 (32) R4 (16) Quarterfinals Semifinals Final * R1 (128) R2 (64) R3 (32) R4 (16) Quarterfinals Semifinals Final
@ -878,6 +916,7 @@ export const BRACKET_TEMPLATES: Record<string, BracketTemplate> = {
simple_4: SIMPLE_4, simple_4: SIMPLE_4,
simple_8: SIMPLE_8, simple_8: SIMPLE_8,
simple_16: SIMPLE_16, simple_16: SIMPLE_16,
college_hockey_16: COLLEGE_HOCKEY_16,
simple_32: SIMPLE_32, simple_32: SIMPLE_32,
ncaa_68: NCAA_68, ncaa_68: NCAA_68,
nfl_14: NFL_14, nfl_14: NFL_14,

View file

@ -4,6 +4,7 @@ import {
SIMPLE_8, SIMPLE_8,
SIMPLE_16, SIMPLE_16,
SIMPLE_32, SIMPLE_32,
COLLEGE_HOCKEY_16,
NCAA_68, NCAA_68,
NFL_14, NFL_14,
getBracketTemplate, getBracketTemplate,
@ -44,6 +45,21 @@ describe("Bracket Templates", () => {
expect(SIMPLE_16.rounds[3].isScoring).toBe(true); expect(SIMPLE_16.rounds[3].isScoring).toBe(true);
}); });
it("COLLEGE_HOCKEY_16 has correct structure", () => {
expect(COLLEGE_HOCKEY_16.id).toBe("college_hockey_16");
expect(COLLEGE_HOCKEY_16.totalTeams).toBe(16);
expect(COLLEGE_HOCKEY_16.rounds).toHaveLength(4);
expect(COLLEGE_HOCKEY_16.scoringStartsAtRound).toBe("Regional Finals");
expect(COLLEGE_HOCKEY_16.rounds[0].name).toBe("Regional Semifinals");
expect(COLLEGE_HOCKEY_16.rounds[0].isScoring).toBe(false);
expect(COLLEGE_HOCKEY_16.rounds[1].name).toBe("Regional Finals");
expect(COLLEGE_HOCKEY_16.rounds[1].isScoring).toBe(true);
expect(COLLEGE_HOCKEY_16.rounds[2].name).toBe("Frozen Four Semifinals");
expect(COLLEGE_HOCKEY_16.rounds[2].isScoring).toBe(true);
expect(COLLEGE_HOCKEY_16.rounds[3].name).toBe("National Championship");
expect(COLLEGE_HOCKEY_16.rounds[3].isScoring).toBe(true);
});
it("SIMPLE_32 has correct structure", () => { it("SIMPLE_32 has correct structure", () => {
expect(SIMPLE_32.id).toBe("simple_32"); expect(SIMPLE_32.id).toBe("simple_32");
expect(SIMPLE_32.totalTeams).toBe(32); expect(SIMPLE_32.totalTeams).toBe(32);
@ -145,6 +161,7 @@ describe("Bracket Templates", () => {
expect(getBracketTemplate("simple_4")).toBe(SIMPLE_4); expect(getBracketTemplate("simple_4")).toBe(SIMPLE_4);
expect(getBracketTemplate("simple_8")).toBe(SIMPLE_8); expect(getBracketTemplate("simple_8")).toBe(SIMPLE_8);
expect(getBracketTemplate("simple_16")).toBe(SIMPLE_16); expect(getBracketTemplate("simple_16")).toBe(SIMPLE_16);
expect(getBracketTemplate("college_hockey_16")).toBe(COLLEGE_HOCKEY_16);
expect(getBracketTemplate("ncaa_68")).toBe(NCAA_68); expect(getBracketTemplate("ncaa_68")).toBe(NCAA_68);
}); });

View file

@ -425,7 +425,7 @@ export async function batchSaveSourceOdds(
* Uses INSERT ... ON CONFLICT DO UPDATE to avoid N+1 queries. * Uses INSERT ... ON CONFLICT DO UPDATE to avoid N+1 queries.
*/ */
export async function batchSaveSourceElos( export async function batchSaveSourceElos(
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceElo: number; worldRanking?: number | null }> inputs: Array<{ participantId: string; sportsSeasonId: string; sourceElo: number | null; worldRanking?: number | null }>
): Promise<void> { ): Promise<void> {
if (inputs.length === 0) return; if (inputs.length === 0) return;
const db = database(); const db = database();

View file

@ -40,10 +40,12 @@ import {
} from '~/services/probability-engine'; } from '~/services/probability-engine';
// Simulator types that use worldRanking in addition to sourceElo // Simulator types that use worldRanking in addition to sourceElo
const RANKING_SIMULATOR_TYPES = new Set(['darts_bracket', 'cs2_major_qualifying_points']); const RANKING_SIMULATOR_TYPES = new Set(['darts_bracket', 'cs2_major_qualifying_points', 'college_hockey_bracket']);
const RANK_ONLY_SIMULATOR_TYPES = new Set(['college_hockey_bracket']);
function rankingLabel(simulatorType: string | null | undefined): string { function rankingLabel(simulatorType: string | null | undefined): string {
if (simulatorType === 'cs2_major_qualifying_points') return 'HLTV Rank'; if (simulatorType === 'cs2_major_qualifying_points') return 'HLTV Rank';
if (simulatorType === 'college_hockey_bracket') return 'NPI Rank';
return 'World Rank'; return 'World Rank';
} }
@ -98,11 +100,13 @@ export async function action({ request, params }: Route.ActionArgs) {
} }
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId); const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? ''); const simulatorType = sportsSeason.sport?.simulatorType ?? '';
const usesRanking = RANKING_SIMULATOR_TYPES.has(simulatorType);
const allowsRankOnly = RANK_ONLY_SIMULATOR_TYPES.has(simulatorType);
const rawMode = formData.get('inputMode'); const rawMode = formData.get('inputMode');
const inputMode = rawMode === 'projectedWins' ? 'projectedWins' : 'elo'; const inputMode = rawMode === 'projectedWins' ? 'projectedWins' : 'elo';
const eloInputs: Array<{ participantId: string; sportsSeasonId: string; sourceElo: number; worldRanking?: number | null }> = []; const eloInputs: Array<{ participantId: string; sportsSeasonId: string; sourceElo: number | null; worldRanking?: number | null }> = [];
if (inputMode === 'projectedWins' && sportsSeason.sport?.simulatorType) { if (inputMode === 'projectedWins' && sportsSeason.sport?.simulatorType) {
const config = getSimulatorConfig(sportsSeason.sport.simulatorType as SimulatorType); const config = getSimulatorConfig(sportsSeason.sport.simulatorType as SimulatorType);
@ -128,20 +132,21 @@ export async function action({ request, params }: Route.ActionArgs) {
const eloVal = formData.get(`elo_${participant.id}`) as string; const eloVal = formData.get(`elo_${participant.id}`) as string;
const rankVal = formData.get(`rank_${participant.id}`) as string; const rankVal = formData.get(`rank_${participant.id}`) as string;
if (eloVal && eloVal.trim() !== '') { const elo = eloVal && eloVal.trim() !== '' ? Number(eloVal) : null;
const elo = Number(eloVal); const ranking = rankVal && rankVal.trim() !== '' ? Number(rankVal) : null;
if (!isNaN(elo) && elo > 0) { const validElo = elo !== null && !isNaN(elo) && elo > 0;
if (usesRanking) { const validRanking = ranking !== null && !isNaN(ranking) && ranking > 0;
const ranking = rankVal && rankVal.trim() !== '' ? Number(rankVal) : null;
eloInputs.push({ if (validElo || (usesRanking && allowsRankOnly && validRanking)) {
participantId: participant.id, if (usesRanking) {
sportsSeasonId, eloInputs.push({
sourceElo: elo, participantId: participant.id,
worldRanking: ranking && !isNaN(ranking) && ranking > 0 ? ranking : null, sportsSeasonId,
}); sourceElo: validElo ? elo : null,
} else { worldRanking: validRanking ? ranking : null,
eloInputs.push({ participantId: participant.id, sportsSeasonId, sourceElo: elo }); });
} } else if (validElo) {
eloInputs.push({ participantId: participant.id, sportsSeasonId, sourceElo: elo });
} }
} }
} }
@ -150,7 +155,9 @@ export async function action({ request, params }: Route.ActionArgs) {
if (eloInputs.length === 0) { if (eloInputs.length === 0) {
return { success: false, message: inputMode === 'projectedWins' return { success: false, message: inputMode === 'projectedWins'
? 'Please enter projections for at least one participant' ? 'Please enter projections for at least one participant'
: 'Please enter an Elo rating for at least one participant' }; : allowsRankOnly
? 'Please enter an Elo rating or NPI rank for at least one participant'
: 'Please enter an Elo rating for at least one participant' };
} }
if (!sportsSeason.sport?.simulatorType) { if (!sportsSeason.sport?.simulatorType) {
@ -286,8 +293,8 @@ export default function AdminSportsSeasonEloRatings() {
const [bulkText, setBulkText] = useState(''); const [bulkText, setBulkText] = useState('');
const [parseResults, setParseResults] = useState<{ const [parseResults, setParseResults] = useState<{
matched: Array<{ participantId: string; name: string; elo: number; ranking: number | null; inputName: string }>; matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }>;
unmatched: Array<{ inputName: string; elo: number; ranking: number | null }>; unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null }>;
} | null>(null); } | null>(null);
function findParticipantMatch(inputName: string) { function findParticipantMatch(inputName: string) {
@ -312,8 +319,8 @@ export default function AdminSportsSeasonEloRatings() {
function parseBulkText() { function parseBulkText() {
const lines = bulkText.split('\n'); const lines = bulkText.split('\n');
const matched: Array<{ participantId: string; name: string; elo: number; ranking: number | null; inputName: string }> = []; const matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }> = [];
const unmatched: Array<{ inputName: string; elo: number; ranking: number | null }> = []; const unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null }> = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const line of lines) { for (const line of lines) {
@ -342,15 +349,23 @@ export default function AdminSportsSeasonEloRatings() {
} }
} else { } else {
const match = usesRanking const match = usesRanking
? /^(.+?)[\s,:\t]+(\d{3,5})(?:[\s,:\t]+(\d{1,3}))?\s*$/.exec(trimmed) ? /^(.+?)[\s,:\t]+(\d{1,5})(?:[\s,:\t]+(\d{1,3}))?\s*$/.exec(trimmed)
: /^(.+?)[\s,:\t]+(\d{3,5})\s*$/.exec(trimmed); : /^(.+?)[\s,:\t]+(\d{3,5})\s*$/.exec(trimmed);
if (!match) continue; if (!match) continue;
const inputName = match[1].trim(); const inputName = match[1].trim();
const elo = parseInt(match[2], 10); const firstNumber = parseInt(match[2], 10);
const ranking = usesRanking && match[3] ? parseInt(match[3], 10) : null; const parsedAsRankOnly = usesRanking && allowsRankOnly && !match[3] && firstNumber < 500;
const elo = parsedAsRankOnly ? null : firstNumber;
const ranking = parsedAsRankOnly
? firstNumber
: usesRanking && match[3]
? parseInt(match[3], 10)
: null;
if (isNaN(elo) || elo < 500 || elo > 5000) continue; const validElo = elo !== null && !isNaN(elo) && elo >= 500 && elo <= 5000;
const validRanking = ranking !== null && !isNaN(ranking) && ranking > 0;
if (!validElo && !(allowsRankOnly && validRanking)) continue;
const participant = findParticipantMatch(inputName); const participant = findParticipantMatch(inputName);
if (participant && !seen.has(participant.id)) { if (participant && !seen.has(participant.id)) {
@ -371,9 +386,9 @@ export default function AdminSportsSeasonEloRatings() {
const newRanks = { ...rankValues }; const newRanks = { ...rankValues };
const newWins = { ...winsValues }; const newWins = { ...winsValues };
for (const m of parseResults.matched) { for (const m of parseResults.matched) {
newElos[m.participantId] = m.elo.toString(); if (m.elo !== null) newElos[m.participantId] = m.elo.toString();
if (m.ranking !== null) newRanks[m.participantId] = m.ranking.toString(); if (m.ranking !== null) newRanks[m.participantId] = m.ranking.toString();
if (inputMode === 'projectedWins' && simulatorConfig) { if (inputMode === 'projectedWins' && simulatorConfig && m.elo !== null) {
newWins[m.participantId] = (simulatorConfig.projectionInput === 'tablePoints' newWins[m.participantId] = (simulatorConfig.projectionInput === 'tablePoints'
? eloToProjectedTablePoints(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo) ? eloToProjectedTablePoints(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
: eloToProjectedWins(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo) : eloToProjectedWins(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
@ -390,6 +405,8 @@ export default function AdminSportsSeasonEloRatings() {
const isSubmitting = navigation.state === 'submitting'; const isSubmitting = navigation.state === 'submitting';
const simulatorType = sportsSeason.sport?.simulatorType ?? null; const simulatorType = sportsSeason.sport?.simulatorType ?? null;
const rankLabel = rankingLabel(simulatorType); const rankLabel = rankingLabel(simulatorType);
const allowsRankOnly = RANK_ONLY_SIMULATOR_TYPES.has(simulatorType ?? '');
const participantLabel = simulatorType === 'college_hockey_bracket' ? 'Team' : 'Player';
// When ranking is available, sort by rank ascending, then unranked alphabetically // When ranking is available, sort by rank ascending, then unranked alphabetically
const sortedParticipants = usesRanking const sortedParticipants = usesRanking
@ -446,8 +463,12 @@ export default function AdminSportsSeasonEloRatings() {
) : usesRanking ? ( ) : usesRanking ? (
<> <>
Paste one entry per line. Format:{' '} Paste one entry per line. Format:{' '}
<code>Name, Elo, {rankLabel}</code> (ranking is optional omit it and the simulator {allowsRankOnly ? (
will use Elo order for seeding). Names are fuzzy-matched to participants. <>Use <code>Name, {rankLabel}</code> or <code>Name, Elo, {rankLabel}</code>. Elo is optional when NPI rank is present.</>
) : (
<>Use <code>Name, Elo, {rankLabel}</code> (ranking is optional omit it and the simulator will use Elo order for seeding).</>
)}{' '}
Names are fuzzy-matched to participants.
</> </>
) : ( ) : (
<> <>
@ -465,10 +486,14 @@ export default function AdminSportsSeasonEloRatings() {
? `Team Name, 76.5\nTeam Name, 68.0\nTeam Name, 54.5` ? `Team Name, 76.5\nTeam Name, 68.0\nTeam Name, 54.5`
: `Team Name, 15.6\nTeam Name, 14.5\nTeam Name, 13.8` : `Team Name, 15.6\nTeam Name, 14.5\nTeam Name, 13.8`
: usesRanking : usesRanking
? simulatorType === 'cs2_major_qualifying_points' ? simulatorType === 'college_hockey_bracket'
? `Natus Vincere, 1850, 1\nFaZe Clan, 1820, 2\nVitality, 1810, 3` ? `Denver, 1\nBoston College, 2\nMichigan State, 1650, 3`
: `Luke Littler, 2099, 1\nMichael van Gerwen, 1950, 2\nLuke Humphries, 1947, 3` : simulatorType === 'cs2_major_qualifying_points'
: `Judd Trump, 2594\nRonnie O'Sullivan, 2441\nMark Selby, 2432` ? `Natus Vincere, 1850, 1\nFaZe Clan, 1820, 2\nVitality, 1810, 3`
: `Luke Littler, 2099, 1\nMichael van Gerwen, 1950, 2\nLuke Humphries, 1947, 3`
: `Judd Trump, 2594
Ronnie O'Sullivan, 2441
Mark Selby, 2432`
} }
value={bulkText} value={bulkText}
onChange={e => { setBulkText(e.target.value); setParseResults(null); }} onChange={e => { setBulkText(e.target.value); setParseResults(null); }}
@ -492,7 +517,7 @@ export default function AdminSportsSeasonEloRatings() {
<div key={m.participantId} className="flex justify-between px-3 py-1.5"> <div key={m.participantId} className="flex justify-between px-3 py-1.5">
<span className="text-muted-foreground">{m.inputName}</span> <span className="text-muted-foreground">{m.inputName}</span>
<span className="font-medium"> <span className="font-medium">
{m.name} &rarr; Elo {m.elo} {m.name} &rarr; {m.elo !== null ? `Elo ${m.elo}` : 'No Elo'}
{usesRanking && m.ranking !== null ? `, ${rankLabel} #${m.ranking}` : ''} {usesRanking && m.ranking !== null ? `, ${rankLabel} #${m.ranking}` : ''}
</span> </span>
</div> </div>
@ -512,7 +537,7 @@ export default function AdminSportsSeasonEloRatings() {
<div key={u.inputName} className="flex justify-between px-3 py-1.5"> <div key={u.inputName} className="flex justify-between px-3 py-1.5">
<span>{u.inputName}</span> <span>{u.inputName}</span>
<span className="font-medium"> <span className="font-medium">
Elo {u.elo} {u.elo !== null ? `Elo ${u.elo}` : 'No Elo'}
{usesRanking && u.ranking !== null ? `, ${rankLabel} #${u.ranking}` : ''} {usesRanking && u.ranking !== null ? `, ${rankLabel} #${u.ranking}` : ''}
</span> </span>
</div> </div>
@ -538,15 +563,15 @@ export default function AdminSportsSeasonEloRatings() {
{inputMode === 'projectedWins' {inputMode === 'projectedWins'
? `${projectionLabel} (out of ${projectionMax || '?'})` ? `${projectionLabel} (out of ${projectionMax || '?'})`
: usesRanking : usesRanking
? `Player Elo & ${rankLabel}s` ? `${participantLabel} Elo${allowsRankOnly ? ' (optional)' : ''} & ${rankLabel}s`
: 'Player Elo Ratings'} : `${participantLabel} Elo Ratings`}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{inputMode === 'projectedWins' {inputMode === 'projectedWins'
? `Enter each team's projected total season ${projectionUnit}. Converted to Elo automatically. Saving will run the simulation and update expected values.` ? `Enter each team's projected total season ${projectionUnit}. Converted to Elo automatically. Saving will run the simulation and update expected values.`
: usesRanking : usesRanking
? `Enter each player's Elo and ${rankLabel}. Saving will automatically run the simulation and update expected values.` ? `Enter each ${participantLabel.toLowerCase()}'s Elo${allowsRankOnly ? ' (optional)' : ''} and ${rankLabel}. Saving will automatically run the simulation and update expected values.`
: 'Enter each player\'s current Elo rating. Saving will automatically run the simulation and update expected values.'} : `Enter each ${participantLabel.toLowerCase()}'s current Elo rating. Saving will automatically run the simulation and update expected values.`}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -554,7 +579,7 @@ export default function AdminSportsSeasonEloRatings() {
<input type="hidden" name="inputMode" value={inputMode} /> <input type="hidden" name="inputMode" value={inputMode} />
{usesRanking && inputMode === 'elo' && ( {usesRanking && inputMode === 'elo' && (
<div className="grid grid-cols-[1fr_100px_90px] gap-x-3 gap-y-1 items-center text-xs font-medium text-muted-foreground"> <div className="grid grid-cols-[1fr_100px_90px] gap-x-3 gap-y-1 items-center text-xs font-medium text-muted-foreground">
<span>Player</span> <span>{participantLabel}</span>
<span>Elo</span> <span>Elo</span>
<span>{rankLabel} #</span> <span>{rankLabel} #</span>
</div> </div>
@ -603,7 +628,7 @@ export default function AdminSportsSeasonEloRatings() {
type="number" type="number"
id={`elo_${participant.id}`} id={`elo_${participant.id}`}
name={`elo_${participant.id}`} name={`elo_${participant.id}`}
placeholder={usesRanking ? '1800' : '2450'} placeholder={usesRanking ? (allowsRankOnly ? 'optional' : '1800') : '2450'}
value={eloValues[participant.id] ?? ''} value={eloValues[participant.id] ?? ''}
onChange={e => onChange={e =>
setEloValues(prev => ({ ...prev, [participant.id]: e.target.value })) setEloValues(prev => ({ ...prev, [participant.id]: e.target.value }))
@ -664,7 +689,7 @@ export default function AdminSportsSeasonEloRatings() {
)} )}
{usesRanking && inputMode === 'elo' && ( {usesRanking && inputMode === 'elo' && (
<div className="mt-4 text-muted-foreground text-xs"> <div className="mt-4 text-muted-foreground text-xs">
{rankLabel} is optional if omitted, the simulator uses Elo order for seeding. {rankLabel} is optional if omitted, the simulator uses Elo order for seeding. {allowsRankOnly ? 'For college hockey, Elo is optional when an NPI rank is entered.' : ''}
</div> </div>
)} )}
{inputMode === 'projectedWins' && simulatorConfig && ( {inputMode === 'projectedWins' && simulatorConfig && (

View file

@ -0,0 +1,204 @@
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import {
buildCollegeHockeyTeams,
CollegeHockeySimulator,
npiRankSelectionWeight,
npiRankToElo,
} from "../college-hockey-simulator";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
const IDS = Array.from({ length: 16 }, (_, i) => `team-${i + 1}`);
const POOL_IDS = Array.from({ length: 20 }, (_, i) => `team-${i + 1}`);
function makeParticipants(ids: string[]) {
return ids.map((id) => ({ id }));
}
function makeEvRows(ids: string[]) {
return ids.map((participantId, i) => ({
participantId,
sourceElo: 1700 - i * 20,
sourceOdds: null,
worldRanking: i + 1,
}));
}
function makeMatch(round: string, matchNumber: number, p1?: string, p2?: string, winner?: string, loser?: string) {
return {
id: `${round}-${matchNumber}`,
scoringEventId: "event-1",
round,
templateRound: null,
matchNumber,
participant1Id: p1 ?? null,
participant2Id: p2 ?? null,
winnerId: winner ?? null,
loserId: loser ?? null,
isComplete: Boolean(winner),
participant1Score: null,
participant2Score: null,
isScoring: round !== "Regional Semifinals",
seedInfo: null,
createdAt: new Date(),
updatedAt: new Date(),
};
}
function makeCompletedBracket() {
const r16Winners = IDS.slice(0, 8);
const qfWinners = IDS.slice(0, 4);
const sfWinners = IDS.slice(0, 2);
return [
...Array.from({ length: 8 }, (_, i) =>
makeMatch("Regional Semifinals", i + 1, IDS[i], IDS[15 - i], r16Winners[i], IDS[15 - i])
),
...Array.from({ length: 4 }, (_, i) =>
makeMatch("Regional Finals", i + 1, r16Winners[i * 2], r16Winners[i * 2 + 1], qfWinners[i], r16Winners[i * 2 + 1])
),
...Array.from({ length: 2 }, (_, i) =>
makeMatch("Frozen Four Semifinals", i + 1, qfWinners[i * 2], qfWinners[i * 2 + 1], sfWinners[i], qfWinners[i * 2 + 1])
),
makeMatch("National Championship", 1, sfWinners[0], sfWinners[1], sfWinners[0], sfWinners[1]),
];
}
function makeBracketWithStaleFutureSlots() {
return [
...Array.from({ length: 8 }, (_, i) =>
makeMatch("Regional Semifinals", i + 1, IDS[i], IDS[15 - i])
),
// These incomplete downstream slots intentionally contain stale participants.
// The simulator should ignore them and use the simulated/completed upstream winners.
makeMatch("Regional Finals", 1, "team-9", "team-10"),
makeMatch("Regional Finals", 2, "team-11", "team-12"),
makeMatch("Regional Finals", 3, "team-13", "team-14"),
makeMatch("Regional Finals", 4, "team-15", "team-16"),
makeMatch("Frozen Four Semifinals", 1),
makeMatch("Frozen Four Semifinals", 2),
makeMatch("National Championship", 1),
];
}
describe("college hockey input helpers", () => {
it("maps better NPI ranks to higher Elo ratings", () => {
expect(npiRankToElo(1, 64)).toBeGreaterThan(npiRankToElo(16, 64));
});
it("maps better NPI ranks to higher Zipf selection weights", () => {
expect(npiRankSelectionWeight(1)).toBeGreaterThan(npiRankSelectionWeight(10));
});
it("builds team strength from admin-editable NPI rank when Elo is absent", () => {
const teams = buildCollegeHockeyTeams(["one", "two"], [
{ participantId: "one", sourceElo: null, sourceOdds: null, worldRanking: 1 },
{ participantId: "two", sourceElo: null, sourceOdds: null, worldRanking: 20 },
]);
expect(teams[0].elo).toBeGreaterThan(teams[1].elo);
expect(teams[0].selectionWeight).toBeGreaterThan(teams[1].selectionWeight);
});
it("uses NPI rank before Elo for seeding when both are present", () => {
const teams = buildCollegeHockeyTeams(["strong-elo", "better-npi"], [
{ participantId: "strong-elo", sourceElo: 1800, sourceOdds: null, worldRanking: 12 },
{ participantId: "better-npi", sourceElo: 1500, sourceOdds: null, worldRanking: 2 },
]);
const byId = new Map(teams.map((team) => [team.participantId, team]));
expect(byId.get("better-npi")?.seedScore).toBeGreaterThan(byId.get("strong-elo")?.seedScore ?? 0);
});
it("gives no-odds teams a fallback field-selection weight when odds are partial", () => {
const teams = buildCollegeHockeyTeams(["with-odds", "no-odds"], [
{ participantId: "with-odds", sourceElo: null, sourceOdds: 200, worldRanking: 1 },
{ participantId: "no-odds", sourceElo: null, sourceOdds: null, worldRanking: 2 },
]);
const noOdds = teams.find((team) => team.participantId === "no-odds");
expect(noOdds?.selectionWeight).toBeGreaterThan(0);
});
});
describe("CollegeHockeySimulator", () => {
let mockDb: {
query: {
scoringEvents: { findFirst: MockInstance };
playoffMatches: { findMany: MockInstance };
};
select: MockInstance;
};
let selectCallCount: number;
beforeEach(async () => {
selectCallCount = 0;
const { database } = await import("~/database/context");
mockDb = {
query: {
scoringEvents: { findFirst: vi.fn() },
playoffMatches: { findMany: vi.fn() },
},
select: vi.fn(),
};
(database as unknown as MockInstance).mockReturnValue(mockDb);
});
function setupPreBracket(ids: string[]) {
mockDb.query.scoringEvents.findFirst.mockResolvedValue(null);
mockDb.select.mockImplementation(() => {
const data = selectCallCount++ === 0 ? makeParticipants(ids) : makeEvRows(ids);
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) };
});
}
function setupExistingBracket(matches = makeCompletedBracket()) {
mockDb.query.scoringEvents.findFirst.mockResolvedValue({ id: "event-1" });
mockDb.query.playoffMatches.findMany.mockResolvedValue(matches);
mockDb.select.mockImplementation(() => ({
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(makeEvRows(IDS)) }),
}));
}
it("simulates a pre-bracket 20-team pool and returns all participants", async () => {
setupPreBracket(POOL_IDS);
const results = await new CollegeHockeySimulator().simulate("season-1");
expect(results).toHaveLength(20);
expect(results.every((r) => r.source === "college_hockey_monte_carlo")).toBe(true);
});
it("normalizes champion and finalist probability buckets", async () => {
setupPreBracket(IDS);
const results = await new CollegeHockeySimulator().simulate("season-1");
expect(results.reduce((sum, r) => sum + r.probabilities.probFirst, 0)).toBeCloseTo(1, 1);
expect(results.reduce((sum, r) => sum + r.probabilities.probSecond, 0)).toBeCloseTo(1, 1);
});
it("honors completed results in an existing Frozen Four bracket", async () => {
setupExistingBracket();
const results = await new CollegeHockeySimulator().simulate("season-1");
const byId = new Map(results.map((result) => [result.participantId, result]));
expect(byId.get("team-1")?.probabilities.probFirst).toBe(1);
expect(byId.get("team-2")?.probabilities.probSecond).toBe(1);
});
it("ignores stale future-round slots when upstream rounds are unresolved", async () => {
setupExistingBracket(makeBracketWithStaleFutureSlots());
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0);
try {
const results = await new CollegeHockeySimulator().simulate("season-1");
const byId = new Map(results.map((result) => [result.participantId, result]));
expect(byId.get("team-1")?.probabilities.probFirst).toBe(1);
expect(byId.get("team-9")?.probabilities.probFirst).toBe(0);
} finally {
randomSpy.mockRestore();
}
});
});

View file

@ -45,6 +45,10 @@ describe("simulator-config", () => {
expect(getSimulatorConfig("darts_bracket")).toBeNull(); expect(getSimulatorConfig("darts_bracket")).toBeNull();
}); });
it("returns null for college_hockey_bracket", () => {
expect(getSimulatorConfig("college_hockey_bracket")).toBeNull();
});
it("returns null for playoff_bracket", () => { it("returns null for playoff_bracket", () => {
expect(getSimulatorConfig("playoff_bracket")).toBeNull(); expect(getSimulatorConfig("playoff_bracket")).toBeNull();
}); });
@ -66,5 +70,9 @@ describe("simulator-config", () => {
it("returns false for darts_bracket", () => { it("returns false for darts_bracket", () => {
expect(supportsProjectedWins("darts_bracket")).toBe(false); expect(supportsProjectedWins("darts_bracket")).toBe(false);
}); });
it("returns false for college_hockey_bracket", () => {
expect(supportsProjectedWins("college_hockey_bracket")).toBe(false);
});
}); });
}); });

View file

@ -0,0 +1,423 @@
/**
* 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";
const NUM_SIMULATIONS = 50_000;
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;
}
function simGame(team1: Team, team2: Team): { winner: Team; loser: Team } {
const p1Wins = Math.random() < eloWinProbabilityWithParity(team1.elo, team2.elo, HOCKEY_PARITY_FACTOR);
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);
}
function simulateSeeded16TeamBracket(teams: Team[], counts: Map<string, PlacementCounts>): void {
const r16Pairs: Array<[number, number]> = [
[0, 15], [7, 8], [4, 11], [3, 12], [5, 10], [2, 13], [6, 9], [1, 14],
];
const r16Winners = r16Pairs.map(([a, b]) => simGame(teams[a], teams[b]).winner);
const qfWinners: Team[] = [];
for (let i = 0; i < 4; i++) {
const result = simGame(r16Winners[i * 2], r16Winners[i * 2 + 1]);
qfWinners.push(result.winner);
bump(counts, result.loser.participantId, "quarterfinalLoser");
}
const sf1 = simGame(qfWinners[0], qfWinners[1]);
const sf2 = simGame(qfWinners[2], qfWinners[3]);
bump(counts, sf1.loser.participantId, "semifinalLoser");
bump(counts, sf2.loser.participantId, "semifinalLoser");
const final = simGame(sf1.winner, sf2.winner);
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,
teamById: Map<string, Team>
): { winner: Team; loser: Team } {
if (match?.isComplete && match.winnerId) {
const loserId = completedLoser(match);
return { winner: getTeam(teamById, match.winnerId), loser: getTeam(teamById, loserId) };
}
return simGame(getTeam(teamById, p1), getTeam(teamById, p2));
}
function simulateExistingBracket(
rounds: { r16: PlayoffMatch[]; qf: PlayoffMatch[]; sf: PlayoffMatch[]; final: PlayoffMatch },
teamById: Map<string, Team>,
counts: Map<string, PlacementCounts>
): 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.`);
}
r16Winners.push(simulateMatchFromIds(match.participant1Id, match.participant2Id, match, teamById).winner);
}
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.`);
const result = simulateMatchFromIds(p1, p2, match, teamById);
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.`);
const result = simulateMatchFromIds(p1, p2, match, teamById);
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.`);
const final = simulateMatchFromIds(p1, p2, rounds.final, teamById);
bump(counts, final.winner.participantId, "champion");
bump(counts, final.loser.participantId, "finalist");
}
function toResults(participantIds: string[], counts: Map<string, PlacementCounts>): SimulationResult[] {
const results = participantIds.map((participantId) => {
const c = counts.get(participantId) ?? { champion: 0, finalist: 0, semifinalLoser: 0, quarterfinalLoser: 0 };
const sf = c.semifinalLoser / (2 * NUM_SIMULATIONS);
const qf = c.quarterfinalLoser / (4 * NUM_SIMULATIONS);
return {
participantId,
probabilities: {
probFirst: c.champion / NUM_SIMULATIONS,
probSecond: c.finalist / NUM_SIMULATIONS,
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 {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
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) {
for (let i = 0; i < NUM_SIMULATIONS; i++) {
simulateExistingBracket(existingBracket, teamById, counts);
}
} else {
const preBracketMode = participantIds.length > BRACKET_SIZE;
const deterministicField = preBracketMode ? null : seedField(teams);
for (let i = 0; i < NUM_SIMULATIONS; i++) {
const field = preBracketMode ? sampleField(teams, BRACKET_SIZE) : deterministicField ?? [];
simulateSeeded16TeamBracket(field, counts);
}
}
return toResults(participantIds, counts);
}
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 };

View file

@ -27,6 +27,7 @@ import { WNBASimulator } from "./wnba-simulator";
import { WorldCupSimulator } from "./world-cup-simulator"; import { WorldCupSimulator } from "./world-cup-simulator";
import { NCAAFootballSimulator } from "./ncaa-football-simulator"; import { NCAAFootballSimulator } from "./ncaa-football-simulator";
import { LLWSSimulator } from "./llws-simulator"; import { LLWSSimulator } from "./llws-simulator";
import { CollegeHockeySimulator } from "./college-hockey-simulator";
import { BracktSimulator } from "./brackt-simulator"; import { BracktSimulator } from "./brackt-simulator";
export const SIMULATOR_TYPES = [ export const SIMULATOR_TYPES = [
@ -51,6 +52,7 @@ export const SIMULATOR_TYPES = [
"cs2_major_qualifying_points", "cs2_major_qualifying_points",
"ncaa_football_bracket", "ncaa_football_bracket",
"llws_bracket", "llws_bracket",
"college_hockey_bracket",
"brackt", "brackt",
] as const; ] as const;
@ -166,6 +168,13 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
}, },
create: () => new LLWSSimulator(), create: () => new LLWSSimulator(),
}, },
college_hockey_bracket: {
info: {
name: "Men's College Hockey Monte Carlo",
description: "Simulates the 16-team NCAA men's hockey tournament. Pre-bracket mode samples the field using admin-entered futures odds, NPI rank, and/or Elo; bracket mode honors the existing Frozen Four bracket and completed results.",
},
create: () => new CollegeHockeySimulator(),
},
brackt: { brackt: {
info: { info: {
name: "Brackt Harville Model", name: "Brackt Harville Model",

View file

@ -79,6 +79,7 @@ const CONFIG: Record<SimulatorType, SimulatorConfig | null> = {
cs2_major_qualifying_points: null, cs2_major_qualifying_points: null,
ncaa_football_bracket: null, ncaa_football_bracket: null,
llws_bracket: null, llws_bracket: null,
college_hockey_bracket: null,
brackt: null, brackt: null,
}; };

View file

@ -152,6 +152,7 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [
"cs2_major_qualifying_points", "cs2_major_qualifying_points",
"ncaa_football_bracket", "ncaa_football_bracket",
"llws_bracket", "llws_bracket",
"college_hockey_bracket",
"brackt", "brackt",
]); ]);

View file

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

File diff suppressed because it is too large Load diff

View file

@ -687,6 +687,13 @@
"when": 1778189247519, "when": 1778189247519,
"tag": "0097_puzzling_spectrum", "tag": "0097_puzzling_spectrum",
"breakpoints": true "breakpoints": true
},
{
"idx": 98,
"version": "7",
"when": 1778261539567,
"tag": "0098_lucky_sabra",
"breakpoints": true
} }
] ]
} }