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)
|
* 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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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} → Elo {m.elo}
|
{m.name} → {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 && (
|
||||||
|
|
|
||||||
|
|
@ -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();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
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 { 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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue