Deduplicate soccer simulator helpers (#391)
This commit is contained in:
parent
6df4f86920
commit
caaffda236
23 changed files with 6786 additions and 80 deletions
|
|
@ -14,6 +14,11 @@ interface StandingRow {
|
||||||
wins: number;
|
wins: number;
|
||||||
losses: number;
|
losses: number;
|
||||||
otLosses: number | null;
|
otLosses: number | null;
|
||||||
|
ties?: number | null;
|
||||||
|
tablePoints?: number | null;
|
||||||
|
goalsFor?: number | null;
|
||||||
|
goalsAgainst?: number | null;
|
||||||
|
goalDifference?: number | null;
|
||||||
winPct: string | null;
|
winPct: string | null;
|
||||||
gamesPlayed: number;
|
gamesPlayed: number;
|
||||||
gamesBack: string | null;
|
gamesBack: string | null;
|
||||||
|
|
@ -41,6 +46,7 @@ interface Props {
|
||||||
teamOwnerships: Record<string, TeamOwnership>;
|
teamOwnerships: Record<string, TeamOwnership>;
|
||||||
userParticipantIds: string[];
|
userParticipantIds: string[];
|
||||||
showOtLosses?: boolean;
|
showOtLosses?: boolean;
|
||||||
|
showSoccerTable?: boolean;
|
||||||
/** How many teams per conference qualify for playoffs (flat mode only). 0 = no line. */
|
/** How many teams per conference qualify for playoffs (flat mode only). 0 = no line. */
|
||||||
playoffSpots?: number;
|
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. */
|
/** "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,
|
teamOwnerships,
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
showOtLosses,
|
showOtLosses,
|
||||||
|
showSoccerTable = false,
|
||||||
}: {
|
}: {
|
||||||
sections: TableSection[];
|
sections: TableSection[];
|
||||||
teamOwnerships: Record<string, TeamOwnership>;
|
teamOwnerships: Record<string, TeamOwnership>;
|
||||||
userParticipantIds: string[];
|
userParticipantIds: string[];
|
||||||
showOtLosses: boolean;
|
showOtLosses: boolean;
|
||||||
|
showSoccerTable?: boolean;
|
||||||
}) {
|
}) {
|
||||||
// # Team GP W L [OTL] [PTS] PCT GB L10 STK Mgr
|
// # Team GP W L [OTL] [PTS] PCT GB L10 STK Mgr
|
||||||
// OTL and PTS are both shown for hockey (showOtLosses = true)
|
// OTL and PTS are both shown for hockey (showOtLosses = true).
|
||||||
const totalCols = 10 + (showOtLosses ? 2 : 0);
|
// Soccer table mode shows D/GF/GA/GD/Pts instead of PCT/GB/form columns.
|
||||||
|
const totalCols = showSoccerTable ? 11 : 10 + (showOtLosses ? 2 : 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto -mx-6 px-6">
|
<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-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-10">GP</th>
|
||||||
<th className="text-right py-1.5 px-2 w-8">W</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>
|
<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">OTL</th>}
|
||||||
{showOtLosses && <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">PTS</th>}
|
||||||
<th className="text-right py-1.5 px-2 w-12">PCT</th>
|
{!showSoccerTable && <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>
|
{!showSoccerTable && <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>
|
{!showSoccerTable && <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">STK</th>}
|
||||||
<th className="text-right py-1.5 pl-4 w-40">Mgr</th>
|
<th className="text-right py-1.5 pl-4 w-40">Mgr</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -316,7 +330,22 @@ function StandingsTable({
|
||||||
{row.gamesPlayed}
|
{row.gamesPlayed}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-2 text-right tabular-nums font-medium">{row.wins}</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>
|
<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 && (
|
{showOtLosses && (
|
||||||
<td className="py-2 px-2 text-right tabular-nums">{row.otLosses ?? 0}</td>
|
<td className="py-2 px-2 text-right tabular-nums">{row.otLosses ?? 0}</td>
|
||||||
)}
|
)}
|
||||||
|
|
@ -325,15 +354,22 @@ function StandingsTable({
|
||||||
{row.wins * 2 + (row.otLosses ?? 0)}
|
{row.wins * 2 + (row.otLosses ?? 0)}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
|
{!showSoccerTable && (
|
||||||
<td className="py-2 px-2 text-right tabular-nums text-muted-foreground">
|
<td className="py-2 px-2 text-right tabular-nums text-muted-foreground">
|
||||||
{formatWinPct(row.winPct, row.wins, row.gamesPlayed)}
|
{formatWinPct(row.winPct, row.wins, row.gamesPlayed)}
|
||||||
</td>
|
</td>
|
||||||
|
)}
|
||||||
|
{!showSoccerTable && (
|
||||||
<td className="py-2 px-2 text-right tabular-nums text-muted-foreground">
|
<td className="py-2 px-2 text-right tabular-nums text-muted-foreground">
|
||||||
{formatGB(row.gamesBack)}
|
{formatGB(row.gamesBack)}
|
||||||
</td>
|
</td>
|
||||||
|
)}
|
||||||
|
{!showSoccerTable && (
|
||||||
<td className="py-2 px-2 text-right tabular-nums text-muted-foreground">
|
<td className="py-2 px-2 text-right tabular-nums text-muted-foreground">
|
||||||
{row.lastTen ?? "—"}
|
{row.lastTen ?? "—"}
|
||||||
</td>
|
</td>
|
||||||
|
)}
|
||||||
|
{!showSoccerTable && (
|
||||||
<td className="py-2 px-2 text-right tabular-nums">
|
<td className="py-2 px-2 text-right tabular-nums">
|
||||||
{row.streak ? (
|
{row.streak ? (
|
||||||
<span
|
<span
|
||||||
|
|
@ -349,6 +385,7 @@ function StandingsTable({
|
||||||
<span className="text-muted-foreground">—</span>
|
<span className="text-muted-foreground">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
)}
|
||||||
<td className="py-2 pl-4 text-right">
|
<td className="py-2 pl-4 text-right">
|
||||||
{ownership ? (
|
{ownership ? (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|
@ -381,6 +418,7 @@ export function RegularSeasonStandings({
|
||||||
teamOwnerships,
|
teamOwnerships,
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
showOtLosses = false,
|
showOtLosses = false,
|
||||||
|
showSoccerTable = false,
|
||||||
playoffSpots = 8,
|
playoffSpots = 8,
|
||||||
displayMode = "flat",
|
displayMode = "flat",
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
|
@ -399,7 +437,7 @@ export function RegularSeasonStandings({
|
||||||
? buildMlbSections(standings)
|
? buildMlbSections(standings)
|
||||||
: buildFlatSections(standings, playoffSpots);
|
: buildFlatSections(standings, playoffSpots);
|
||||||
|
|
||||||
const tableProps = { teamOwnerships, userParticipantIds, showOtLosses };
|
const tableProps = { teamOwnerships, userParticipantIds, showOtLosses, showSoccerTable };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,11 @@ function makeRow(overrides: Partial<{
|
||||||
wins: number;
|
wins: number;
|
||||||
losses: number;
|
losses: number;
|
||||||
otLosses: number | null;
|
otLosses: number | null;
|
||||||
|
ties: number | null;
|
||||||
|
tablePoints: number | null;
|
||||||
|
goalsFor: number | null;
|
||||||
|
goalsAgainst: number | null;
|
||||||
|
goalDifference: number | null;
|
||||||
gamesPlayed: number;
|
gamesPlayed: number;
|
||||||
winPct: string | null;
|
winPct: string | null;
|
||||||
gamesBack: string | null;
|
gamesBack: string | null;
|
||||||
|
|
@ -29,6 +34,11 @@ function makeRow(overrides: Partial<{
|
||||||
wins: overrides.wins ?? 40,
|
wins: overrides.wins ?? 40,
|
||||||
losses: overrides.losses ?? 28,
|
losses: overrides.losses ?? 28,
|
||||||
otLosses: overrides.otLosses ?? null,
|
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,
|
gamesPlayed: overrides.gamesPlayed ?? 68,
|
||||||
winPct: overrides.winPct ?? "0.5882",
|
winPct: overrides.winPct ?? "0.5882",
|
||||||
gamesBack: overrides.gamesBack ?? null,
|
gamesBack: overrides.gamesBack ?? null,
|
||||||
|
|
@ -99,6 +109,38 @@ describe("RegularSeasonStandings", () => {
|
||||||
expect(screen.queryByText("OTL")).not.toBeInTheDocument();
|
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", () => {
|
it("renders conference headings and inline division labels when present", () => {
|
||||||
render(
|
render(
|
||||||
<RegularSeasonStandings
|
<RegularSeasonStandings
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@ export interface UpsertRegularSeasonStandingData {
|
||||||
losses: number;
|
losses: number;
|
||||||
otLosses?: number | null;
|
otLosses?: number | null;
|
||||||
ties?: number | null;
|
ties?: number | null;
|
||||||
|
tablePoints?: number | null;
|
||||||
|
goalsFor?: number | null;
|
||||||
|
goalsAgainst?: number | null;
|
||||||
|
goalDifference?: number | null;
|
||||||
winPct?: number | null;
|
winPct?: number | null;
|
||||||
gamesPlayed: number;
|
gamesPlayed: number;
|
||||||
gamesBack?: number | null;
|
gamesBack?: number | null;
|
||||||
|
|
@ -47,6 +51,10 @@ export async function upsertRegularSeasonStandings(
|
||||||
losses: r.losses,
|
losses: r.losses,
|
||||||
otLosses: r.otLosses ?? null,
|
otLosses: r.otLosses ?? null,
|
||||||
ties: r.ties ?? 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,
|
winPct: r.winPct !== null && r.winPct !== undefined ? r.winPct.toString() : null,
|
||||||
gamesPlayed: r.gamesPlayed,
|
gamesPlayed: r.gamesPlayed,
|
||||||
gamesBack: r.gamesBack !== null && r.gamesBack !== undefined ? r.gamesBack.toString() : null,
|
gamesBack: r.gamesBack !== null && r.gamesBack !== undefined ? r.gamesBack.toString() : null,
|
||||||
|
|
@ -79,6 +87,10 @@ export async function upsertRegularSeasonStandings(
|
||||||
losses: sql`excluded.losses`,
|
losses: sql`excluded.losses`,
|
||||||
otLosses: sql`excluded.ot_losses`,
|
otLosses: sql`excluded.ot_losses`,
|
||||||
ties: sql`excluded.ties`,
|
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`,
|
winPct: sql`excluded.win_pct`,
|
||||||
gamesPlayed: sql`excluded.games_played`,
|
gamesPlayed: sql`excluded.games_played`,
|
||||||
gamesBack: sql`excluded.games_back`,
|
gamesBack: sql`excluded.games_back`,
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,12 @@ import { useState } from 'react';
|
||||||
import { Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
import { Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||||
import { normalizeName } from '~/lib/fuzzy-match';
|
import { normalizeName } from '~/lib/fuzzy-match';
|
||||||
import { getSimulatorConfig, supportsProjectedWins } from '~/services/simulations/simulator-config';
|
import { 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
|
// Simulator types that use worldRanking in addition to sourceElo
|
||||||
const RANKING_SIMULATOR_TYPES = new Set(['darts_bracket', 'cs2_major_qualifying_points']);
|
const RANKING_SIMULATOR_TYPES = new Set(['darts_bracket', 'cs2_major_qualifying_points']);
|
||||||
|
|
@ -102,15 +107,18 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
if (inputMode === 'projectedWins' && sportsSeason.sport?.simulatorType) {
|
if (inputMode === 'projectedWins' && sportsSeason.sport?.simulatorType) {
|
||||||
const config = getSimulatorConfig(sportsSeason.sport.simulatorType as SimulatorType);
|
const config = getSimulatorConfig(sportsSeason.sport.simulatorType as SimulatorType);
|
||||||
if (!config) {
|
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) {
|
for (const participant of participants) {
|
||||||
const winsVal = formData.get(`wins_${participant.id}`) as string;
|
const winsVal = formData.get(`wins_${participant.id}`) as string;
|
||||||
if (winsVal && winsVal.trim() !== '') {
|
if (winsVal && winsVal.trim() !== '') {
|
||||||
const projectedWins = parseFloat(winsVal);
|
const projectedWins = parseFloat(winsVal);
|
||||||
if (!isNaN(projectedWins) && projectedWins >= 0 && projectedWins <= config.seasonGames) {
|
const projectionMax = config.projectionInput === 'tablePoints' ? config.seasonGames * 3 : config.seasonGames;
|
||||||
const elo = projectedWinsToElo(projectedWins, config.seasonGames, config.parityFactor, config.averageOpponentElo);
|
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 });
|
eloInputs.push({ participantId: participant.id, sportsSeasonId, sourceElo: elo });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -141,7 +149,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
|
|
||||||
if (eloInputs.length === 0) {
|
if (eloInputs.length === 0) {
|
||||||
return { success: false, message: inputMode === 'projectedWins'
|
return { success: false, message: inputMode === 'projectedWins'
|
||||||
? 'Please enter 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' };
|
: 'Please enter an Elo rating for at least one participant' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -234,6 +242,13 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
const [inputMode, setInputMode] = useState<'elo' | 'projectedWins'>('elo');
|
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 [eloValues, setEloValues] = useState<Record<string, string>>(() => {
|
||||||
const initial: Record<string, string> = {};
|
const initial: Record<string, string> = {};
|
||||||
|
|
@ -259,8 +274,9 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
participants.forEach(p => {
|
participants.forEach(p => {
|
||||||
const d = existingData[p.id];
|
const d = existingData[p.id];
|
||||||
if (d?.elo !== null && d?.elo !== undefined) {
|
if (d?.elo !== null && d?.elo !== undefined) {
|
||||||
initial[p.id] = eloToProjectedWins(
|
initial[p.id] = (simulatorConfig.projectionInput === 'tablePoints'
|
||||||
d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo
|
? eloToProjectedTablePoints(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
||||||
|
: eloToProjectedWins(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
||||||
).toFixed(1);
|
).toFixed(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -311,9 +327,11 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
const inputName = match[1].trim();
|
const inputName = match[1].trim();
|
||||||
const projectedWins = parseFloat(match[2]);
|
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);
|
const participant = findParticipantMatch(inputName);
|
||||||
if (participant && !seen.has(participant.id)) {
|
if (participant && !seen.has(participant.id)) {
|
||||||
|
|
@ -356,8 +374,9 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
newElos[m.participantId] = m.elo.toString();
|
newElos[m.participantId] = m.elo.toString();
|
||||||
if (m.ranking !== null) newRanks[m.participantId] = m.ranking.toString();
|
if (m.ranking !== null) newRanks[m.participantId] = m.ranking.toString();
|
||||||
if (inputMode === 'projectedWins' && simulatorConfig) {
|
if (inputMode === 'projectedWins' && simulatorConfig) {
|
||||||
newWins[m.participantId] = eloToProjectedWins(
|
newWins[m.participantId] = (simulatorConfig.projectionInput === 'tablePoints'
|
||||||
m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo
|
? eloToProjectedTablePoints(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
||||||
|
: eloToProjectedWins(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
||||||
).toFixed(1);
|
).toFixed(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -408,7 +427,7 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
variant={inputMode === 'projectedWins' ? 'default' : 'outline'}
|
variant={inputMode === 'projectedWins' ? 'default' : 'outline'}
|
||||||
onClick={() => setInputMode('projectedWins')}
|
onClick={() => setInputMode('projectedWins')}
|
||||||
>
|
>
|
||||||
Projected Wins
|
{projectionLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -420,8 +439,8 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{inputMode === 'projectedWins' && simulatorConfig ? (
|
{inputMode === 'projectedWins' && simulatorConfig ? (
|
||||||
<>
|
<>
|
||||||
Paste projected season wins one per line. Format: <code>Team Name, 15.6</code>.
|
Paste projected season {projectionUnit} one per line. Format: <code>Team Name, {simulatorConfig.projectionInput === 'tablePoints' ? '76.5' : '15.6'}</code>.
|
||||||
Wins are automatically converted to Elo ratings using {simulatorConfig.seasonGames} total games
|
Projections are automatically converted to Elo ratings using {simulatorConfig.seasonGames} total games
|
||||||
and parity factor {simulatorConfig.parityFactor}. Names are fuzzy-matched to participants.
|
and parity factor {simulatorConfig.parityFactor}. Names are fuzzy-matched to participants.
|
||||||
</>
|
</>
|
||||||
) : usesRanking ? (
|
) : usesRanking ? (
|
||||||
|
|
@ -442,7 +461,9 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder={
|
placeholder={
|
||||||
inputMode === 'projectedWins'
|
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
|
: usesRanking
|
||||||
? simulatorType === 'cs2_major_qualifying_points'
|
? simulatorType === 'cs2_major_qualifying_points'
|
||||||
? `Natus Vincere, 1850, 1\nFaZe Clan, 1820, 2\nVitality, 1810, 3`
|
? `Natus Vincere, 1850, 1\nFaZe Clan, 1820, 2\nVitality, 1810, 3`
|
||||||
|
|
@ -515,14 +536,14 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
{inputMode === 'projectedWins'
|
{inputMode === 'projectedWins'
|
||||||
? `Projected Season Wins (out of ${simulatorConfig?.seasonGames ?? '?'})`
|
? `${projectionLabel} (out of ${projectionMax || '?'})`
|
||||||
: usesRanking
|
: usesRanking
|
||||||
? `Player Elo & ${rankLabel}s`
|
? `Player Elo & ${rankLabel}s`
|
||||||
: 'Player Elo Ratings'}
|
: 'Player Elo Ratings'}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{inputMode === 'projectedWins'
|
{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
|
: usesRanking
|
||||||
? `Enter each player's Elo and ${rankLabel}. Saving will automatically run the simulation and update expected values.`
|
? `Enter each player's Elo and ${rankLabel}. Saving will automatically run the simulation and update expected values.`
|
||||||
: 'Enter each player\'s current Elo rating. Saving will automatically run the simulation and update expected values.'}
|
: 'Enter each player\'s current Elo rating. Saving will automatically run the simulation and update expected values.'}
|
||||||
|
|
@ -541,7 +562,7 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
{inputMode === 'projectedWins' && (
|
{inputMode === 'projectedWins' && (
|
||||||
<div className="grid grid-cols-[1fr_100px] gap-x-3 gap-y-1 items-center text-xs font-medium text-muted-foreground">
|
<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>Team</span>
|
||||||
<span>Proj Wins</span>
|
<span>{simulatorConfig?.projectionInput === 'tablePoints' ? 'Proj Pts' : 'Proj Wins'}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -557,7 +578,7 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
step="0.1"
|
step="0.1"
|
||||||
id={`wins_${participant.id}`}
|
id={`wins_${participant.id}`}
|
||||||
name={`wins_${participant.id}`}
|
name={`wins_${participant.id}`}
|
||||||
placeholder={`${(simulatorConfig.seasonGames / 2).toFixed(1)}`}
|
placeholder={`${(projectionMax / 2).toFixed(1)}`}
|
||||||
value={winsValues[participant.id] ?? ''}
|
value={winsValues[participant.id] ?? ''}
|
||||||
onChange={e =>
|
onChange={e =>
|
||||||
setWinsValues(prev => ({ ...prev, [participant.id]: e.target.value }))
|
setWinsValues(prev => ({ ...prev, [participant.id]: e.target.value }))
|
||||||
|
|
@ -625,8 +646,8 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
<CardContent className="text-sm space-y-2">
|
<CardContent className="text-sm space-y-2">
|
||||||
{inputMode === 'projectedWins' ? (
|
{inputMode === 'projectedWins' ? (
|
||||||
<ol className="list-decimal list-inside space-y-2">
|
<ol className="list-decimal list-inside space-y-2">
|
||||||
<li>Enter each team's projected total season wins</li>
|
<li>Enter each team's projected total season {projectionUnit}</li>
|
||||||
<li>Wins are converted to Elo ratings using the inverse Elo formula</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>Elo ratings are saved and the simulation runs automatically</li>
|
||||||
<li>Results: probability distributions across 1st–8th place buckets</li>
|
<li>Results: probability distributions across 1st–8th place buckets</li>
|
||||||
<li>Expected fantasy value is calculated per team</li>
|
<li>Expected fantasy value is calculated per team</li>
|
||||||
|
|
@ -648,8 +669,8 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
)}
|
)}
|
||||||
{inputMode === 'projectedWins' && simulatorConfig && (
|
{inputMode === 'projectedWins' && simulatorConfig && (
|
||||||
<div className="mt-4 text-muted-foreground text-xs">
|
<div className="mt-4 text-muted-foreground text-xs">
|
||||||
Conversion: Elo = {simulatorConfig.averageOpponentElo} − {simulatorConfig.parityFactor} × log₁₀((1 − winRate) / winRate),
|
Conversion: Elo = {simulatorConfig.averageOpponentElo} − {simulatorConfig.parityFactor} × log₁₀((1 − rate) / rate),
|
||||||
where winRate = projectedWins / {simulatorConfig.seasonGames}.
|
where rate = projection / {projectionMax}.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
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,
|
participants,
|
||||||
standingsMap,
|
standingsMap,
|
||||||
};
|
};
|
||||||
|
|
@ -44,13 +44,20 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
export async function action({ request, params }: Route.ActionArgs) {
|
export async function action({ request, params }: Route.ActionArgs) {
|
||||||
const formData = await request.formData();
|
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<
|
const byId = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
wins?: number;
|
wins?: number;
|
||||||
losses?: number;
|
losses?: number;
|
||||||
otLosses?: number | null;
|
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;
|
conference?: string | null;
|
||||||
division?: string | null;
|
division?: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -60,6 +67,13 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
const winsMatch = key.match(/^wins_(.+)$/);
|
const winsMatch = key.match(/^wins_(.+)$/);
|
||||||
const lossesMatch = key.match(/^losses_(.+)$/);
|
const lossesMatch = key.match(/^losses_(.+)$/);
|
||||||
const otMatch = key.match(/^otLosses_(.+)$/);
|
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 confMatch = key.match(/^conference_(.+)$/);
|
||||||
const divMatch = key.match(/^division_(.+)$/);
|
const divMatch = key.match(/^division_(.+)$/);
|
||||||
|
|
||||||
|
|
@ -75,6 +89,34 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
const id = otMatch[1];
|
const id = otMatch[1];
|
||||||
const otLosses = value === "" ? null : parseInt(value as string, 10);
|
const otLosses = value === "" ? null : parseInt(value as string, 10);
|
||||||
byId.set(id, { ...byId.get(id), otLosses: isNaN(otLosses as number) ? null : otLosses });
|
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) {
|
} else if (confMatch) {
|
||||||
const id = confMatch[1];
|
const id = confMatch[1];
|
||||||
byId.set(id, { ...byId.get(id), conference: (value as string).trim() || null });
|
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;
|
if (data.wins === null && data.losses === null) continue;
|
||||||
const wins = data.wins ?? 0;
|
const wins = data.wins ?? 0;
|
||||||
const losses = data.losses ?? 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 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, {
|
await upsertManualStanding(participantId, params.id, {
|
||||||
wins,
|
wins,
|
||||||
losses,
|
losses,
|
||||||
otLosses: data.otLosses ?? null,
|
otLosses: data.otLosses ?? null,
|
||||||
|
ties: data.ties ?? null,
|
||||||
|
tablePoints,
|
||||||
|
goalsFor,
|
||||||
|
goalsAgainst,
|
||||||
|
goalDifference,
|
||||||
gamesPlayed,
|
gamesPlayed,
|
||||||
|
leagueRank: data.leagueRank ?? null,
|
||||||
winPct,
|
winPct,
|
||||||
conference: data.conference ?? null,
|
conference: data.conference ?? null,
|
||||||
division: data.division ?? null,
|
division: data.division ?? null,
|
||||||
|
|
@ -113,12 +166,16 @@ export default function ManageRegularStandings({ loaderData, actionData }: Route
|
||||||
const { sportsSeason, participants, standingsMap } = loaderData;
|
const { sportsSeason, participants, standingsMap } = loaderData;
|
||||||
const isNhl = sportsSeason.sport?.name?.toLowerCase().includes("nhl") ||
|
const isNhl = sportsSeason.sport?.name?.toLowerCase().includes("nhl") ||
|
||||||
sportsSeason.sport?.name?.toLowerCase().includes("hockey");
|
sportsSeason.sport?.name?.toLowerCase().includes("hockey");
|
||||||
|
const isEpl = sportsSeason.sport?.simulatorType === "epl_standings";
|
||||||
|
|
||||||
// Sort participants: those with records first (by wins desc), then unrecorded
|
// Sort participants: those with records first (by wins desc), then unrecorded
|
||||||
const sorted = [...participants].toSorted((a, b) => {
|
const sorted = [...participants].toSorted((a, b) => {
|
||||||
const recA = standingsMap[a.id];
|
const recA = standingsMap[a.id];
|
||||||
const recB = standingsMap[b.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 (recA) return -1;
|
||||||
if (recB) return 1;
|
if (recB) return 1;
|
||||||
return a.name.localeCompare(b.name);
|
return a.name.localeCompare(b.name);
|
||||||
|
|
@ -139,7 +196,7 @@ export default function ManageRegularStandings({ loaderData, actionData }: Route
|
||||||
{sportsSeason.sport.name} — {sportsSeason.name}
|
{sportsSeason.sport.name} — {sportsSeason.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -162,7 +219,9 @@ export default function ManageRegularStandings({ loaderData, actionData }: Route
|
||||||
W/L Records
|
W/L Records
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
@ -170,6 +229,20 @@ export default function ManageRegularStandings({ loaderData, actionData }: Route
|
||||||
<input type="hidden" name="_isNhl" value={isNhl ? "true" : "false"} />
|
<input type="hidden" name="_isNhl" value={isNhl ? "true" : "false"} />
|
||||||
|
|
||||||
<div className="space-y-1 mb-4">
|
<div className="space-y-1 mb-4">
|
||||||
|
{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]"}`}>
|
<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>Team</span>
|
||||||
<span>W</span>
|
<span>W</span>
|
||||||
|
|
@ -178,10 +251,36 @@ export default function ManageRegularStandings({ loaderData, actionData }: Route
|
||||||
<span>Conference</span>
|
<span>Conference</span>
|
||||||
<span>Division</span>
|
<span>Division</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{sorted.map((participant) => {
|
{sorted.map((participant) => {
|
||||||
const record = standingsMap[participant.id];
|
const record = standingsMap[participant.id];
|
||||||
const isSynced = record && record.syncedAt !== null;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={participant.id}
|
key={participant.id}
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,7 @@ export default function SportSeasonDetail({
|
||||||
teamOwnerships={ownershipMap}
|
teamOwnerships={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
showOtLosses={showOtLosses}
|
showOtLosses={showOtLosses}
|
||||||
|
showSoccerTable={simulatorType === "epl_standings"}
|
||||||
displayMode={standingsDisplayMode as "flat" | "nhl-divisions" | "mlb-divisions"}
|
displayMode={standingsDisplayMode as "flat" | "nhl-divisions" | "mlb-divisions"}
|
||||||
playoffSpots={playoffSpots}
|
playoffSpots={playoffSpots}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -202,8 +202,16 @@ export function mapToElo(
|
||||||
* eloWinProbability(1648, 1324) // 0.828 (82.8%)
|
* eloWinProbability(1648, 1324) // 0.828 (82.8%)
|
||||||
* eloWinProbability(1500, 1500) // 0.5 (50%)
|
* 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 {
|
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));
|
const winProb = 1 / (1 + Math.pow(10, (averageElo - elo) / parityFactor));
|
||||||
return winProb * totalGames;
|
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 { normalizeTeamName } from "~/lib/normalize-team-name";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||||||
|
import { eloWinProbabilityWithParity } from "~/services/probability-engine";
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -161,7 +162,7 @@ export function getTeamData(name: string): AflTeamData | undefined {
|
||||||
* Exported for unit testing.
|
* Exported for unit testing.
|
||||||
*/
|
*/
|
||||||
export function eloWinProbability(eloA: number, eloB: number): number {
|
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 ───────────────────────────────────────────────────────────
|
// ─── 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 { NBASimulator } from "./nba-simulator";
|
||||||
import { NHLSimulator } from "./nhl-simulator";
|
import { NHLSimulator } from "./nhl-simulator";
|
||||||
import { AFLSimulator } from "./afl-simulator";
|
import { AFLSimulator } from "./afl-simulator";
|
||||||
|
import { EPLSimulator } from "./epl-simulator";
|
||||||
import { SnookerSimulator } from "./snooker-simulator";
|
import { SnookerSimulator } from "./snooker-simulator";
|
||||||
import { TennisSimulator } from "./tennis-simulator";
|
import { TennisSimulator } from "./tennis-simulator";
|
||||||
import { DartsSimulator } from "./darts-simulator";
|
import { DartsSimulator } from "./darts-simulator";
|
||||||
|
|
@ -40,6 +41,7 @@ export const SIMULATOR_TYPES = [
|
||||||
"nhl_bracket",
|
"nhl_bracket",
|
||||||
"nfl_bracket",
|
"nfl_bracket",
|
||||||
"afl_bracket",
|
"afl_bracket",
|
||||||
|
"epl_standings",
|
||||||
"snooker_bracket",
|
"snooker_bracket",
|
||||||
"tennis_qualifying_points",
|
"tennis_qualifying_points",
|
||||||
"mlb_bracket",
|
"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." },
|
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(),
|
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: {
|
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." },
|
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(),
|
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;
|
parityFactor: number;
|
||||||
/** Average opponent Elo — used for season projections against a generic opponent. */
|
/** Average opponent Elo — used for season projections against a generic opponent. */
|
||||||
averageOpponentElo: number;
|
averageOpponentElo: number;
|
||||||
|
/** Label/type for the admin projection input. Defaults to projected wins. */
|
||||||
|
projectionInput?: "wins" | "tablePoints";
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONFIG: Record<SimulatorType, SimulatorConfig | null> = {
|
const CONFIG: Record<SimulatorType, SimulatorConfig | null> = {
|
||||||
|
|
@ -50,6 +52,12 @@ const CONFIG: Record<SimulatorType, SimulatorConfig | null> = {
|
||||||
parityFactor: 400,
|
parityFactor: 400,
|
||||||
averageOpponentElo: 1500,
|
averageOpponentElo: 1500,
|
||||||
},
|
},
|
||||||
|
epl_standings: {
|
||||||
|
seasonGames: 38,
|
||||||
|
parityFactor: 400,
|
||||||
|
averageOpponentElo: 1500,
|
||||||
|
projectionInput: "tablePoints",
|
||||||
|
},
|
||||||
wnba_bracket: {
|
wnba_bracket: {
|
||||||
seasonGames: 44,
|
seasonGames: 44,
|
||||||
parityFactor: 400,
|
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 { eq, and } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { convertFuturesToElo, eloWinProbability } from "~/services/probability-engine";
|
import { convertFuturesToElo, eloWinProbability } from "~/services/probability-engine";
|
||||||
|
import { simulateEloSoccerMatch } from "./soccer-helpers";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import type { Simulator, SimulationResult, SimulationProbabilities } from "./types";
|
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).
|
* Returns "win" (team A wins), "draw", or "loss" (team B wins).
|
||||||
*/
|
*/
|
||||||
export function simGroupMatch(eloA: number, eloB: number): "win" | "draw" | "loss" {
|
export function simGroupMatch(eloA: number, eloB: number): "win" | "draw" | "loss" {
|
||||||
const eloDiff = eloA - eloB;
|
return simulateEloSoccerMatch(eloA, eloB, {
|
||||||
const pDraw = BASE_DRAW_RATE * Math.exp(-DRAW_DECAY * Math.abs(eloDiff));
|
baseDrawRate: BASE_DRAW_RATE,
|
||||||
const eloWin = eloWinProbability(eloA, eloB);
|
drawDecay: DRAW_DECAY,
|
||||||
const pWin = (1 - pDraw) * eloWin;
|
});
|
||||||
const rand = Math.random();
|
|
||||||
if (rand < pWin) return "win";
|
|
||||||
if (rand < pWin + pDraw) return "draw";
|
|
||||||
return "loss";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TeamStats {
|
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 { AflStandingsAdapter } from "./afl";
|
||||||
import { MlbStandingsAdapter } from "./mlb";
|
import { MlbStandingsAdapter } from "./mlb";
|
||||||
import { WnbaStandingsAdapter } from "./wnba";
|
import { WnbaStandingsAdapter } from "./wnba";
|
||||||
|
import { EplStandingsAdapter } from "./epl";
|
||||||
import type { StandingsSyncAdapter, SyncResult, UnmatchedTeam } from "./types";
|
import type { StandingsSyncAdapter, SyncResult, UnmatchedTeam } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -25,6 +26,8 @@ function getAdapter(simulatorType: string): StandingsSyncAdapter {
|
||||||
return new NhlStandingsAdapter();
|
return new NhlStandingsAdapter();
|
||||||
case "afl_bracket":
|
case "afl_bracket":
|
||||||
return new AflStandingsAdapter();
|
return new AflStandingsAdapter();
|
||||||
|
case "epl_standings":
|
||||||
|
return new EplStandingsAdapter();
|
||||||
case "mlb_bracket":
|
case "mlb_bracket":
|
||||||
return new MlbStandingsAdapter();
|
return new MlbStandingsAdapter();
|
||||||
case "wnba_bracket":
|
case "wnba_bracket":
|
||||||
|
|
@ -40,7 +43,7 @@ function getAdapter(simulatorType: string): StandingsSyncAdapter {
|
||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`No standings sync adapter available for simulator type "${simulatorType}". ` +
|
`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,
|
losses: record.losses,
|
||||||
otLosses: record.otLosses ?? null,
|
otLosses: record.otLosses ?? null,
|
||||||
ties: record.ties ?? null,
|
ties: record.ties ?? null,
|
||||||
|
tablePoints: record.tablePoints ?? null,
|
||||||
|
goalsFor: record.goalsFor ?? null,
|
||||||
|
goalsAgainst: record.goalsAgainst ?? null,
|
||||||
|
goalDifference: record.goalDifference ?? null,
|
||||||
winPct: record.winPct,
|
winPct: record.winPct,
|
||||||
gamesPlayed: record.gamesPlayed,
|
gamesPlayed: record.gamesPlayed,
|
||||||
gamesBack: record.gamesBack ?? null,
|
gamesBack: record.gamesBack ?? null,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ export interface FetchedStandingsRecord {
|
||||||
losses: number;
|
losses: number;
|
||||||
otLosses?: number;
|
otLosses?: number;
|
||||||
ties?: number;
|
ties?: number;
|
||||||
|
tablePoints?: number;
|
||||||
|
goalsFor?: number;
|
||||||
|
goalsAgainst?: number;
|
||||||
|
goalDifference?: number;
|
||||||
winPct: number;
|
winPct: number;
|
||||||
gamesPlayed: number;
|
gamesPlayed: number;
|
||||||
gamesBack?: number;
|
gamesBack?: number;
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,7 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [
|
||||||
"nhl_bracket",
|
"nhl_bracket",
|
||||||
"nfl_bracket",
|
"nfl_bracket",
|
||||||
"afl_bracket",
|
"afl_bracket",
|
||||||
|
"epl_standings",
|
||||||
"snooker_bracket",
|
"snooker_bracket",
|
||||||
"tennis_qualifying_points",
|
"tennis_qualifying_points",
|
||||||
"mlb_bracket",
|
"mlb_bracket",
|
||||||
|
|
@ -1336,7 +1337,11 @@ export const regularSeasonStandings = pgTable("regular_season_standings", {
|
||||||
wins: integer("wins").notNull().default(0),
|
wins: integer("wins").notNull().default(0),
|
||||||
losses: integer("losses").notNull().default(0),
|
losses: integer("losses").notNull().default(0),
|
||||||
otLosses: integer("ot_losses"), // NHL overtime losses; null for NBA
|
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 }),
|
winPct: decimal("win_pct", { precision: 5, scale: 4 }),
|
||||||
gamesPlayed: integer("games_played").notNull().default(0),
|
gamesPlayed: integer("games_played").notNull().default(0),
|
||||||
gamesBack: decimal("games_back", { precision: 5, scale: 1 }),
|
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,
|
"when": 1778043585280,
|
||||||
"tag": "0095_lucky_madrox",
|
"tag": "0095_lucky_madrox",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 96,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778177368941,
|
||||||
|
"tag": "0096_demonic_vulture",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue