Add projected-wins input mode to admin Elo Ratings page (#306)

* Add projected-wins input mode to admin Elo Ratings page

Admins can now enter projected season win totals instead of raw Elo
numbers on the Elo Ratings page. Wins are auto-converted to Elo using
the inverse formula and stored as sourceElo, keeping the rest of the
simulation pipeline unchanged.

- Add `projectedWinsToElo` / `eloToProjectedWins` to probability-engine
- Add `simulator-config.ts` centralising per-sport season length, parity
  factor, and average opponent Elo (AFL, NFL, NBA, NHL, MLB, WNBA)
- Admin Elo Ratings page: toggle between Elo and Projected Wins input
  modes; bulk import parses wins format; existing sourceElo back-fills
  the wins field on load
- AFL simulator now reads sourceElo from participantExpectedValues first,
  falling back to hardcoded TEAMS_DATA then 1400

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix lint errors in simulator-config tests and probability-engine

- Replace non-null assertions (`!`) with optional chaining (`?.`) in simulator-config tests
- Remove redundant type annotations on default parameters in probability-engine

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-04-17 12:40:08 -07:00 committed by GitHub
parent e03bdd538f
commit 7fa88fc0ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 672 additions and 122 deletions

View file

@ -31,6 +31,8 @@ import {
import { useState } from 'react'; import { useState } from 'react';
import { Loader2, CheckCircle2, AlertCircle } from 'lucide-react'; import { Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
import { normalizeName } from '~/lib/fuzzy-match'; import { normalizeName } from '~/lib/fuzzy-match';
import { getSimulatorConfig, supportsProjectedWins } from '~/services/simulations/simulator-config';
import { eloToProjectedWins, projectedWinsToElo } 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']);
@ -66,7 +68,14 @@ export async function loader({ params }: Route.LoaderArgs) {
const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? ''); const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? '');
return { sportsSeason, participants, existingData, usesRanking }; const simulatorConfig = sportsSeason.sport?.simulatorType
? getSimulatorConfig(sportsSeason.sport.simulatorType as SimulatorType)
: null;
const canUseProjectedWins = sportsSeason.sport?.simulatorType
? supportsProjectedWins(sportsSeason.sport.simulatorType as SimulatorType)
: false;
return { sportsSeason, participants, existingData, usesRanking, simulatorConfig, canUseProjectedWins };
} }
interface ActionData { interface ActionData {
@ -85,8 +94,28 @@ 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 usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.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; worldRanking?: number | null }> = [];
if (inputMode === 'projectedWins' && sportsSeason.sport?.simulatorType) {
const config = getSimulatorConfig(sportsSeason.sport.simulatorType as SimulatorType);
if (!config) {
return { success: false, message: 'This sport does not support projected wins input.' };
}
for (const participant of participants) {
const winsVal = formData.get(`wins_${participant.id}`) as string;
if (winsVal && winsVal.trim() !== '') {
const projectedWins = parseFloat(winsVal);
if (!isNaN(projectedWins) && projectedWins >= 0 && projectedWins <= config.seasonGames) {
const elo = projectedWinsToElo(projectedWins, config.seasonGames, config.parityFactor, config.averageOpponentElo);
eloInputs.push({ participantId: participant.id, sportsSeasonId, sourceElo: elo });
}
}
}
} else {
for (const participant of participants) { for (const participant of participants) {
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;
@ -108,9 +137,12 @@ export async function action({ request, params }: Route.ActionArgs) {
} }
} }
} }
}
if (eloInputs.length === 0) { if (eloInputs.length === 0) {
return { success: false, message: 'Please enter an Elo rating for at least one participant' }; return { success: false, message: inputMode === 'projectedWins'
? 'Please enter projected wins for at least one participant'
: 'Please enter an Elo rating for at least one participant' };
} }
if (!sportsSeason.sport?.simulatorType) { if (!sportsSeason.sport?.simulatorType) {
@ -197,10 +229,12 @@ export async function action({ request, params }: Route.ActionArgs) {
} }
export default function AdminSportsSeasonEloRatings() { export default function AdminSportsSeasonEloRatings() {
const { sportsSeason, participants, existingData, usesRanking } = useLoaderData<typeof loader>(); const { sportsSeason, participants, existingData, usesRanking, simulatorConfig, canUseProjectedWins } = useLoaderData<typeof loader>();
const actionData = useActionData<ActionData>(); const actionData = useActionData<ActionData>();
const navigation = useNavigation(); const navigation = useNavigation();
const [inputMode, setInputMode] = useState<'elo' | 'projectedWins'>('elo');
const [eloValues, setEloValues] = useState<Record<string, string>>(() => { const [eloValues, setEloValues] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {}; const initial: Record<string, string> = {};
participants.forEach(p => { participants.forEach(p => {
@ -219,6 +253,21 @@ export default function AdminSportsSeasonEloRatings() {
return initial; return initial;
}); });
const [winsValues, setWinsValues] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
if (simulatorConfig) {
participants.forEach(p => {
const d = existingData[p.id];
if (d?.elo !== null && d?.elo !== undefined) {
initial[p.id] = eloToProjectedWins(
d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo
).toFixed(1);
}
});
}
return initial;
});
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; ranking: number | null; inputName: string }>;
@ -255,7 +304,25 @@ export default function AdminSportsSeasonEloRatings() {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed) continue; if (!trimmed) continue;
// Match "Name, Elo" or "Name, Elo, Ranking" (comma/colon/tab separated) if (inputMode === 'projectedWins' && simulatorConfig) {
const match = /^(.+?)[\s,:\t]+(\d+(?:\.\d+)?)\s*$/.exec(trimmed);
if (!match) continue;
const inputName = match[1].trim();
const projectedWins = parseFloat(match[2]);
if (isNaN(projectedWins) || projectedWins < 0 || projectedWins > simulatorConfig.seasonGames) continue;
const elo = projectedWinsToElo(projectedWins, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo);
const participant = findParticipantMatch(inputName);
if (participant && !seen.has(participant.id)) {
seen.add(participant.id);
matched.push({ participantId: participant.id, name: participant.name, elo, ranking: null, inputName });
} else if (!participant) {
unmatched.push({ inputName, elo, ranking: null });
}
} else {
const match = usesRanking const match = usesRanking
? /^(.+?)[\s,:\t]+(\d{3,5})(?:[\s,:\t]+(\d{1,3}))?\s*$/.exec(trimmed) ? /^(.+?)[\s,:\t]+(\d{3,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);
@ -275,6 +342,7 @@ export default function AdminSportsSeasonEloRatings() {
unmatched.push({ inputName, elo, ranking }); unmatched.push({ inputName, elo, ranking });
} }
} }
}
setParseResults({ matched, unmatched }); setParseResults({ matched, unmatched });
} }
@ -283,12 +351,19 @@ export default function AdminSportsSeasonEloRatings() {
if (!parseResults) return; if (!parseResults) return;
const newElos = { ...eloValues }; const newElos = { ...eloValues };
const newRanks = { ...rankValues }; const newRanks = { ...rankValues };
const newWins = { ...winsValues };
for (const m of parseResults.matched) { for (const m of parseResults.matched) {
newElos[m.participantId] = m.elo.toString(); 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) {
newWins[m.participantId] = eloToProjectedWins(
m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo
).toFixed(1);
}
} }
setEloValues(newElos); setEloValues(newElos);
setRankValues(newRanks); setRankValues(newRanks);
setWinsValues(newWins);
setParseResults(null); setParseResults(null);
setBulkText(''); setBulkText('');
} }
@ -318,12 +393,38 @@ export default function AdminSportsSeasonEloRatings() {
</p> </p>
</div> </div>
{/* Input Mode Toggle */}
{canUseProjectedWins && !usesRanking && (
<div className="mb-6 flex gap-2">
<Button
type="button"
variant={inputMode === 'elo' ? 'default' : 'outline'}
onClick={() => setInputMode('elo')}
>
Elo Ratings
</Button>
<Button
type="button"
variant={inputMode === 'projectedWins' ? 'default' : 'outline'}
onClick={() => setInputMode('projectedWins')}
>
Projected Wins
</Button>
</div>
)}
{/* Bulk Import */} {/* Bulk Import */}
<Card className="mb-6"> <Card className="mb-6">
<CardHeader> <CardHeader>
<CardTitle>Bulk Import</CardTitle> <CardTitle>Bulk Import</CardTitle>
<CardDescription> <CardDescription>
{usesRanking ? ( {inputMode === 'projectedWins' && simulatorConfig ? (
<>
Paste projected season wins one per line. Format: <code>Team Name, 15.6</code>.
Wins are automatically converted to Elo ratings using {simulatorConfig.seasonGames} total games
and parity factor {simulatorConfig.parityFactor}. Names are fuzzy-matched to participants.
</>
) : 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 <code>Name, Elo, {rankLabel}</code> (ranking is optional omit it and the simulator
@ -340,7 +441,9 @@ export default function AdminSportsSeasonEloRatings() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<Textarea <Textarea
placeholder={ placeholder={
usesRanking inputMode === 'projectedWins'
? `Team Name, 15.6\nTeam Name, 14.5\nTeam Name, 13.8`
: usesRanking
? simulatorType === 'cs2_major_qualifying_points' ? simulatorType === 'cs2_major_qualifying_points'
? `Natus Vincere, 1850, 1\nFaZe Clan, 1820, 2\nVitality, 1810, 3` ? `Natus Vincere, 1850, 1\nFaZe Clan, 1820, 2\nVitality, 1810, 3`
: `Luke Littler, 2099, 1\nMichael van Gerwen, 1950, 2\nLuke Humphries, 1947, 3` : `Luke Littler, 2099, 1\nMichael van Gerwen, 1950, 2\nLuke Humphries, 1947, 3`
@ -411,25 +514,61 @@ export default function AdminSportsSeasonEloRatings() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
{usesRanking ? `Player Elo & ${rankLabel}s` : 'Player Elo Ratings'} {inputMode === 'projectedWins'
? `Projected Season Wins (out of ${simulatorConfig?.seasonGames ?? '?'})`
: usesRanking
? `Player Elo & ${rankLabel}s`
: 'Player Elo Ratings'}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{usesRanking {inputMode === 'projectedWins'
? 'Enter each team\'s projected total season wins. 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 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 player\'s current Elo rating. Saving will automatically run the simulation and update expected values.'}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Form method="post" className="space-y-4"> <Form method="post" className="space-y-4">
{usesRanking && ( <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"> <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>Player</span>
<span>Elo</span> <span>Elo</span>
<span>{rankLabel} #</span> <span>{rankLabel} #</span>
</div> </div>
)} )}
{inputMode === 'projectedWins' && (
<div className="grid grid-cols-[1fr_100px] gap-x-3 gap-y-1 items-center text-xs font-medium text-muted-foreground">
<span>Team</span>
<span>Proj Wins</span>
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
{sortedParticipants.map(participant => ( {sortedParticipants.map(participant => {
if (inputMode === 'projectedWins' && simulatorConfig) {
return (
<div key={participant.id} className="grid grid-cols-[1fr_100px] gap-x-3 items-center">
<Label htmlFor={`wins_${participant.id}`} className="truncate text-sm">
{participant.name}
</Label>
<Input
type="number"
step="0.1"
id={`wins_${participant.id}`}
name={`wins_${participant.id}`}
placeholder={`${(simulatorConfig.seasonGames / 2).toFixed(1)}`}
value={winsValues[participant.id] ?? ''}
onChange={e =>
setWinsValues(prev => ({ ...prev, [participant.id]: e.target.value }))
}
className="h-8 text-sm"
/>
</div>
);
}
return (
<div <div
key={participant.id} key={participant.id}
className={usesRanking className={usesRanking
@ -463,7 +602,8 @@ export default function AdminSportsSeasonEloRatings() {
/> />
)} )}
</div> </div>
))} );
})}
</div> </div>
{actionData && !actionData.success && actionData.message && ( {actionData && !actionData.success && actionData.message && (
@ -483,6 +623,15 @@ export default function AdminSportsSeasonEloRatings() {
<CardTitle>How It Works</CardTitle> <CardTitle>How It Works</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="text-sm space-y-2"> <CardContent className="text-sm space-y-2">
{inputMode === 'projectedWins' ? (
<ol className="list-decimal list-inside space-y-2">
<li>Enter each team's projected total season wins</li>
<li>Wins are converted to Elo ratings using the inverse Elo formula</li>
<li>Elo ratings are saved and the simulation runs automatically</li>
<li>Results: probability distributions across 1st8th place buckets</li>
<li>Expected fantasy value is calculated per team</li>
</ol>
) : (
<ol className="list-decimal list-inside space-y-2"> <ol className="list-decimal list-inside space-y-2">
<li>Save Elo ratings{usesRanking ? ` and ${rankLabel}s` : ''} for all participants</li> <li>Save Elo ratings{usesRanking ? ` and ${rankLabel}s` : ''} for all participants</li>
<li>Compute per-game win probability from Elo difference</li> <li>Compute per-game win probability from Elo difference</li>
@ -491,11 +640,18 @@ export default function AdminSportsSeasonEloRatings() {
<li>Distribute probabilities across 1st8th place buckets</li> <li>Distribute probabilities across 1st8th place buckets</li>
<li>Calculate expected fantasy value per player</li> <li>Calculate expected fantasy value per player</li>
</ol> </ol>
{usesRanking && ( )}
{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.
</div> </div>
)} )}
{inputMode === 'projectedWins' && simulatorConfig && (
<div className="mt-4 text-muted-foreground text-xs">
Conversion: Elo = {simulatorConfig.averageOpponentElo} &minus; {simulatorConfig.parityFactor} &times; log&#8321;&#8320;((1 &minus; winRate) / winRate),
where winRate = projectedWins / {simulatorConfig.seasonGames}.
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View file

@ -8,6 +8,8 @@ import {
eloWinProbability, eloWinProbability,
convertFuturesToElo, convertFuturesToElo,
calculatePredictionError, calculatePredictionError,
projectedWinsToElo,
eloToProjectedWins,
} from '../probability-engine'; } from '../probability-engine';
describe('probability-engine', () => { describe('probability-engine', () => {
@ -319,4 +321,78 @@ describe('probability-engine', () => {
expect(error).toBeLessThan(0.25); // 25% tolerance before calibration expect(error).toBeLessThan(0.25); // 25% tolerance before calibration
}); });
}); });
describe('projectedWinsToElo', () => {
it('converts average projected wins to 1500 Elo', () => {
// 11.5 wins out of 23 = 0.5 win rate = 1500 Elo
expect(projectedWinsToElo(11.5, 23, 450)).toBe(1500);
});
it('converts AFL 2026 Bulldogs (15.6 wins / 23) to ~1646 Elo', () => {
expect(projectedWinsToElo(15.6, 23, 450)).toBe(1646);
});
it('converts AFL 2026 Essendon (7.1 wins / 23) to ~1342 Elo', () => {
expect(projectedWinsToElo(7.1, 23, 450)).toBe(1342);
});
it('higher projected wins produce higher Elo', () => {
const elo10 = projectedWinsToElo(10, 23, 450);
const elo15 = projectedWinsToElo(15, 23, 450);
expect(elo15).toBeGreaterThan(elo10);
});
it('uses parity factor 400 as default', () => {
// NFL-like: 8.5/17 = 0.5 → 1500
expect(projectedWinsToElo(8.5, 17)).toBe(1500);
});
it('handles 0 projected wins (floor)', () => {
const elo = projectedWinsToElo(0, 23, 450);
expect(elo).toBeLessThan(1000);
});
it('handles max projected wins (cap)', () => {
const elo = projectedWinsToElo(23, 23, 450);
expect(elo).toBeGreaterThan(2500);
});
it('is inverse of eloToProjectedWins (round-trip)', () => {
const wins = 14.3;
const elo = projectedWinsToElo(wins, 23, 450);
const roundTrip = eloToProjectedWins(elo, 23, 450);
expect(roundTrip).toBeCloseTo(wins, 0);
});
it('throws for negative projected wins', () => {
expect(() => projectedWinsToElo(-1, 23, 450)).toThrow('between 0 and');
});
it('throws for projected wins exceeding total games', () => {
expect(() => projectedWinsToElo(25, 23, 450)).toThrow('between 0 and');
});
it('throws for zero total games', () => {
expect(() => projectedWinsToElo(5, 0, 450)).toThrow('must be positive');
});
});
describe('eloToProjectedWins', () => {
it('returns half the season for 1500 Elo (average)', () => {
expect(eloToProjectedWins(1500, 23, 450)).toBeCloseTo(11.5, 1);
});
it('returns more wins for higher Elo', () => {
const winsHigh = eloToProjectedWins(1700, 23, 450);
const winsLow = eloToProjectedWins(1300, 23, 450);
expect(winsHigh).toBeGreaterThan(winsLow);
});
it('round-trips with projectedWinsToElo', () => {
const elo = 1600;
const wins = eloToProjectedWins(elo, 23, 450);
const roundTrip = projectedWinsToElo(wins, 23, 450);
expect(roundTrip).toBe(elo);
});
});
}); });

View file

@ -293,3 +293,78 @@ export function calculatePredictionError(
return { meanError, maxError }; return { meanError, maxError };
} }
/**
* Convert projected season win totals to an Elo rating.
*
* Given a team's projected total wins (including games already played),
* derives the Elo rating that would produce that win rate against an
* average opponent over the full season.
*
* Inverse of the Elo win probability formula:
* elo = avgElo - parityFactor × log((1 winRate) / winRate)
* where winRate = projectedWins / totalGames
*
* @param projectedWins Total projected wins for the season (e.g. 15.6)
* @param totalGames Total regular season games (e.g. 23 for AFL)
* @param parityFactor Elo parity factor for the sport (e.g. 450 for AFL)
* @param averageElo Elo of an average opponent (typically 1500)
* @returns Elo rating (rounded to integer)
*
* @example
* projectedWinsToElo(15.6, 23, 450) // 1646 (Western Bulldogs 2026)
* projectedWinsToElo(11.5, 23, 450) // 1500 (average team)
* projectedWinsToElo(7.1, 23, 450) // 1342 (Essendon 2026)
*/
export function projectedWinsToElo(
projectedWins: number,
totalGames: number,
parityFactor = 400,
averageElo = 1500
): number {
if (totalGames <= 0) {
throw new Error('Total games must be positive');
}
if (parityFactor <= 0) {
throw new Error('Parity factor must be positive');
}
if (projectedWins < 0 || projectedWins > totalGames) {
throw new Error(
`Projected wins (${projectedWins}) must be between 0 and total games (${totalGames})`
);
}
const winRate = projectedWins / totalGames;
// Edge cases: clamp to avoid log(0) or division by zero
if (winRate >= 1.0) return averageElo + parityFactor * 3; // ~dominant cap
if (winRate <= 0.0) return averageElo - parityFactor * 3; // ~terrible floor
const elo = averageElo - parityFactor * Math.log10((1 - winRate) / winRate);
return Math.round(elo);
}
/**
* Convert an Elo rating back to projected season wins.
*
* This is the forward direction of projectedWinsToElo:
* winRate = 1 / (1 + 10^((averageElo - elo) / parityFactor))
* projectedWins = winRate × totalGames
*
* Useful for displaying the equivalent projected wins for a given Elo rating.
*
* @param elo Elo rating
* @param totalGames Total regular season games
* @param parityFactor Elo parity factor
* @param averageElo Elo of an average opponent
* @returns Projected wins (decimal, e.g. 15.6)
*/
export function eloToProjectedWins(
elo: number,
totalGames: number,
parityFactor = 400,
averageElo = 1500
): number {
const winProb = 1 / (1 + Math.pow(10, (averageElo - elo) / parityFactor));
return winProb * totalGames;
}

View file

@ -132,11 +132,25 @@ describe("AFLSimulator.simulate()", () => {
const { database } = await import("~/database/context"); const { database } = await import("~/database/context");
const { getRegularSeasonStandings } = await import("~/models/regular-season-standings"); const { getRegularSeasonStandings } = await import("~/models/regular-season-standings");
const participantRows = PARTICIPANT_ROWS;
let selectCallCount = 0;
mockDb = { mockDb = {
select: vi.fn().mockReturnValue({ select: vi.fn().mockImplementation(() => {
selectCallCount++;
if (selectCallCount === 1) {
return {
from: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(PARTICIPANT_ROWS), where: vi.fn().mockResolvedValue(participantRows),
}), }),
};
}
// Second call: sourceElo query (no DB Elo by default)
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
};
}), }),
}; };
@ -146,10 +160,21 @@ describe("AFLSimulator.simulate()", () => {
}); });
it("throws if no participants found", async () => { it("throws if no participants found", async () => {
mockDb.select.mockReturnValue({ let selectCallCount = 0;
mockDb.select.mockImplementation(() => {
selectCallCount++;
if (selectCallCount === 1) {
return {
from: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]), where: vi.fn().mockResolvedValue([]),
}), }),
};
}
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
};
}); });
const sim = new AFLSimulator(); const sim = new AFLSimulator();
await expect(sim.simulate("season-1")).rejects.toThrow(/No participants found/); await expect(sim.simulate("season-1")).rejects.toThrow(/No participants found/);
@ -285,4 +310,49 @@ describe("AFLSimulator.simulate()", () => {
expect(leader.probabilities.probFirst).toBeGreaterThan(bottom.probabilities.probFirst); expect(leader.probabilities.probFirst).toBeGreaterThan(bottom.probabilities.probFirst);
}); });
it("DB sourceElo overrides hardcoded TEAMS_DATA values", async () => {
// Set DB Elo for West Coast (team-18) to 1800 (higher than Bulldogs)
let selectCallCount = 0;
mockDb.select.mockImplementation(() => {
selectCallCount++;
if (selectCallCount === 1) {
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(PARTICIPANT_ROWS),
}),
};
}
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([
{ participantId: "team-18", sourceElo: 1800 },
]),
}),
};
});
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
const westCoast = results.find((r) => r.participantId === "team-18");
const bulldogs = results.find((r) => r.participantId === "team-1");
if (!westCoast || !bulldogs) throw new Error("Expected results not found");
// With DB Elo 1800, West Coast should now be favored over Bulldogs (1646)
expect(westCoast.probabilities.probFirst).toBeGreaterThan(bulldogs.probabilities.probFirst);
});
it("falls back to hardcoded TEAMS_DATA when no DB sourceElo exists", async () => {
// Default mock already returns no sourceElo rows — should use TEAMS_DATA
const sim = new AFLSimulator();
const results = await sim.simulate("season-1");
const bulldogs = results.find((r) => r.participantId === "team-1");
const westCoast = results.find((r) => r.participantId === "team-18");
if (!bulldogs || !westCoast) throw new Error("Expected results not found");
// Bulldogs (1646) should still be favored over West Coast (1362) from hardcoded data
expect(bulldogs.probabilities.probFirst).toBeGreaterThan(westCoast.probabilities.probFirst);
});
}); });

View file

@ -0,0 +1,70 @@
import { describe, it, expect } from "vitest";
import { getSimulatorConfig, supportsProjectedWins } from "../simulator-config";
describe("simulator-config", () => {
describe("getSimulatorConfig", () => {
it("returns config for afl_bracket", () => {
const config = getSimulatorConfig("afl_bracket");
expect(config).not.toBeNull();
expect(config?.seasonGames).toBe(23);
expect(config?.parityFactor).toBe(450);
expect(config?.averageOpponentElo).toBe(1500);
});
it("returns config for nfl_bracket", () => {
const config = getSimulatorConfig("nfl_bracket");
expect(config).not.toBeNull();
expect(config?.seasonGames).toBe(17);
expect(config?.parityFactor).toBe(400);
});
it("returns config for nba_bracket", () => {
const config = getSimulatorConfig("nba_bracket");
expect(config).not.toBeNull();
expect(config?.seasonGames).toBe(82);
});
it("returns config for nhl_bracket", () => {
const config = getSimulatorConfig("nhl_bracket");
expect(config).not.toBeNull();
expect(config?.seasonGames).toBe(82);
expect(config?.parityFactor).toBe(1000);
});
it("returns config for mlb_bracket", () => {
const config = getSimulatorConfig("mlb_bracket");
expect(config).not.toBeNull();
expect(config?.seasonGames).toBe(162);
});
it("returns null for snooker_bracket", () => {
expect(getSimulatorConfig("snooker_bracket")).toBeNull();
});
it("returns null for darts_bracket", () => {
expect(getSimulatorConfig("darts_bracket")).toBeNull();
});
it("returns null for playoff_bracket", () => {
expect(getSimulatorConfig("playoff_bracket")).toBeNull();
});
});
describe("supportsProjectedWins", () => {
it("returns true for afl_bracket", () => {
expect(supportsProjectedWins("afl_bracket")).toBe(true);
});
it("returns true for nfl_bracket", () => {
expect(supportsProjectedWins("nfl_bracket")).toBe(true);
});
it("returns false for snooker_bracket", () => {
expect(supportsProjectedWins("snooker_bracket")).toBe(false);
});
it("returns false for darts_bracket", () => {
expect(supportsProjectedWins("darts_bracket")).toBe(false);
});
});
});

View file

@ -5,8 +5,9 @@
* *
* Algorithm: * Algorithm:
* 1. Load all participants for the sports season from DB * 1. Load all participants for the sports season from DB
* 2. Load current regular season standings (wins, gamesPlayed) if available * 2. Load Elo ratings from participantExpectedValues.sourceElo (admin-maintained)
* 3. Match participant names to hardcoded team data (Elo ratings) * Falls back to hardcoded TEAMS_DATA (Squiggle-derived) if no sourceElo set.
* 3. Load current regular season standings (wins, gamesPlayed) if available
* 4. For each simulation: * 4. For each simulation:
* a. For each team, simulate remaining regular season games (TOTAL_GAMES - gamesPlayed) * a. For each team, simulate remaining regular season games (TOTAL_GAMES - gamesPlayed)
* using Elo win probability vs. an average opponent (Elo 1500) * using Elo win probability vs. an average opponent (Elo 1500)
@ -36,12 +37,14 @@
* Per-game win probability = eloWinProbability(teamElo, 1500) where 1500 = average opponent. * Per-game win probability = eloWinProbability(teamElo, 1500) where 1500 = average opponent.
* If no standings exist in DB, defaults to 0 wins / TOTAL_GAMES remaining (seeding by Elo only). * If no standings exist in DB, defaults to 0 wins / TOTAL_GAMES remaining (seeding by Elo only).
* *
* Elo ratings (see TEAMS_DATA below): * Elo ratings:
* Backsolved from Squiggle's projected season win totals using the inverse formula: * Priority: sourceElo from participantExpectedValues (admin UI) hardcoded TEAMS_DATA
* fallback 1400.
* Admin can enter Elo directly or via "Projected Wins" mode on the Elo Ratings admin page,
* which auto-converts projected season wins to Elo using the inverse formula:
* elo = 1500 - 450 × log((1 wins/23) / (wins/23)) * elo = 1500 - 450 × log((1 wins/23) / (wins/23))
* Projected win counts were read from a screenshot of squiggle.com.au's season * The hardcoded TEAMS_DATA values are backsolved from Squiggle's projected season
* simulation table (as of Round 2, 2026). Update each round as projections shift. * win totals (as of Round 2, 2026). Source: https://squiggle.com.au
* Source: https://squiggle.com.au
* *
* Placement tiers SimulationProbabilities mapping: * Placement tiers SimulationProbabilities mapping:
* probFirst = Grand Final winner (1 per sim) * probFirst = Grand Final winner (1 per sim)
@ -83,16 +86,13 @@ const AFL_REGULAR_SEASON_GAMES = 23;
/** Average opponent Elo used for regular season projections. */ /** Average opponent Elo used for regular season projections. */
const AVERAGE_OPPONENT_ELO = 1500; const AVERAGE_OPPONENT_ELO = 1500;
// ─── Team data (2026 AFL season, as of end of Round 2) ─────────────────────── // ─── Hardcoded team data (FALLBACK — used only when no sourceElo in DB) ──────
// //
// Elo ratings are backsolved from Squiggle's projected season win totals. // Elo ratings are backsolved from Squiggle's projected season win totals.
// Process: we screenshotted squiggle.com.au's season simulation table (Round 2, // These serve as fallback defaults when no sourceElo has been entered via the
// 2026), read each team's projected wins, then applied the inverse formula: // admin Elo Ratings page. Prefer updating via Admin → Elo Ratings (projected
// elo = 1500 - 450 × log₁₀((1 wins/23) / (wins/23)) // wins mode) rather than editing these values.
// This calibrates each team so the simulator reproduces Squiggle's ladder // Source: https://squiggle.com.au (Round 2, 2026)
// projection when every game is played against an average opponent (Elo 1500).
// Update each round by re-reading the projected wins from Squiggle and recalculating.
// Source: https://squiggle.com.au
interface AflTeamData { interface AflTeamData {
elo: number; elo: number;
@ -169,7 +169,8 @@ export function eloWinProbability(eloA: number, eloB: number): number {
interface TeamEntry { interface TeamEntry {
id: string; id: string;
name: string; name: string;
data: AflTeamData | undefined; /** Resolved Elo: DB sourceElo > hardcoded TEAMS_DATA > fallback 1400. */
elo: number;
/** Actual wins from the standings table (0 if no standings loaded). */ /** Actual wins from the standings table (0 if no standings loaded). */
currentWins: number; currentWins: number;
/** Remaining regular season games = TOTAL_GAMES - gamesPlayed (0 if season is complete). */ /** Remaining regular season games = TOTAL_GAMES - gamesPlayed (0 if season is complete). */
@ -178,12 +179,6 @@ interface TeamEntry {
winProb: number; winProb: number;
} }
/** Get Elo for a team entry.
* Fallback 1400 = conservative below-average estimate for unknown/unrecognized teams. */
function elo(entry: TeamEntry): number {
return entry.data?.elo ?? 1400;
}
/** Simulate remaining regular season games for a team. /** Simulate remaining regular season games for a team.
* Returns projected total wins for the season. */ * Returns projected total wins for the season. */
function simulateProjectedWins(entry: TeamEntry): number { function simulateProjectedWins(entry: TeamEntry): number {
@ -200,12 +195,19 @@ export class AFLSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database(); const db = database();
// 1. Load participants and standings in parallel. // 1. Load participants, DB Elo, and standings in parallel.
const [participantRows, standings] = await Promise.all([ const [participantRows, evRows, standings] = await Promise.all([
db db
.select({ id: schema.participants.id, name: schema.participants.name }) .select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants) .from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId)), .where(eq(schema.participants.sportsSeasonId, sportsSeasonId)),
db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceElo: schema.participantExpectedValues.sourceElo,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)),
getRegularSeasonStandings(sportsSeasonId), getRegularSeasonStandings(sportsSeasonId),
]); ]);
@ -223,7 +225,16 @@ export class AFLSimulator implements Simulator {
); );
} }
// 2. Build standings lookup and construct team entries. // 2. Build Elo map from DB sourceElo values.
const dbEloMap = new Map<string, number>();
for (const row of evRows) {
if (row.sourceElo !== null && row.sourceElo !== undefined) {
dbEloMap.set(row.participantId, row.sourceElo);
}
}
// 3. Build standings lookup and construct team entries.
// Elo priority: DB sourceElo → hardcoded TEAMS_DATA → fallback 1400.
// currentWins, remainingGames, and per-game winProb are all resolved once // currentWins, remainingGames, and per-game winProb are all resolved once
// here so nothing is recomputed inside the hot simulation loop. // here so nothing is recomputed inside the hot simulation loop.
const standingsMap = new Map(standings.map((s) => [s.participantId, s])); const standingsMap = new Map(standings.map((s) => [s.participantId, s]));
@ -231,22 +242,25 @@ export class AFLSimulator implements Simulator {
const teams: TeamEntry[] = participantRows.map((r) => { const teams: TeamEntry[] = participantRows.map((r) => {
const standing = standingsMap.get(r.id); const standing = standingsMap.get(r.id);
const data = getTeamData(r.name); const dbElo = dbEloMap.get(r.id);
if (!data) { const fallbackData = getTeamData(r.name);
const resolvedElo = dbElo ?? fallbackData?.elo ?? 1400;
if (dbElo === undefined && !fallbackData) {
logger.warn( logger.warn(
{ participantName: r.name, sportsSeasonId }, { participantName: r.name, sportsSeasonId },
`AFL simulator: no Elo found for participant "${r.name}" — falling back to 1400. ` + `AFL simulator: no Elo found for participant "${r.name}" — falling back to 1400. ` +
`Add an entry to TEAMS_DATA or rename the participant to match an existing key.` `Enter Elo via Admin → Elo Ratings or rename the participant to match a TEAMS_DATA key.`
); );
} }
const gamesPlayed = standing?.gamesPlayed ?? 0; const gamesPlayed = standing?.gamesPlayed ?? 0;
return { return {
id: r.id, id: r.id,
name: r.name, name: r.name,
data, elo: resolvedElo,
currentWins: standing?.wins ?? 0, currentWins: standing?.wins ?? 0,
remainingGames: Math.max(0, AFL_REGULAR_SEASON_GAMES - gamesPlayed), remainingGames: Math.max(0, AFL_REGULAR_SEASON_GAMES - gamesPlayed),
winProb: eloWinProbability(data?.elo ?? 1400, AVERAGE_OPPONENT_ELO), winProb: eloWinProbability(resolvedElo, AVERAGE_OPPONENT_ELO),
}; };
}); });
@ -254,7 +268,7 @@ export class AFLSimulator implements Simulator {
/** Simulate a single AFL game. Returns the winner. */ /** Simulate a single AFL game. Returns the winner. */
const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry => const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
Math.random() < eloWinProbability(elo(a), elo(b)) ? a : b; Math.random() < eloWinProbability(a.elo, b.elo) ? a : b;
/** /**
* Project end-of-season ladder and return the top 10 finalists seeded 110. * Project end-of-season ladder and return the top 10 finalists seeded 110.

View file

@ -0,0 +1,89 @@
/**
* Simulator Configuration
*
* Per-sport parameters used by simulators and admin UI tools.
* Centralizes season length, parity factors, and other sport-specific
* constants so they can be shared between simulators and the Elo Ratings
* admin page (e.g., for projected wins Elo conversion).
*/
import type { SimulatorType } from "./registry";
export interface SimulatorConfig {
/** Total regular season games per team in this sport. */
seasonGames: number;
/** Elo parity factor for single-game win probability.
* P(A) = 1 / (1 + 10^((eloB - eloA) / parityFactor))
* Higher = more unpredictable per game. */
parityFactor: number;
/** Average opponent Elo — used for season projections against a generic opponent. */
averageOpponentElo: number;
}
const CONFIG: Record<SimulatorType, SimulatorConfig | null> = {
afl_bracket: {
seasonGames: 23,
parityFactor: 450,
averageOpponentElo: 1500,
},
nfl_bracket: {
seasonGames: 17,
parityFactor: 400,
averageOpponentElo: 1500,
},
nba_bracket: {
seasonGames: 82,
parityFactor: 400,
averageOpponentElo: 1500,
},
nhl_bracket: {
seasonGames: 82,
// NHL is the highest-parity major North American league — roughly 1 in 3 games
// goes to overtime/shootout and favourites win far less reliably than in NBA/NFL.
// 1000 was calibrated so that a team projected at 55 wins (67%) maps to ~1570 Elo,
// which keeps the spread tight and reflects the empirical upset rate.
parityFactor: 1000,
averageOpponentElo: 1500,
},
mlb_bracket: {
seasonGames: 162,
parityFactor: 400,
averageOpponentElo: 1500,
},
wnba_bracket: {
seasonGames: 44,
parityFactor: 400,
averageOpponentElo: 1500,
},
// Simulators below don't use projected-wins → Elo conversion.
// They can be populated later if needed.
f1_standings: null,
indycar_standings: null,
golf_qualifying_points: null,
playoff_bracket: null,
ucl_bracket: null,
ncaam_bracket: null,
ncaaw_bracket: null,
snooker_bracket: null,
tennis_qualifying_points: null,
world_cup: null,
darts_bracket: null,
cs2_major_qualifying_points: null,
ncaa_football_bracket: null,
llws_bracket: null,
};
/**
* Get the simulator config for a given simulator type.
* Returns null for simulator types that don't have season-game / Elo config.
*/
export function getSimulatorConfig(simulatorType: SimulatorType): SimulatorConfig | null {
return CONFIG[simulatorType];
}
/**
* Check if a simulator type supports projected-wins Elo conversion.
*/
export function supportsProjectedWins(simulatorType: SimulatorType): boolean {
return CONFIG[simulatorType] !== null;
}