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:
parent
e03bdd538f
commit
7fa88fc0ef
7 changed files with 672 additions and 122 deletions
|
|
@ -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 1st–8th 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 1st–8th place buckets</li>
|
<li>Distribute probabilities across 1st–8th 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} − {simulatorConfig.parityFactor} × log₁₀((1 − winRate) / winRate),
|
||||||
|
where winRate = projectedWins / {simulatorConfig.seasonGames}.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
70
app/services/simulations/__tests__/simulator-config.test.ts
Normal file
70
app/services/simulations/__tests__/simulator-config.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 1–10.
|
* Project end-of-season ladder and return the top 10 finalists seeded 1–10.
|
||||||
|
|
|
||||||
89
app/services/simulations/simulator-config.ts
Normal file
89
app/services/simulations/simulator-config.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue