Deduplicate soccer simulator helpers

This commit is contained in:
Chris Parsons 2026-05-07 11:42:27 -07:00
parent 6df4f86920
commit 4ed69c2246
23 changed files with 6786 additions and 80 deletions

View file

@ -14,6 +14,11 @@ interface StandingRow {
wins: number;
losses: number;
otLosses: number | null;
ties?: number | null;
tablePoints?: number | null;
goalsFor?: number | null;
goalsAgainst?: number | null;
goalDifference?: number | null;
winPct: string | null;
gamesPlayed: number;
gamesBack: string | null;
@ -41,6 +46,7 @@ interface Props {
teamOwnerships: Record<string, TeamOwnership>;
userParticipantIds: string[];
showOtLosses?: boolean;
showSoccerTable?: boolean;
/** How many teams per conference qualify for playoffs (flat mode only). 0 = no line. */
playoffSpots?: number;
/** "flat" = single ranked list per conference (NBA). "nhl-divisions" = div top-3 + wild card. "mlb-divisions" = same structure with 3 WC spots, uses "League" label. */
@ -222,15 +228,18 @@ function StandingsTable({
teamOwnerships,
userParticipantIds,
showOtLosses,
showSoccerTable = false,
}: {
sections: TableSection[];
teamOwnerships: Record<string, TeamOwnership>;
userParticipantIds: string[];
showOtLosses: boolean;
showSoccerTable?: boolean;
}) {
// # Team GP W L [OTL] [PTS] PCT GB L10 STK Mgr
// OTL and PTS are both shown for hockey (showOtLosses = true)
const totalCols = 10 + (showOtLosses ? 2 : 0);
// OTL and PTS are both shown for hockey (showOtLosses = true).
// Soccer table mode shows D/GF/GA/GD/Pts instead of PCT/GB/form columns.
const totalCols = showSoccerTable ? 11 : 10 + (showOtLosses ? 2 : 0);
return (
<div className="overflow-x-auto -mx-6 px-6">
@ -241,13 +250,18 @@ function StandingsTable({
<th className="text-left py-1.5">Team</th>
<th className="text-right py-1.5 px-2 w-10">GP</th>
<th className="text-right py-1.5 px-2 w-8">W</th>
{showSoccerTable && <th className="text-right py-1.5 px-2 w-8">D</th>}
<th className="text-right py-1.5 px-2 w-8">L</th>
{showSoccerTable && <th className="text-right py-1.5 px-2 w-10">GF</th>}
{showSoccerTable && <th className="text-right py-1.5 px-2 w-10">GA</th>}
{showSoccerTable && <th className="text-right py-1.5 px-2 w-10">GD</th>}
{showSoccerTable && <th className="text-right py-1.5 px-2 w-10">PTS</th>}
{showOtLosses && <th className="text-right py-1.5 px-2 w-10">OTL</th>}
{showOtLosses && <th className="text-right py-1.5 px-2 w-10">PTS</th>}
<th className="text-right py-1.5 px-2 w-12">PCT</th>
<th className="text-right py-1.5 px-2 w-10">GB</th>
<th className="text-right py-1.5 px-2 w-12">L10</th>
<th className="text-right py-1.5 px-2 w-12">STK</th>
{!showSoccerTable && <th className="text-right py-1.5 px-2 w-12">PCT</th>}
{!showSoccerTable && <th className="text-right py-1.5 px-2 w-10">GB</th>}
{!showSoccerTable && <th className="text-right py-1.5 px-2 w-12">L10</th>}
{!showSoccerTable && <th className="text-right py-1.5 px-2 w-12">STK</th>}
<th className="text-right py-1.5 pl-4 w-40">Mgr</th>
</tr>
</thead>
@ -316,7 +330,22 @@ function StandingsTable({
{row.gamesPlayed}
</td>
<td className="py-2 px-2 text-right tabular-nums font-medium">{row.wins}</td>
{showSoccerTable && (
<td className="py-2 px-2 text-right tabular-nums">{row.ties ?? 0}</td>
)}
<td className="py-2 px-2 text-right tabular-nums">{row.losses}</td>
{showSoccerTable && (
<td className="py-2 px-2 text-right tabular-nums text-muted-foreground">{row.goalsFor ?? "—"}</td>
)}
{showSoccerTable && (
<td className="py-2 px-2 text-right tabular-nums text-muted-foreground">{row.goalsAgainst ?? "—"}</td>
)}
{showSoccerTable && (
<td className="py-2 px-2 text-right tabular-nums text-muted-foreground">{row.goalDifference ?? "—"}</td>
)}
{showSoccerTable && (
<td className="py-2 px-2 text-right tabular-nums font-medium">{row.tablePoints ?? row.wins * 3 + (row.ties ?? 0)}</td>
)}
{showOtLosses && (
<td className="py-2 px-2 text-right tabular-nums">{row.otLosses ?? 0}</td>
)}
@ -325,30 +354,38 @@ function StandingsTable({
{row.wins * 2 + (row.otLosses ?? 0)}
</td>
)}
<td className="py-2 px-2 text-right tabular-nums text-muted-foreground">
{formatWinPct(row.winPct, row.wins, row.gamesPlayed)}
</td>
<td className="py-2 px-2 text-right tabular-nums text-muted-foreground">
{formatGB(row.gamesBack)}
</td>
<td className="py-2 px-2 text-right tabular-nums text-muted-foreground">
{row.lastTen ?? "—"}
</td>
<td className="py-2 px-2 text-right tabular-nums">
{row.streak ? (
<span
className={
row.streak.startsWith("W")
? "text-emerald-500"
: "text-destructive/80"
}
>
{row.streak}
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
{!showSoccerTable && (
<td className="py-2 px-2 text-right tabular-nums text-muted-foreground">
{formatWinPct(row.winPct, row.wins, row.gamesPlayed)}
</td>
)}
{!showSoccerTable && (
<td className="py-2 px-2 text-right tabular-nums text-muted-foreground">
{formatGB(row.gamesBack)}
</td>
)}
{!showSoccerTable && (
<td className="py-2 px-2 text-right tabular-nums text-muted-foreground">
{row.lastTen ?? "—"}
</td>
)}
{!showSoccerTable && (
<td className="py-2 px-2 text-right tabular-nums">
{row.streak ? (
<span
className={
row.streak.startsWith("W")
? "text-emerald-500"
: "text-destructive/80"
}
>
{row.streak}
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
)}
<td className="py-2 pl-4 text-right">
{ownership ? (
<div className="flex justify-end">
@ -381,6 +418,7 @@ export function RegularSeasonStandings({
teamOwnerships,
userParticipantIds,
showOtLosses = false,
showSoccerTable = false,
playoffSpots = 8,
displayMode = "flat",
}: Props) {
@ -399,7 +437,7 @@ export function RegularSeasonStandings({
? buildMlbSections(standings)
: buildFlatSections(standings, playoffSpots);
const tableProps = { teamOwnerships, userParticipantIds, showOtLosses };
const tableProps = { teamOwnerships, userParticipantIds, showOtLosses, showSoccerTable };
return (
<Card>

View file

@ -8,6 +8,11 @@ function makeRow(overrides: Partial<{
wins: number;
losses: number;
otLosses: number | null;
ties: number | null;
tablePoints: number | null;
goalsFor: number | null;
goalsAgainst: number | null;
goalDifference: number | null;
gamesPlayed: number;
winPct: string | null;
gamesBack: string | null;
@ -29,6 +34,11 @@ function makeRow(overrides: Partial<{
wins: overrides.wins ?? 40,
losses: overrides.losses ?? 28,
otLosses: overrides.otLosses ?? null,
ties: overrides.ties ?? null,
tablePoints: overrides.tablePoints ?? null,
goalsFor: overrides.goalsFor ?? null,
goalsAgainst: overrides.goalsAgainst ?? null,
goalDifference: overrides.goalDifference ?? null,
gamesPlayed: overrides.gamesPlayed ?? 68,
winPct: overrides.winPct ?? "0.5882",
gamesBack: overrides.gamesBack ?? null,
@ -99,6 +109,38 @@ describe("RegularSeasonStandings", () => {
expect(screen.queryByText("OTL")).not.toBeInTheDocument();
});
it("shows soccer table columns when showSoccerTable=true", () => {
render(
<RegularSeasonStandings
standings={[
makeRow({
participant: { id: "p-1", name: "Arsenal" },
gamesPlayed: 30,
wins: 21,
ties: 6,
losses: 3,
goalsFor: 72,
goalsAgainst: 25,
goalDifference: 47,
tablePoints: 69,
}),
]}
teamOwnerships={NO_OWNERSHIPS}
userParticipantIds={NO_USER_IDS}
showSoccerTable={true}
/>
);
expect(screen.getByText("D")).toBeInTheDocument();
expect(screen.getByText("GF")).toBeInTheDocument();
expect(screen.getByText("GA")).toBeInTheDocument();
expect(screen.getByText("GD")).toBeInTheDocument();
expect(screen.getByText("PTS")).toBeInTheDocument();
expect(screen.getByText("Arsenal")).toBeInTheDocument();
expect(screen.getByText("69")).toBeInTheDocument();
});
it("renders conference headings and inline division labels when present", () => {
render(
<RegularSeasonStandings

View file

@ -9,6 +9,10 @@ export interface UpsertRegularSeasonStandingData {
losses: number;
otLosses?: number | null;
ties?: number | null;
tablePoints?: number | null;
goalsFor?: number | null;
goalsAgainst?: number | null;
goalDifference?: number | null;
winPct?: number | null;
gamesPlayed: number;
gamesBack?: number | null;
@ -47,6 +51,10 @@ export async function upsertRegularSeasonStandings(
losses: r.losses,
otLosses: r.otLosses ?? null,
ties: r.ties ?? null,
tablePoints: r.tablePoints ?? null,
goalsFor: r.goalsFor ?? null,
goalsAgainst: r.goalsAgainst ?? null,
goalDifference: r.goalDifference ?? null,
winPct: r.winPct !== null && r.winPct !== undefined ? r.winPct.toString() : null,
gamesPlayed: r.gamesPlayed,
gamesBack: r.gamesBack !== null && r.gamesBack !== undefined ? r.gamesBack.toString() : null,
@ -79,6 +87,10 @@ export async function upsertRegularSeasonStandings(
losses: sql`excluded.losses`,
otLosses: sql`excluded.ot_losses`,
ties: sql`excluded.ties`,
tablePoints: sql`excluded.table_points`,
goalsFor: sql`excluded.goals_for`,
goalsAgainst: sql`excluded.goals_against`,
goalDifference: sql`excluded.goal_difference`,
winPct: sql`excluded.win_pct`,
gamesPlayed: sql`excluded.games_played`,
gamesBack: sql`excluded.games_back`,

View file

@ -32,7 +32,12 @@ import { useState } from 'react';
import { Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
import { normalizeName } from '~/lib/fuzzy-match';
import { getSimulatorConfig, supportsProjectedWins } from '~/services/simulations/simulator-config';
import { eloToProjectedWins, projectedWinsToElo } from '~/services/probability-engine';
import {
eloToProjectedTablePoints,
eloToProjectedWins,
projectedTablePointsToElo,
projectedWinsToElo,
} from '~/services/probability-engine';
// Simulator types that use worldRanking in addition to sourceElo
const RANKING_SIMULATOR_TYPES = new Set(['darts_bracket', 'cs2_major_qualifying_points']);
@ -102,15 +107,18 @@ export async function action({ request, params }: Route.ActionArgs) {
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.' };
return { success: false, message: 'This sport does not support projection 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);
const projectionMax = config.projectionInput === 'tablePoints' ? config.seasonGames * 3 : config.seasonGames;
if (!isNaN(projectedWins) && projectedWins >= 0 && projectedWins <= projectionMax) {
const elo = config.projectionInput === 'tablePoints'
? projectedTablePointsToElo(projectedWins, config.seasonGames, config.parityFactor, config.averageOpponentElo)
: projectedWinsToElo(projectedWins, config.seasonGames, config.parityFactor, config.averageOpponentElo);
eloInputs.push({ participantId: participant.id, sportsSeasonId, sourceElo: elo });
}
}
@ -141,7 +149,7 @@ export async function action({ request, params }: Route.ActionArgs) {
if (eloInputs.length === 0) {
return { success: false, message: inputMode === 'projectedWins'
? 'Please enter projected wins for at least one participant'
? 'Please enter projections for at least one participant'
: 'Please enter an Elo rating for at least one participant' };
}
@ -234,6 +242,13 @@ export default function AdminSportsSeasonEloRatings() {
const navigation = useNavigation();
const [inputMode, setInputMode] = useState<'elo' | 'projectedWins'>('elo');
const projectionLabel = simulatorConfig?.projectionInput === 'tablePoints' ? 'Projected Points' : 'Projected Wins';
const projectionUnit = simulatorConfig?.projectionInput === 'tablePoints' ? 'points' : 'wins';
const projectionMax = simulatorConfig
? simulatorConfig.projectionInput === 'tablePoints'
? simulatorConfig.seasonGames * 3
: simulatorConfig.seasonGames
: 0;
const [eloValues, setEloValues] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
@ -259,8 +274,9 @@ export default function AdminSportsSeasonEloRatings() {
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
initial[p.id] = (simulatorConfig.projectionInput === 'tablePoints'
? eloToProjectedTablePoints(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
: eloToProjectedWins(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
).toFixed(1);
}
});
@ -311,9 +327,11 @@ export default function AdminSportsSeasonEloRatings() {
const inputName = match[1].trim();
const projectedWins = parseFloat(match[2]);
if (isNaN(projectedWins) || projectedWins < 0 || projectedWins > simulatorConfig.seasonGames) continue;
if (isNaN(projectedWins) || projectedWins < 0 || projectedWins > projectionMax) continue;
const elo = projectedWinsToElo(projectedWins, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo);
const elo = simulatorConfig.projectionInput === 'tablePoints'
? projectedTablePointsToElo(projectedWins, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
: projectedWinsToElo(projectedWins, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo);
const participant = findParticipantMatch(inputName);
if (participant && !seen.has(participant.id)) {
@ -356,8 +374,9 @@ export default function AdminSportsSeasonEloRatings() {
newElos[m.participantId] = m.elo.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
newWins[m.participantId] = (simulatorConfig.projectionInput === 'tablePoints'
? eloToProjectedTablePoints(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
: eloToProjectedWins(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
).toFixed(1);
}
}
@ -408,7 +427,7 @@ export default function AdminSportsSeasonEloRatings() {
variant={inputMode === 'projectedWins' ? 'default' : 'outline'}
onClick={() => setInputMode('projectedWins')}
>
Projected Wins
{projectionLabel}
</Button>
</div>
)}
@ -420,8 +439,8 @@ export default function AdminSportsSeasonEloRatings() {
<CardDescription>
{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
Paste projected season {projectionUnit} one per line. Format: <code>Team Name, {simulatorConfig.projectionInput === 'tablePoints' ? '76.5' : '15.6'}</code>.
Projections are automatically converted to Elo ratings using {simulatorConfig.seasonGames} total games
and parity factor {simulatorConfig.parityFactor}. Names are fuzzy-matched to participants.
</>
) : usesRanking ? (
@ -442,7 +461,9 @@ export default function AdminSportsSeasonEloRatings() {
<Textarea
placeholder={
inputMode === 'projectedWins'
? `Team Name, 15.6\nTeam Name, 14.5\nTeam Name, 13.8`
? simulatorConfig?.projectionInput === 'tablePoints'
? `Team Name, 76.5\nTeam Name, 68.0\nTeam Name, 54.5`
: `Team Name, 15.6\nTeam Name, 14.5\nTeam Name, 13.8`
: usesRanking
? simulatorType === 'cs2_major_qualifying_points'
? `Natus Vincere, 1850, 1\nFaZe Clan, 1820, 2\nVitality, 1810, 3`
@ -515,14 +536,14 @@ export default function AdminSportsSeasonEloRatings() {
<CardHeader>
<CardTitle>
{inputMode === 'projectedWins'
? `Projected Season Wins (out of ${simulatorConfig?.seasonGames ?? '?'})`
? `${projectionLabel} (out of ${projectionMax || '?'})`
: usesRanking
? `Player Elo & ${rankLabel}s`
: 'Player Elo Ratings'}
</CardTitle>
<CardDescription>
{inputMode === 'projectedWins'
? 'Enter each team\'s projected total season wins. 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
? `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.'}
@ -541,7 +562,7 @@ export default function AdminSportsSeasonEloRatings() {
{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>
<span>{simulatorConfig?.projectionInput === 'tablePoints' ? 'Proj Pts' : 'Proj Wins'}</span>
</div>
)}
<div className="space-y-2">
@ -557,7 +578,7 @@ export default function AdminSportsSeasonEloRatings() {
step="0.1"
id={`wins_${participant.id}`}
name={`wins_${participant.id}`}
placeholder={`${(simulatorConfig.seasonGames / 2).toFixed(1)}`}
placeholder={`${(projectionMax / 2).toFixed(1)}`}
value={winsValues[participant.id] ?? ''}
onChange={e =>
setWinsValues(prev => ({ ...prev, [participant.id]: e.target.value }))
@ -625,8 +646,8 @@ export default function AdminSportsSeasonEloRatings() {
<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>Enter each team's projected total season {projectionUnit}</li>
<li>Projections 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>
@ -648,8 +669,8 @@ export default function AdminSportsSeasonEloRatings() {
)}
{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}.
Conversion: Elo = {simulatorConfig.averageOpponentElo} &minus; {simulatorConfig.parityFactor} &times; log&#8321;&#8320;((1 &minus; rate) / rate),
where rate = projection / {projectionMax}.
</div>
)}
</CardContent>

View file

@ -35,7 +35,7 @@ export async function loader({ params }: Route.LoaderArgs) {
);
return {
sportsSeason: sportsSeason as typeof sportsSeason & { sport: { id: string; name: string; type: string } },
sportsSeason: sportsSeason as typeof sportsSeason & { sport: { id: string; name: string; type: string; simulatorType: string | null } },
participants,
standingsMap,
};
@ -44,13 +44,20 @@ export async function loader({ params }: Route.LoaderArgs) {
export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData();
// Parse updates from form fields: wins_<id>, losses_<id>, otLosses_<id>, conference_<id>, division_<id>
// Parse updates from form fields: wins_<id>, losses_<id>, ties_<id>, otLosses_<id>, etc.
const byId = new Map<
string,
{
wins?: number;
losses?: number;
otLosses?: number | null;
ties?: number | null;
gamesPlayed?: number | null;
leagueRank?: number | null;
tablePoints?: number | null;
goalsFor?: number | null;
goalsAgainst?: number | null;
goalDifference?: number | null;
conference?: string | null;
division?: string | null;
}
@ -60,6 +67,13 @@ export async function action({ request, params }: Route.ActionArgs) {
const winsMatch = key.match(/^wins_(.+)$/);
const lossesMatch = key.match(/^losses_(.+)$/);
const otMatch = key.match(/^otLosses_(.+)$/);
const tiesMatch = key.match(/^ties_(.+)$/);
const gpMatch = key.match(/^gamesPlayed_(.+)$/);
const rankMatch = key.match(/^leagueRank_(.+)$/);
const pointsMatch = key.match(/^tablePoints_(.+)$/);
const gfMatch = key.match(/^goalsFor_(.+)$/);
const gaMatch = key.match(/^goalsAgainst_(.+)$/);
const gdMatch = key.match(/^goalDifference_(.+)$/);
const confMatch = key.match(/^conference_(.+)$/);
const divMatch = key.match(/^division_(.+)$/);
@ -75,6 +89,34 @@ export async function action({ request, params }: Route.ActionArgs) {
const id = otMatch[1];
const otLosses = value === "" ? null : parseInt(value as string, 10);
byId.set(id, { ...byId.get(id), otLosses: isNaN(otLosses as number) ? null : otLosses });
} else if (tiesMatch) {
const id = tiesMatch[1];
const ties = value === "" ? null : parseInt(value as string, 10);
byId.set(id, { ...byId.get(id), ties: isNaN(ties as number) ? null : ties });
} else if (gpMatch) {
const id = gpMatch[1];
const gamesPlayed = value === "" ? null : parseInt(value as string, 10);
byId.set(id, { ...byId.get(id), gamesPlayed: isNaN(gamesPlayed as number) ? null : gamesPlayed });
} else if (rankMatch) {
const id = rankMatch[1];
const leagueRank = value === "" ? null : parseInt(value as string, 10);
byId.set(id, { ...byId.get(id), leagueRank: isNaN(leagueRank as number) ? null : leagueRank });
} else if (pointsMatch) {
const id = pointsMatch[1];
const tablePoints = value === "" ? null : parseInt(value as string, 10);
byId.set(id, { ...byId.get(id), tablePoints: isNaN(tablePoints as number) ? null : tablePoints });
} else if (gfMatch) {
const id = gfMatch[1];
const goalsFor = value === "" ? null : parseInt(value as string, 10);
byId.set(id, { ...byId.get(id), goalsFor: isNaN(goalsFor as number) ? null : goalsFor });
} else if (gaMatch) {
const id = gaMatch[1];
const goalsAgainst = value === "" ? null : parseInt(value as string, 10);
byId.set(id, { ...byId.get(id), goalsAgainst: isNaN(goalsAgainst as number) ? null : goalsAgainst });
} else if (gdMatch) {
const id = gdMatch[1];
const goalDifference = value === "" ? null : parseInt(value as string, 10);
byId.set(id, { ...byId.get(id), goalDifference: isNaN(goalDifference as number) ? null : goalDifference });
} else if (confMatch) {
const id = confMatch[1];
byId.set(id, { ...byId.get(id), conference: (value as string).trim() || null });
@ -89,14 +131,25 @@ export async function action({ request, params }: Route.ActionArgs) {
if (data.wins === null && data.losses === null) continue;
const wins = data.wins ?? 0;
const losses = data.losses ?? 0;
const gamesPlayed = wins + losses + (data.otLosses ?? 0);
const ties = data.ties ?? 0;
const gamesPlayed = data.gamesPlayed ?? wins + losses + ties + (data.otLosses ?? 0);
const winPct = gamesPlayed > 0 ? wins / gamesPlayed : 0;
const goalsFor = data.goalsFor ?? null;
const goalsAgainst = data.goalsAgainst ?? null;
const goalDifference = data.goalDifference ?? (goalsFor !== null && goalsAgainst !== null ? goalsFor - goalsAgainst : null);
const tablePoints = data.tablePoints ?? wins * 3 + ties;
await upsertManualStanding(participantId, params.id, {
wins,
losses,
otLosses: data.otLosses ?? null,
ties: data.ties ?? null,
tablePoints,
goalsFor,
goalsAgainst,
goalDifference,
gamesPlayed,
leagueRank: data.leagueRank ?? null,
winPct,
conference: data.conference ?? null,
division: data.division ?? null,
@ -113,12 +166,16 @@ export default function ManageRegularStandings({ loaderData, actionData }: Route
const { sportsSeason, participants, standingsMap } = loaderData;
const isNhl = sportsSeason.sport?.name?.toLowerCase().includes("nhl") ||
sportsSeason.sport?.name?.toLowerCase().includes("hockey");
const isEpl = sportsSeason.sport?.simulatorType === "epl_standings";
// Sort participants: those with records first (by wins desc), then unrecorded
const sorted = [...participants].toSorted((a, b) => {
const recA = standingsMap[a.id];
const recB = standingsMap[b.id];
if (recA && recB) return (recB.wins ?? 0) - (recA.wins ?? 0);
if (recA && recB) {
if (isEpl) return (recA.leagueRank ?? 999) - (recB.leagueRank ?? 999);
return (recB.wins ?? 0) - (recA.wins ?? 0);
}
if (recA) return -1;
if (recB) return 1;
return a.name.localeCompare(b.name);
@ -139,7 +196,7 @@ export default function ManageRegularStandings({ loaderData, actionData }: Route
{sportsSeason.sport.name} {sportsSeason.name}
</p>
<p className="text-sm text-muted-foreground mt-1">
Manually enter W/L records. These will be overwritten on the next auto-sync unless you use this page again afterward.
Manually enter current standings. These will be overwritten on the next auto-sync unless you use this page again afterward.
</p>
</div>
@ -162,7 +219,9 @@ export default function ManageRegularStandings({ loaderData, actionData }: Route
W/L Records
</CardTitle>
<CardDescription>
Enter wins, losses{isNhl ? ", and OT losses" : ""} for each team. Conference and division are optional but improve the standings display.
{isEpl
? "Enter Premier League table rows. Points, GP, and GD are auto-derived if left blank."
: <>Enter wins, losses{isNhl ? ", and OT losses" : ""} for each team. Conference and division are optional but improve the standings display.</>}
</CardDescription>
</CardHeader>
<CardContent>
@ -170,18 +229,58 @@ export default function ManageRegularStandings({ loaderData, actionData }: Route
<input type="hidden" name="_isNhl" value={isNhl ? "true" : "false"} />
<div className="space-y-1 mb-4">
<div className={`grid gap-2 px-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide border-b ${isNhl ? "grid-cols-[1fr_3.5rem_3.5rem_3.5rem_6rem_6rem]" : "grid-cols-[1fr_3.5rem_3.5rem_6rem_6rem]"}`}>
<span>Team</span>
<span>W</span>
<span>L</span>
{isNhl && <span>OTL</span>}
<span>Conference</span>
<span>Division</span>
</div>
{isEpl ? (
<div className="grid grid-cols-[1fr_3rem_3rem_3rem_3rem_3rem_3rem_3rem_3rem_3rem] gap-2 px-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide border-b">
<span>Team</span>
<span>Pos</span>
<span>GP</span>
<span>W</span>
<span>D</span>
<span>L</span>
<span>GF</span>
<span>GA</span>
<span>GD</span>
<span>Pts</span>
</div>
) : (
<div className={`grid gap-2 px-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide border-b ${isNhl ? "grid-cols-[1fr_3.5rem_3.5rem_3.5rem_6rem_6rem]" : "grid-cols-[1fr_3.5rem_3.5rem_6rem_6rem]"}`}>
<span>Team</span>
<span>W</span>
<span>L</span>
{isNhl && <span>OTL</span>}
<span>Conference</span>
<span>Division</span>
</div>
)}
{sorted.map((participant) => {
const record = standingsMap[participant.id];
const isSynced = record && record.syncedAt !== null;
if (isEpl) {
return (
<div
key={participant.id}
className="grid grid-cols-[1fr_3rem_3rem_3rem_3rem_3rem_3rem_3rem_3rem_3rem] gap-2 items-center px-2 py-1.5 rounded hover:bg-muted/40"
>
<span className="text-sm font-medium truncate flex items-center gap-1.5">
{participant.name}
{isSynced && (
<span className="text-xs text-muted-foreground font-normal">(synced)</span>
)}
</span>
<Input name={`leagueRank_${participant.id}`} type="number" min="1" defaultValue={record?.leagueRank ?? ""} placeholder="—" className="h-8 text-sm" />
<Input name={`gamesPlayed_${participant.id}`} type="number" min="0" defaultValue={record?.gamesPlayed ?? ""} placeholder="—" className="h-8 text-sm" />
<Input name={`wins_${participant.id}`} type="number" min="0" defaultValue={record?.wins ?? ""} placeholder="—" className="h-8 text-sm" />
<Input name={`ties_${participant.id}`} type="number" min="0" defaultValue={record?.ties ?? ""} placeholder="—" className="h-8 text-sm" />
<Input name={`losses_${participant.id}`} type="number" min="0" defaultValue={record?.losses ?? ""} placeholder="—" className="h-8 text-sm" />
<Input name={`goalsFor_${participant.id}`} type="number" min="0" defaultValue={record?.goalsFor ?? ""} placeholder="—" className="h-8 text-sm" />
<Input name={`goalsAgainst_${participant.id}`} type="number" min="0" defaultValue={record?.goalsAgainst ?? ""} placeholder="—" className="h-8 text-sm" />
<Input name={`goalDifference_${participant.id}`} type="number" defaultValue={record?.goalDifference ?? ""} placeholder="—" className="h-8 text-sm" />
<Input name={`tablePoints_${participant.id}`} type="number" min="0" defaultValue={record?.tablePoints ?? ""} placeholder="—" className="h-8 text-sm" />
</div>
);
}
return (
<div
key={participant.id}

View file

@ -115,6 +115,7 @@ export default function SportSeasonDetail({
teamOwnerships={ownershipMap}
userParticipantIds={userParticipantIds}
showOtLosses={showOtLosses}
showSoccerTable={simulatorType === "epl_standings"}
displayMode={standingsDisplayMode as "flat" | "nhl-divisions" | "mlb-divisions"}
playoffSpots={playoffSpots}
/>

View file

@ -202,8 +202,16 @@ export function mapToElo(
* eloWinProbability(1648, 1324) // 0.828 (82.8%)
* eloWinProbability(1500, 1500) // 0.5 (50%)
*/
export function eloWinProbabilityWithParity(
eloA: number,
eloB: number,
parityFactor = 400
): number {
return 1 / (1 + Math.pow(10, (eloB - eloA) / parityFactor));
}
export function eloWinProbability(eloA: number, eloB: number): number {
return 1 / (1 + Math.pow(10, (eloB - eloA) / 400));
return eloWinProbabilityWithParity(eloA, eloB, 400);
}
/**
@ -368,3 +376,53 @@ export function eloToProjectedWins(
const winProb = 1 / (1 + Math.pow(10, (averageElo - elo) / parityFactor));
return winProb * totalGames;
}
/**
* Convert projected league table points to an Elo rating.
*
* For soccer-style standings where each match offers up to `maxPointsPerGame`
* table points (EPL: 3), this maps projected points-per-game onto the same
* inverse-logit Elo scale used by projectedWinsToElo.
*/
export function projectedTablePointsToElo(
projectedPoints: number,
totalGames: number,
parityFactor = 400,
averageElo = 1500,
maxPointsPerGame = 3
): number {
if (totalGames <= 0) {
throw new Error('Total games must be positive');
}
if (parityFactor <= 0) {
throw new Error('Parity factor must be positive');
}
if (maxPointsPerGame <= 0) {
throw new Error('Max points per game must be positive');
}
const maxPoints = totalGames * maxPointsPerGame;
if (projectedPoints < 0 || projectedPoints > maxPoints) {
throw new Error(
`Projected points (${projectedPoints}) must be between 0 and maximum points (${maxPoints})`
);
}
const pointsShare = projectedPoints / maxPoints;
if (pointsShare >= 1.0) return averageElo + parityFactor * 3;
if (pointsShare <= 0.0) return averageElo - parityFactor * 3;
const elo = averageElo - parityFactor * Math.log10((1 - pointsShare) / pointsShare);
return Math.round(elo);
}
/** Convert Elo back to projected soccer-style table points. */
export function eloToProjectedTablePoints(
elo: number,
totalGames: number,
parityFactor = 400,
averageElo = 1500,
maxPointsPerGame = 3
): number {
const pointsShare = 1 / (1 + Math.pow(10, (averageElo - elo) / parityFactor));
return pointsShare * totalGames * maxPointsPerGame;
}

View file

@ -0,0 +1,143 @@
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import { EPLSimulator, eplWinProbability, simEplMatch } from "../epl-simulator";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/models/regular-season-standings", () => ({
getRegularSeasonStandings: vi.fn(),
}));
const EPL_TEAMS = [
"Arsenal", "Manchester City", "Liverpool", "Chelsea", "Tottenham Hotspur",
"Manchester United", "Newcastle United", "Aston Villa", "Brighton & Hove Albion", "West Ham United",
"Crystal Palace", "Fulham", "Everton", "Bournemouth", "Brentford",
"Wolverhampton Wanderers", "Nottingham Forest", "Leeds United", "Burnley", "Sunderland",
];
const PARTICIPANT_ROWS = EPL_TEAMS.map((name, i) => ({ id: `team-${i + 1}`, name }));
const EV_ROWS = PARTICIPANT_ROWS.map((team, i) => ({
participantId: team.id,
sourceElo: 1760 - i * 25,
sourceOdds: null,
}));
function mockSelects(participantRows = PARTICIPANT_ROWS, evRows = EV_ROWS) {
let selectCallCount = 0;
return vi.fn().mockImplementation(() => {
selectCallCount++;
if (selectCallCount === 1) {
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(participantRows) }) };
}
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(evRows) }) };
});
}
describe("EPL match helpers", () => {
it("returns 0.5 win probability for equal Elo ratings", () => {
expect(eplWinProbability(1500, 1500)).toBeCloseTo(0.5, 6);
});
it("equal Elo teams draw at a realistic rate", () => {
let draws = 0;
const N = 5000;
for (let i = 0; i < N; i++) {
if (simEplMatch(1500, 1500) === "draw") draws++;
}
const drawRate = draws / N;
expect(drawRate).toBeGreaterThan(0.21);
expect(drawRate).toBeLessThan(0.31);
});
});
describe("EPLSimulator.simulate()", () => {
let mockDb: { select: MockInstance };
beforeEach(async () => {
const { database } = await import("~/database/context");
const { getRegularSeasonStandings } = await import("~/models/regular-season-standings");
mockDb = { select: mockSelects() };
(database as unknown as MockInstance).mockReturnValue(mockDb);
(getRegularSeasonStandings as unknown as MockInstance).mockResolvedValue([]);
});
it("throws if no participants found", async () => {
mockDb.select = mockSelects([], []);
await expect(new EPLSimulator().simulate("season-1")).rejects.toThrow(/No participants found/);
});
it("returns one result per EPL club and normalizes top-eight columns", async () => {
const results = await new EPLSimulator().simulate("season-1");
expect(results).toHaveLength(20);
const keys = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
] as const;
for (const key of keys) {
const colSum = results.reduce((sum, result) => sum + result.probabilities[key], 0);
expect(colSum, `${key} column sum`).toBeCloseTo(1.0, 8);
}
for (const result of results) {
expect(result.source).toBe("epl_standings_monte_carlo");
for (const value of Object.values(result.probabilities)) {
expect(value).toBeGreaterThanOrEqual(0);
expect(value).toBeLessThanOrEqual(1);
}
}
});
it("higher Elo teams have stronger title probability", async () => {
const results = await new EPLSimulator().simulate("season-1");
const top = results.find((result) => result.participantId === "team-1");
const bottom = results.find((result) => result.participantId === "team-20");
if (!top || !bottom) throw new Error("Expected test teams not found");
expect(top.probabilities.probFirst).toBeGreaterThan(bottom.probabilities.probFirst);
});
it("uses current EPL standings when present", async () => {
const { getRegularSeasonStandings } = await import("~/models/regular-season-standings");
(getRegularSeasonStandings as unknown as MockInstance).mockResolvedValue([
{
participantId: "team-20",
wins: 25,
ties: 5,
losses: 0,
gamesPlayed: 30,
tablePoints: 80,
goalsFor: 80,
goalsAgainst: 20,
goalDifference: 60,
leagueRank: 1,
},
...PARTICIPANT_ROWS.slice(0, 19).map((team) => ({
participantId: team.id,
wins: 8,
ties: 5,
losses: 17,
gamesPlayed: 30,
tablePoints: 29,
goalsFor: 35,
goalsAgainst: 55,
goalDifference: -20,
leagueRank: 10,
})),
]);
const results = await new EPLSimulator().simulate("season-1");
const leader = results.find((result) => result.participantId === "team-20");
if (!leader) throw new Error("Expected standings leader not found");
expect(leader.probabilities.probFirst).toBeGreaterThan(0.95);
});
it("throws when Elo/projected points are missing", async () => {
mockDb.select = mockSelects(PARTICIPANT_ROWS, []);
await expect(new EPLSimulator().simulate("season-1")).rejects.toThrow(/Missing Elo\/projected points/);
});
});

View file

@ -68,6 +68,7 @@ import type { Simulator, SimulationResult } from "./types";
import { normalizeTeamName } from "~/lib/normalize-team-name";
import { logger } from "~/lib/logger";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { eloWinProbabilityWithParity } from "~/services/probability-engine";
// ─── Simulation parameters ────────────────────────────────────────────────────
@ -161,7 +162,7 @@ export function getTeamData(name: string): AflTeamData | undefined {
* Exported for unit testing.
*/
export function eloWinProbability(eloA: number, eloB: number): number {
return 1 / (1 + Math.pow(10, (eloB - eloA) / PARITY_FACTOR));
return eloWinProbabilityWithParity(eloA, eloB, PARITY_FACTOR);
}
// ─── Internal types ───────────────────────────────────────────────────────────

View file

@ -0,0 +1,201 @@
/**
* EPL Season Standings Simulator
*
* Monte Carlo simulation of a Premier League table. Reads current actual
* standings when present and simulates only the remaining season; if no current
* standings exist, simulates a full 38-match season from 0 points.
*
* Results persist only placement probabilities (P1P8) through the existing EV
* pipeline. Simulated W/D/L/GF/GA/GD rows are internal and discarded.
*/
import { database } from "~/database/context";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { convertFuturesToElo, eloWinProbabilityWithParity } from "~/services/probability-engine";
import { normalizeSimulationResultColumns } from "./simulation-probabilities";
import { simulateEloSoccerMatch, simulateSimpleSoccerGoals } from "./soccer-helpers";
const NUM_SIMULATIONS = 10_000;
const EPL_REGULAR_SEASON_GAMES = 38;
const AVERAGE_OPPONENT_ELO = 1500;
const PARITY_FACTOR = 400;
const BASE_DRAW_RATE = 0.26;
const DRAW_DECAY = 0.002;
interface TeamEntry {
id: string;
elo: number;
currentPoints: number;
currentGoalsFor: number;
currentGoalsAgainst: number;
currentGoalDifference: number;
remainingGames: number;
}
interface SimulatedTableRow {
team: TeamEntry;
points: number;
goalsFor: number;
goalsAgainst: number;
goalDifference: number;
tiebreaker: number;
}
/** EPL single-match win probability using the sport-specific parity factor. */
export function eplWinProbability(eloA: number, eloB: number): number {
return eloWinProbabilityWithParity(eloA, eloB, PARITY_FACTOR);
}
/** Simulate an EPL match result for team A vs team B. Exported for tests. */
export function simEplMatch(eloA: number, eloB: number): "win" | "draw" | "loss" {
return simulateEloSoccerMatch(eloA, eloB, {
baseDrawRate: BASE_DRAW_RATE,
drawDecay: DRAW_DECAY,
parityFactor: PARITY_FACTOR,
});
}
export class EPLSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
const [participantRows, evRows, standings] = await Promise.all([
db
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
.from(schema.seasonParticipants)
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId)),
db
.select({
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
})
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)),
getRegularSeasonStandings(sportsSeasonId),
]);
if (participantRows.length === 0) {
throw new Error(
`No participants found for sports season ${sportsSeasonId}. Add all 20 EPL clubs before running simulation.`
);
}
if (participantRows.length < 8) {
throw new Error(
`EPL simulation requires at least 8 participants to fill top-eight probabilities (got ${participantRows.length}).`
);
}
const dbEloMap = new Map<string, number>();
for (const row of evRows) {
if (row.sourceElo !== null && row.sourceElo !== undefined) {
dbEloMap.set(row.participantId, row.sourceElo);
}
}
if (dbEloMap.size === 0) {
const oddsInput = evRows
.filter((row) => row.sourceOdds !== null && row.sourceOdds !== undefined)
.map((row) => ({ participantId: row.participantId, odds: row.sourceOdds as number }));
const converted = convertFuturesToElo(oddsInput);
for (const [participantId, elo] of converted) dbEloMap.set(participantId, elo);
}
const missingElo = participantRows.filter((row) => !dbEloMap.has(row.id));
if (missingElo.length > 0) {
throw new Error(
`Missing Elo/projected points for ${missingElo.length} EPL participants: ${missingElo.map((p) => p.name).join(", ")}. ` +
`Enter Elo ratings or projected points via Admin → Elo Ratings before simulating.`
);
}
const standingsMap = new Map(standings.map((standing) => [standing.participantId, standing]));
const participantIds = participantRows.map((row) => row.id);
const teams: TeamEntry[] = participantRows.map((row) => {
const standing = standingsMap.get(row.id);
const wins = standing?.wins ?? 0;
const draws = standing?.ties ?? 0;
const losses = standing?.losses ?? 0;
const gamesPlayed = standing?.gamesPlayed ?? wins + draws + losses;
const goalsFor = standing?.goalsFor ?? 0;
const goalsAgainst = standing?.goalsAgainst ?? 0;
const goalDifference = standing?.goalDifference ?? goalsFor - goalsAgainst;
return {
id: row.id,
elo: dbEloMap.get(row.id) as number,
currentPoints: standing?.tablePoints ?? wins * 3 + draws,
currentGoalsFor: goalsFor,
currentGoalsAgainst: goalsAgainst,
currentGoalDifference: goalDifference,
remainingGames: Math.max(0, EPL_REGULAR_SEASON_GAMES - gamesPlayed),
};
});
const rankCounts = new Map<string, number[]>(
participantIds.map((id) => [id, Array.from({ length: 8 }, () => 0)])
);
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
const tableRows: SimulatedTableRow[] = teams.map((team) => ({
team,
points: team.currentPoints,
goalsFor: team.currentGoalsFor,
goalsAgainst: team.currentGoalsAgainst,
goalDifference: team.currentGoalDifference,
tiebreaker: Math.random(),
}));
for (const row of tableRows) {
for (let game = 0; game < row.team.remainingGames; game++) {
const result = simEplMatch(row.team.elo, AVERAGE_OPPONENT_ELO);
const goals = simulateSimpleSoccerGoals(result);
row.goalsFor += goals.gf;
row.goalsAgainst += goals.ga;
row.goalDifference = row.goalsFor - row.goalsAgainst;
if (result === "win") row.points += 3;
else if (result === "draw") row.points += 1;
}
}
const finalTable = tableRows.toSorted((a, b) =>
b.points - a.points ||
b.goalDifference - a.goalDifference ||
b.goalsFor - a.goalsFor ||
b.tiebreaker - a.tiebreaker
);
for (let rank = 0; rank < Math.min(8, finalTable.length); rank++) {
const counts = rankCounts.get(finalTable[rank].team.id);
if (counts) counts[rank]++;
}
}
const results: SimulationResult[] = participantIds.map((participantId) => {
const counts = rankCounts.get(participantId) ?? Array.from({ length: 8 }, () => 0);
return {
participantId,
probabilities: {
probFirst: counts[0] / NUM_SIMULATIONS,
probSecond: counts[1] / NUM_SIMULATIONS,
probThird: counts[2] / NUM_SIMULATIONS,
probFourth: counts[3] / NUM_SIMULATIONS,
probFifth: counts[4] / NUM_SIMULATIONS,
probSixth: counts[5] / NUM_SIMULATIONS,
probSeventh: counts[6] / NUM_SIMULATIONS,
probEighth: counts[7] / NUM_SIMULATIONS,
},
source: "epl_standings_monte_carlo",
};
});
normalizeSimulationResultColumns(results);
return results;
}
}

View file

@ -16,6 +16,7 @@ import { NCAAWSimulator } from "./ncaaw-simulator";
import { NBASimulator } from "./nba-simulator";
import { NHLSimulator } from "./nhl-simulator";
import { AFLSimulator } from "./afl-simulator";
import { EPLSimulator } from "./epl-simulator";
import { SnookerSimulator } from "./snooker-simulator";
import { TennisSimulator } from "./tennis-simulator";
import { DartsSimulator } from "./darts-simulator";
@ -40,6 +41,7 @@ export const SIMULATOR_TYPES = [
"nhl_bracket",
"nfl_bracket",
"afl_bracket",
"epl_standings",
"snooker_bracket",
"tennis_qualifying_points",
"mlb_bracket",
@ -121,6 +123,10 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
info: { name: "AFL Season + Finals Monte Carlo", description: "Projects AFL regular season standings via Elo, then simulates the 10-team finals series (Wildcard → QF/EF → SF → PF → Grand Final). Reads current standings from DB; falls back to full-season projection pre-season." },
create: () => new AFLSimulator(),
},
epl_standings: {
info: { name: "EPL Season Standings Monte Carlo", description: "Projects the English Premier League table using current standings and Elo/projected-points inputs. Simulates draws correctly and returns top-eight placement probabilities." },
create: () => new EPLSimulator(),
},
snooker_bracket: {
info: { name: "Snooker World Championship Monte Carlo", description: "Simulates the 32-player World Championship bracket using frame-by-frame Bernoulli win probability and direct Elo ratings. Pre-bracket path simulates qualifying (ranks 17-48) and randomly draws qualifiers vs top 16 seeds." },
create: () => new SnookerSimulator(),

View file

@ -0,0 +1,31 @@
import type { SimulationProbabilities, SimulationResult } from "./types";
export const SIMULATION_PROBABILITY_KEYS = [
"probFirst",
"probSecond",
"probThird",
"probFourth",
"probFifth",
"probSixth",
"probSeventh",
"probEighth",
] as const satisfies ReadonlyArray<keyof SimulationProbabilities>;
/**
* Normalize each placement-probability column so every finishing position sums
* to exactly 1.0 across all participants after Monte Carlo floating-point math.
*/
export function normalizeSimulationResultColumns(results: SimulationResult[]): void {
if (results.length === 0) return;
for (const key of SIMULATION_PROBABILITY_KEYS) {
const colSum = results.reduce((sum, result) => sum + result.probabilities[key], 0);
const residual = 1.0 - colSum;
if (residual !== 0) {
const maxResult = results.reduce((best, result) =>
result.probabilities[key] > best.probabilities[key] ? result : best
);
maxResult.probabilities[key] += residual;
}
}
}

View file

@ -18,6 +18,8 @@ export interface SimulatorConfig {
parityFactor: number;
/** Average opponent Elo — used for season projections against a generic opponent. */
averageOpponentElo: number;
/** Label/type for the admin projection input. Defaults to projected wins. */
projectionInput?: "wins" | "tablePoints";
}
const CONFIG: Record<SimulatorType, SimulatorConfig | null> = {
@ -50,6 +52,12 @@ const CONFIG: Record<SimulatorType, SimulatorConfig | null> = {
parityFactor: 400,
averageOpponentElo: 1500,
},
epl_standings: {
seasonGames: 38,
parityFactor: 400,
averageOpponentElo: 1500,
projectionInput: "tablePoints",
},
wnba_bracket: {
seasonGames: 44,
parityFactor: 400,

View file

@ -0,0 +1,49 @@
import { eloWinProbabilityWithParity } from "~/services/probability-engine";
export type SoccerMatchResult = "win" | "draw" | "loss";
export interface EloSoccerMatchOptions {
baseDrawRate: number;
drawDecay: number;
parityFactor?: number;
}
export function soccerDrawProbability(
eloA: number,
eloB: number,
baseDrawRate: number,
drawDecay: number
): number {
return baseDrawRate * Math.exp(-drawDecay * Math.abs(eloA - eloB));
}
/**
* Simulate a soccer-style Elo match with an explicit draw probability.
* Returns the result from team A's perspective.
*/
export function simulateEloSoccerMatch(
eloA: number,
eloB: number,
{ baseDrawRate, drawDecay, parityFactor = 400 }: EloSoccerMatchOptions
): SoccerMatchResult {
const pDraw = soccerDrawProbability(eloA, eloB, baseDrawRate, drawDecay);
const pWinNoDraw = eloWinProbabilityWithParity(eloA, eloB, parityFactor);
const pWin = (1 - pDraw) * pWinNoDraw;
const rand = Math.random();
if (rand < pWin) return "win";
if (rand < pWin + pDraw) return "draw";
return "loss";
}
/** Generate simple low-scoring goals for table tiebreakers after a result is known. */
export function simulateSimpleSoccerGoals(result: SoccerMatchResult): { gf: number; ga: number } {
if (result === "draw") {
const goals = Math.random() < 0.55 ? 1 : Math.random() < 0.75 ? 0 : 2;
return { gf: goals, ga: goals };
}
const winnerGoals = Math.random() < 0.55 ? 2 : Math.random() < 0.75 ? 1 : 3;
const loserGoals = winnerGoals === 1 ? 0 : Math.random() < 0.35 ? 1 : 0;
if (result === "win") return { gf: winnerGoals, ga: loserGoals };
return { gf: loserGoals, ga: winnerGoals };
}

View file

@ -39,6 +39,7 @@ import { database } from "~/database/context";
import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema";
import { convertFuturesToElo, eloWinProbability } from "~/services/probability-engine";
import { simulateEloSoccerMatch } from "./soccer-helpers";
import { logger } from "~/lib/logger";
import type { Simulator, SimulationResult, SimulationProbabilities } from "./types";
@ -161,14 +162,10 @@ function getTeamElo(name: string, fallback = 1500): number {
* Returns "win" (team A wins), "draw", or "loss" (team B wins).
*/
export function simGroupMatch(eloA: number, eloB: number): "win" | "draw" | "loss" {
const eloDiff = eloA - eloB;
const pDraw = BASE_DRAW_RATE * Math.exp(-DRAW_DECAY * Math.abs(eloDiff));
const eloWin = eloWinProbability(eloA, eloB);
const pWin = (1 - pDraw) * eloWin;
const rand = Math.random();
if (rand < pWin) return "win";
if (rand < pWin + pDraw) return "draw";
return "loss";
return simulateEloSoccerMatch(eloA, eloB, {
baseDrawRate: BASE_DRAW_RATE,
drawDecay: DRAW_DECAY,
});
}
interface TeamStats {

View file

@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EplStandingsAdapter } from "../epl";
const SAMPLE_RESPONSE = {
standings: [
{
type: "TOTAL",
table: [
{
position: 1,
team: { id: 57, name: "Arsenal FC" },
playedGames: 30,
form: "W,W,D,W,L",
won: 21,
draw: 6,
lost: 3,
points: 69,
goalsFor: 72,
goalsAgainst: 25,
goalDifference: 47,
},
{
position: 2,
team: { id: 65, name: "Manchester City FC" },
playedGames: 30,
form: "W,W,W,D,W",
won: 20,
draw: 7,
lost: 3,
points: 67,
goalsFor: 70,
goalsAgainst: 28,
goalDifference: 42,
},
],
},
{ type: "HOME", table: [] },
],
};
describe("EplStandingsAdapter", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.restoreAllMocks();
});
it("maps football-data.org TOTAL standings to FetchedStandingsRecord[]", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_RESPONSE,
} as Response);
const records = await new EplStandingsAdapter("test-token").fetchStandings();
expect(records).toHaveLength(2);
const arsenal = records[0];
expect(arsenal.teamName).toBe("Arsenal FC");
expect(arsenal.externalTeamId).toBe("57");
expect(arsenal.wins).toBe(21);
expect(arsenal.ties).toBe(6);
expect(arsenal.losses).toBe(3);
expect(arsenal.gamesPlayed).toBe(30);
expect(arsenal.tablePoints).toBe(69);
expect(arsenal.goalsFor).toBe(72);
expect(arsenal.goalsAgainst).toBe(25);
expect(arsenal.goalDifference).toBe(47);
expect(arsenal.leagueRank).toBe(1);
expect(arsenal.lastTen).toBe("W,W,D,W,L");
});
it("sends the football-data.org auth token", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_RESPONSE,
} as Response);
await new EplStandingsAdapter("abc123").fetchStandings();
expect(fetch).toHaveBeenCalledWith(
"https://api.football-data.org/v4/competitions/PL/standings",
{ headers: { "X-Auth-Token": "abc123" } }
);
});
it("throws when no API key is configured", async () => {
await expect(new EplStandingsAdapter("").fetchStandings()).rejects.toThrow(/FOOTBALL_DATA_API_KEY/);
});
it("throws on non-ok response", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 429,
statusText: "Too Many Requests",
} as Response);
await expect(new EplStandingsAdapter("test-token").fetchStandings()).rejects.toThrow("429");
});
});

View file

@ -0,0 +1,91 @@
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
const EPL_STANDINGS_URL = "https://api.football-data.org/v4/competitions/PL/standings";
interface FootballDataTeam {
id: number;
name: string;
shortName?: string;
tla?: string;
}
interface FootballDataTableEntry {
position: number;
team: FootballDataTeam;
playedGames: number;
form?: string | null;
won: number;
draw: number;
lost: number;
points: number;
goalsFor: number;
goalsAgainst: number;
goalDifference: number;
}
interface FootballDataStanding {
type: "TOTAL" | "HOME" | "AWAY" | string;
table: FootballDataTableEntry[];
}
interface FootballDataStandingsResponse {
standings: FootballDataStanding[];
}
/**
* English Premier League standings adapter backed by football-data.org.
*
* Endpoint: GET /v4/competitions/PL/standings
* Required env var: FOOTBALL_DATA_API_KEY
*/
export class EplStandingsAdapter implements StandingsSyncAdapter {
constructor(private readonly apiKey = process.env.FOOTBALL_DATA_API_KEY) {}
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
if (!this.apiKey) {
throw new Error(
"FOOTBALL_DATA_API_KEY is required to sync EPL standings from football-data.org."
);
}
const response = await fetch(EPL_STANDINGS_URL, {
headers: {
"X-Auth-Token": this.apiKey,
},
});
if (!response.ok) {
throw new Error(
`football-data.org EPL standings API returned ${response.status}: ${response.statusText}`
);
}
const json = (await response.json()) as FootballDataStandingsResponse;
const total = json.standings?.find((standing) => standing.type === "TOTAL");
const table = total?.table ?? [];
if (table.length === 0) {
throw new Error(
"football-data.org EPL standings API returned no TOTAL table entries — response shape may have changed"
);
}
return table
.toSorted((a, b) => a.position - b.position)
.map((entry): FetchedStandingsRecord => ({
teamName: entry.team.name,
externalTeamId: String(entry.team.id),
wins: entry.won,
losses: entry.lost,
ties: entry.draw,
tablePoints: entry.points,
goalsFor: entry.goalsFor,
goalsAgainst: entry.goalsAgainst,
goalDifference: entry.goalDifference,
winPct: entry.playedGames > 0 ? entry.won / entry.playedGames : 0,
gamesPlayed: entry.playedGames,
leagueRank: entry.position,
lastTen: entry.form ?? undefined,
}));
}
}

View file

@ -10,6 +10,7 @@ import { NbaStandingsAdapter } from "./nba";
import { AflStandingsAdapter } from "./afl";
import { MlbStandingsAdapter } from "./mlb";
import { WnbaStandingsAdapter } from "./wnba";
import { EplStandingsAdapter } from "./epl";
import type { StandingsSyncAdapter, SyncResult, UnmatchedTeam } from "./types";
/**
@ -25,6 +26,8 @@ function getAdapter(simulatorType: string): StandingsSyncAdapter {
return new NhlStandingsAdapter();
case "afl_bracket":
return new AflStandingsAdapter();
case "epl_standings":
return new EplStandingsAdapter();
case "mlb_bracket":
return new MlbStandingsAdapter();
case "wnba_bracket":
@ -40,7 +43,7 @@ function getAdapter(simulatorType: string): StandingsSyncAdapter {
default:
throw new Error(
`No standings sync adapter available for simulator type "${simulatorType}". ` +
"Only NBA and NHL are currently supported."
"NBA, NHL, AFL, MLB, WNBA, and EPL are currently supported."
);
}
}
@ -113,6 +116,10 @@ export async function syncStandings(sportsSeasonId: string): Promise<SyncResult>
losses: record.losses,
otLosses: record.otLosses ?? null,
ties: record.ties ?? null,
tablePoints: record.tablePoints ?? null,
goalsFor: record.goalsFor ?? null,
goalsAgainst: record.goalsAgainst ?? null,
goalDifference: record.goalDifference ?? null,
winPct: record.winPct,
gamesPlayed: record.gamesPlayed,
gamesBack: record.gamesBack ?? null,

View file

@ -5,6 +5,10 @@ export interface FetchedStandingsRecord {
losses: number;
otLosses?: number;
ties?: number;
tablePoints?: number;
goalsFor?: number;
goalsAgainst?: number;
goalDifference?: number;
winPct: number;
gamesPlayed: number;
gamesBack?: number;

View file

@ -142,6 +142,7 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [
"nhl_bracket",
"nfl_bracket",
"afl_bracket",
"epl_standings",
"snooker_bracket",
"tennis_qualifying_points",
"mlb_bracket",
@ -1336,7 +1337,11 @@ export const regularSeasonStandings = pgTable("regular_season_standings", {
wins: integer("wins").notNull().default(0),
losses: integer("losses").notNull().default(0),
otLosses: integer("ot_losses"), // NHL overtime losses; null for NBA
ties: integer("ties"), // future sports (e.g. soccer)
ties: integer("ties"), // draws/ties (e.g. soccer, AFL)
tablePoints: integer("table_points"), // league table points (e.g. EPL: 3W + D, including deductions)
goalsFor: integer("goals_for"),
goalsAgainst: integer("goals_against"),
goalDifference: integer("goal_difference"),
winPct: decimal("win_pct", { precision: 5, scale: 4 }),
gamesPlayed: integer("games_played").notNull().default(0),
gamesBack: decimal("games_back", { precision: 5, scale: 1 }),

View file

@ -0,0 +1,5 @@
ALTER TYPE "public"."simulator_type" ADD VALUE 'epl_standings' BEFORE 'snooker_bracket';--> statement-breakpoint
ALTER TABLE "regular_season_standings" ADD COLUMN "table_points" integer;--> statement-breakpoint
ALTER TABLE "regular_season_standings" ADD COLUMN "goals_for" integer;--> statement-breakpoint
ALTER TABLE "regular_season_standings" ADD COLUMN "goals_against" integer;--> statement-breakpoint
ALTER TABLE "regular_season_standings" ADD COLUMN "goal_difference" integer;

File diff suppressed because it is too large Load diff

View file

@ -673,6 +673,13 @@
"when": 1778043585280,
"tag": "0095_lucky_madrox",
"breakpoints": true
},
{
"idx": 96,
"version": "7",
"when": 1778177368941,
"tag": "0096_demonic_vulture",
"breakpoints": true
}
]
}