Deduplicate soccer simulator helpers
This commit is contained in:
parent
6df4f86920
commit
4ed69c2246
23 changed files with 6786 additions and 80 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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 1st–8th 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} − {simulatorConfig.parityFactor} × log₁₀((1 − winRate) / winRate),
|
||||
where winRate = projectedWins / {simulatorConfig.seasonGames}.
|
||||
Conversion: Elo = {simulatorConfig.averageOpponentElo} − {simulatorConfig.parityFactor} × log₁₀((1 − rate) / rate),
|
||||
where rate = projection / {projectionMax}.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
143
app/services/simulations/__tests__/epl-simulator.test.ts
Normal file
143
app/services/simulations/__tests__/epl-simulator.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 ───────────────────────────────────────────────────────────
|
||||
|
|
|
|||
201
app/services/simulations/epl-simulator.ts
Normal file
201
app/services/simulations/epl-simulator.ts
Normal 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 (P1–P8) 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
31
app/services/simulations/simulation-probabilities.ts
Normal file
31
app/services/simulations/simulation-probabilities.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
49
app/services/simulations/soccer-helpers.ts
Normal file
49
app/services/simulations/soccer-helpers.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
100
app/services/standings-sync/__tests__/epl.test.ts
Normal file
100
app/services/standings-sync/__tests__/epl.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
91
app/services/standings-sync/epl.ts
Normal file
91
app/services/standings-sync/epl.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
5
drizzle/0096_demonic_vulture.sql
Normal file
5
drizzle/0096_demonic_vulture.sql
Normal 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;
|
||||
5780
drizzle/meta/0096_snapshot.json
Normal file
5780
drizzle/meta/0096_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -673,6 +673,13 @@
|
|||
"when": 1778043585280,
|
||||
"tag": "0095_lucky_madrox",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 96,
|
||||
"version": "7",
|
||||
"when": 1778177368941,
|
||||
"tag": "0096_demonic_vulture",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue