Fix college hockey bracket resolution (#396)
This commit is contained in:
parent
777315541b
commit
79092f0409
13 changed files with 6560 additions and 44 deletions
|
|
@ -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)
|
||||
* 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_8: SIMPLE_8,
|
||||
simple_16: SIMPLE_16,
|
||||
college_hockey_16: COLLEGE_HOCKEY_16,
|
||||
simple_32: SIMPLE_32,
|
||||
ncaa_68: NCAA_68,
|
||||
nfl_14: NFL_14,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
SIMPLE_8,
|
||||
SIMPLE_16,
|
||||
SIMPLE_32,
|
||||
COLLEGE_HOCKEY_16,
|
||||
NCAA_68,
|
||||
NFL_14,
|
||||
getBracketTemplate,
|
||||
|
|
@ -44,6 +45,21 @@ describe("Bracket Templates", () => {
|
|||
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", () => {
|
||||
expect(SIMPLE_32.id).toBe("simple_32");
|
||||
expect(SIMPLE_32.totalTeams).toBe(32);
|
||||
|
|
@ -145,6 +161,7 @@ describe("Bracket Templates", () => {
|
|||
expect(getBracketTemplate("simple_4")).toBe(SIMPLE_4);
|
||||
expect(getBracketTemplate("simple_8")).toBe(SIMPLE_8);
|
||||
expect(getBracketTemplate("simple_16")).toBe(SIMPLE_16);
|
||||
expect(getBracketTemplate("college_hockey_16")).toBe(COLLEGE_HOCKEY_16);
|
||||
expect(getBracketTemplate("ncaa_68")).toBe(NCAA_68);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -425,7 +425,7 @@ export async function batchSaveSourceOdds(
|
|||
* Uses INSERT ... ON CONFLICT DO UPDATE to avoid N+1 queries.
|
||||
*/
|
||||
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> {
|
||||
if (inputs.length === 0) return;
|
||||
const db = database();
|
||||
|
|
|
|||
|
|
@ -40,10 +40,12 @@ import {
|
|||
} from '~/services/probability-engine';
|
||||
|
||||
// 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 {
|
||||
if (simulatorType === 'cs2_major_qualifying_points') return 'HLTV Rank';
|
||||
if (simulatorType === 'college_hockey_bracket') return 'NPI Rank';
|
||||
return 'World Rank';
|
||||
}
|
||||
|
||||
|
|
@ -98,11 +100,13 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
|
||||
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 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) {
|
||||
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 rankVal = formData.get(`rank_${participant.id}`) as string;
|
||||
|
||||
if (eloVal && eloVal.trim() !== '') {
|
||||
const elo = Number(eloVal);
|
||||
if (!isNaN(elo) && elo > 0) {
|
||||
if (usesRanking) {
|
||||
const ranking = rankVal && rankVal.trim() !== '' ? Number(rankVal) : null;
|
||||
eloInputs.push({
|
||||
participantId: participant.id,
|
||||
sportsSeasonId,
|
||||
sourceElo: elo,
|
||||
worldRanking: ranking && !isNaN(ranking) && ranking > 0 ? ranking : null,
|
||||
});
|
||||
} else {
|
||||
eloInputs.push({ participantId: participant.id, sportsSeasonId, sourceElo: elo });
|
||||
}
|
||||
const elo = eloVal && eloVal.trim() !== '' ? Number(eloVal) : null;
|
||||
const ranking = rankVal && rankVal.trim() !== '' ? Number(rankVal) : null;
|
||||
const validElo = elo !== null && !isNaN(elo) && elo > 0;
|
||||
const validRanking = ranking !== null && !isNaN(ranking) && ranking > 0;
|
||||
|
||||
if (validElo || (usesRanking && allowsRankOnly && validRanking)) {
|
||||
if (usesRanking) {
|
||||
eloInputs.push({
|
||||
participantId: participant.id,
|
||||
sportsSeasonId,
|
||||
sourceElo: validElo ? elo : null,
|
||||
worldRanking: validRanking ? ranking : null,
|
||||
});
|
||||
} 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) {
|
||||
return { success: false, message: inputMode === 'projectedWins'
|
||||
? '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) {
|
||||
|
|
@ -286,8 +293,8 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
|
||||
const [bulkText, setBulkText] = useState('');
|
||||
const [parseResults, setParseResults] = useState<{
|
||||
matched: Array<{ participantId: string; name: string; elo: number; ranking: number | null; inputName: string }>;
|
||||
unmatched: Array<{ inputName: string; elo: number; ranking: number | null }>;
|
||||
matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }>;
|
||||
unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null }>;
|
||||
} | null>(null);
|
||||
|
||||
function findParticipantMatch(inputName: string) {
|
||||
|
|
@ -312,8 +319,8 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
|
||||
function parseBulkText() {
|
||||
const lines = bulkText.split('\n');
|
||||
const matched: Array<{ participantId: string; name: string; elo: number; ranking: number | null; inputName: string }> = [];
|
||||
const unmatched: Array<{ inputName: string; elo: number; ranking: number | null }> = [];
|
||||
const matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }> = [];
|
||||
const unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null }> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const line of lines) {
|
||||
|
|
@ -342,15 +349,23 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
}
|
||||
} else {
|
||||
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);
|
||||
if (!match) continue;
|
||||
|
||||
const inputName = match[1].trim();
|
||||
const elo = parseInt(match[2], 10);
|
||||
const ranking = usesRanking && match[3] ? parseInt(match[3], 10) : null;
|
||||
const firstNumber = parseInt(match[2], 10);
|
||||
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);
|
||||
if (participant && !seen.has(participant.id)) {
|
||||
|
|
@ -371,9 +386,9 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
const newRanks = { ...rankValues };
|
||||
const newWins = { ...winsValues };
|
||||
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 (inputMode === 'projectedWins' && simulatorConfig) {
|
||||
if (inputMode === 'projectedWins' && simulatorConfig && m.elo !== null) {
|
||||
newWins[m.participantId] = (simulatorConfig.projectionInput === 'tablePoints'
|
||||
? eloToProjectedTablePoints(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 simulatorType = sportsSeason.sport?.simulatorType ?? null;
|
||||
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
|
||||
const sortedParticipants = usesRanking
|
||||
|
|
@ -446,8 +463,12 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
) : usesRanking ? (
|
||||
<>
|
||||
Paste one entry per line. Format:{' '}
|
||||
<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.
|
||||
{allowsRankOnly ? (
|
||||
<>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, 15.6\nTeam Name, 14.5\nTeam Name, 13.8`
|
||||
: usesRanking
|
||||
? simulatorType === 'cs2_major_qualifying_points'
|
||||
? `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\nRonnie O'Sullivan, 2441\nMark Selby, 2432`
|
||||
? simulatorType === 'college_hockey_bracket'
|
||||
? `Denver, 1\nBoston College, 2\nMichigan State, 1650, 3`
|
||||
: simulatorType === 'cs2_major_qualifying_points'
|
||||
? `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}
|
||||
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">
|
||||
<span className="text-muted-foreground">{m.inputName}</span>
|
||||
<span className="font-medium">
|
||||
{m.name} → Elo {m.elo}
|
||||
{m.name} → {m.elo !== null ? `Elo ${m.elo}` : 'No Elo'}
|
||||
{usesRanking && m.ranking !== null ? `, ${rankLabel} #${m.ranking}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -512,7 +537,7 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
<div key={u.inputName} className="flex justify-between px-3 py-1.5">
|
||||
<span>{u.inputName}</span>
|
||||
<span className="font-medium">
|
||||
Elo {u.elo}
|
||||
{u.elo !== null ? `Elo ${u.elo}` : 'No Elo'}
|
||||
{usesRanking && u.ranking !== null ? `, ${rankLabel} #${u.ranking}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -538,15 +563,15 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
{inputMode === 'projectedWins'
|
||||
? `${projectionLabel} (out of ${projectionMax || '?'})`
|
||||
: usesRanking
|
||||
? `Player Elo & ${rankLabel}s`
|
||||
: 'Player Elo Ratings'}
|
||||
? `${participantLabel} Elo${allowsRankOnly ? ' (optional)' : ''} & ${rankLabel}s`
|
||||
: `${participantLabel} Elo Ratings`}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{inputMode === 'projectedWins'
|
||||
? `Enter each team's projected total season ${projectionUnit}. Converted to Elo automatically. Saving will run the simulation and update expected values.`
|
||||
: usesRanking
|
||||
? `Enter each player's Elo 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 Elo${allowsRankOnly ? ' (optional)' : ''} and ${rankLabel}. 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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -554,7 +579,7 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
<input type="hidden" name="inputMode" value={inputMode} />
|
||||
{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">
|
||||
<span>Player</span>
|
||||
<span>{participantLabel}</span>
|
||||
<span>Elo</span>
|
||||
<span>{rankLabel} #</span>
|
||||
</div>
|
||||
|
|
@ -603,7 +628,7 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
type="number"
|
||||
id={`elo_${participant.id}`}
|
||||
name={`elo_${participant.id}`}
|
||||
placeholder={usesRanking ? '1800' : '2450'}
|
||||
placeholder={usesRanking ? (allowsRankOnly ? 'optional' : '1800') : '2450'}
|
||||
value={eloValues[participant.id] ?? ''}
|
||||
onChange={e =>
|
||||
setEloValues(prev => ({ ...prev, [participant.id]: e.target.value }))
|
||||
|
|
@ -664,7 +689,7 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
)}
|
||||
{usesRanking && inputMode === 'elo' && (
|
||||
<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>
|
||||
)}
|
||||
{inputMode === 'projectedWins' && simulatorConfig && (
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -45,6 +45,10 @@ describe("simulator-config", () => {
|
|||
expect(getSimulatorConfig("darts_bracket")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for college_hockey_bracket", () => {
|
||||
expect(getSimulatorConfig("college_hockey_bracket")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for playoff_bracket", () => {
|
||||
expect(getSimulatorConfig("playoff_bracket")).toBeNull();
|
||||
});
|
||||
|
|
@ -66,5 +70,9 @@ describe("simulator-config", () => {
|
|||
it("returns false for darts_bracket", () => {
|
||||
expect(supportsProjectedWins("darts_bracket")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for college_hockey_bracket", () => {
|
||||
expect(supportsProjectedWins("college_hockey_bracket")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
423
app/services/simulations/college-hockey-simulator.ts
Normal file
423
app/services/simulations/college-hockey-simulator.ts
Normal 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 };
|
||||
|
|
@ -27,6 +27,7 @@ import { WNBASimulator } from "./wnba-simulator";
|
|||
import { WorldCupSimulator } from "./world-cup-simulator";
|
||||
import { NCAAFootballSimulator } from "./ncaa-football-simulator";
|
||||
import { LLWSSimulator } from "./llws-simulator";
|
||||
import { CollegeHockeySimulator } from "./college-hockey-simulator";
|
||||
import { BracktSimulator } from "./brackt-simulator";
|
||||
|
||||
export const SIMULATOR_TYPES = [
|
||||
|
|
@ -51,6 +52,7 @@ export const SIMULATOR_TYPES = [
|
|||
"cs2_major_qualifying_points",
|
||||
"ncaa_football_bracket",
|
||||
"llws_bracket",
|
||||
"college_hockey_bracket",
|
||||
"brackt",
|
||||
] as const;
|
||||
|
||||
|
|
@ -166,6 +168,13 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
|
|||
},
|
||||
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: {
|
||||
info: {
|
||||
name: "Brackt Harville Model",
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ const CONFIG: Record<SimulatorType, SimulatorConfig | null> = {
|
|||
cs2_major_qualifying_points: null,
|
||||
ncaa_football_bracket: null,
|
||||
llws_bracket: null,
|
||||
college_hockey_bracket: null,
|
||||
brackt: null,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [
|
|||
"cs2_major_qualifying_points",
|
||||
"ncaa_football_bracket",
|
||||
"llws_bracket",
|
||||
"college_hockey_bracket",
|
||||
"brackt",
|
||||
]);
|
||||
|
||||
|
|
|
|||
1
drizzle/0098_lucky_sabra.sql
Normal file
1
drizzle/0098_lucky_sabra.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TYPE "public"."simulator_type" ADD VALUE 'college_hockey_bracket' BEFORE 'brackt';
|
||||
5781
drizzle/meta/0098_snapshot.json
Normal file
5781
drizzle/meta/0098_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -687,6 +687,13 @@
|
|||
"when": 1778189247519,
|
||||
"tag": "0097_puzzling_spectrum",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 98,
|
||||
"version": "7",
|
||||
"when": 1778261539567,
|
||||
"tag": "0098_lucky_sabra",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue