Add tennis Grand Slam simulator with surface Elo ratings, fixes #116 (#216)

Implements a Monte Carlo simulator for men's/women's tennis seasons scored
on the qualifying_points pattern. Simulates all 4 Grand Slam majors
(Australian Open, French Open, Wimbledon, US Open) using surface-specific
Elo ratings and ATP/WTA world rankings for seeding.

New table: participant_surface_elos — one row per (participant, season)
storing worldRanking, eloHard, eloClay, eloGrass.

Key design decisions:
- Seeding uses ATP/WTA world ranking (not Elo), matching real draw procedure
- Top 32 seeded with standard slot placement (1→0, 2→64, 3-4→quarters, etc.)
- QP per round with tie-splitting pre-applied: W=20, F=14, SF=9, QF=4, R16=1.5
- Completed majors read actual qualifyingPointsAwarded from eventResults
- 10,000 Monte Carlo simulations; column sums naturally 1.0 (no normalization)

Admin UI at /admin/sports-seasons/:id/surface-elo:
- 5-column grid (Player | Rank | Hard | Clay | Grass)
- Bulk import: "Name, ranking, hardElo, clayElo, grassElo" one per line
- Fuzzy name matching (bigram Dice coefficient) with "Did you mean?" suggestions
- Inline participant creation for unmatched names via useFetcher
- Saves Elos and auto-runs simulation on submit

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-03-23 23:59:35 -07:00 committed by GitHub
parent f4031d2a38
commit d7ba20d9ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 9753 additions and 0 deletions

View file

@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock the database context before importing any model
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
import { database } from "~/database/context";
import { getSurfaceEloMap, batchUpsertSurfaceElos, getSurfaceElosForSeason } from "../surface-elo";
const mockDb = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockReturnThis(),
onConflictDoUpdate: vi.fn().mockReturnThis(),
};
beforeEach(() => {
vi.clearAllMocks();
(database as ReturnType<typeof vi.fn>).mockReturnValue(mockDb);
// Reset chain to return mock itself (supports fluent chaining)
mockDb.select.mockReturnValue(mockDb);
mockDb.from.mockReturnValue(mockDb);
mockDb.where.mockReturnValue(mockDb);
mockDb.innerJoin.mockReturnValue(mockDb);
mockDb.orderBy.mockReturnValue(mockDb);
mockDb.insert.mockReturnValue(mockDb);
mockDb.values.mockReturnValue(mockDb);
mockDb.onConflictDoUpdate.mockResolvedValue(undefined);
});
describe("getSurfaceEloMap", () => {
it("returns an empty Map when no records exist", async () => {
mockDb.where.mockResolvedValue([]);
const result = await getSurfaceEloMap("season-1");
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(0);
});
it("returns a Map keyed by participantId", async () => {
mockDb.where.mockResolvedValue([
{ participantId: "p1", worldRanking: 1, eloHard: 1800, eloClay: 1700, eloGrass: 1600 },
{ participantId: "p2", worldRanking: null, eloHard: null, eloClay: 1750, eloGrass: null },
]);
const result = await getSurfaceEloMap("season-1");
expect(result.size).toBe(2);
expect(result.get("p1")).toEqual({ worldRanking: 1, eloHard: 1800, eloClay: 1700, eloGrass: 1600 });
expect(result.get("p2")).toEqual({ worldRanking: null, eloHard: null, eloClay: 1750, eloGrass: null });
});
it("preserves null surface Elos (does not coerce to 0)", async () => {
mockDb.where.mockResolvedValue([
{ participantId: "p1", worldRanking: null, eloHard: null, eloClay: null, eloGrass: null },
]);
const result = await getSurfaceEloMap("season-1");
const entry = result.get("p1");
expect(entry?.worldRanking).toBeNull();
expect(entry?.eloHard).toBeNull();
expect(entry?.eloClay).toBeNull();
expect(entry?.eloGrass).toBeNull();
});
});
describe("batchUpsertSurfaceElos", () => {
it("does nothing when given an empty array", async () => {
await batchUpsertSurfaceElos([]);
expect(mockDb.insert).not.toHaveBeenCalled();
});
it("calls insert with the correct values", async () => {
const inputs = [
{ participantId: "p1", sportsSeasonId: "s1", worldRanking: 1, eloHard: 1800, eloClay: 1700, eloGrass: 1600 },
{ participantId: "p2", sportsSeasonId: "s1", worldRanking: null, eloHard: null, eloClay: 1750, eloGrass: undefined },
];
await batchUpsertSurfaceElos(inputs);
expect(mockDb.insert).toHaveBeenCalledOnce();
expect(mockDb.values).toHaveBeenCalledOnce();
const passedValues = mockDb.values.mock.calls[0][0] as Array<{
participantId: string;
eloHard: number | null;
eloClay: number | null;
eloGrass: number | null;
}>;
expect(passedValues).toHaveLength(2);
expect(passedValues[0].participantId).toBe("p1");
expect(passedValues[0].eloHard).toBe(1800);
expect(passedValues[1].eloHard).toBeNull();
expect(passedValues[1].eloGrass).toBeNull(); // undefined → null
});
it("uses onConflictDoUpdate for upsert behaviour", async () => {
await batchUpsertSurfaceElos([
{ participantId: "p1", sportsSeasonId: "s1", worldRanking: 5, eloHard: 2000 },
]);
expect(mockDb.onConflictDoUpdate).toHaveBeenCalledOnce();
});
});
describe("getSurfaceElosForSeason", () => {
it("returns records joined with participant names", async () => {
mockDb.orderBy.mockResolvedValue([
{
id: "rec-1",
participantId: "p1",
sportsSeasonId: "s1",
worldRanking: 1,
eloHard: 1800,
eloClay: 1700,
eloGrass: 1600,
updatedAt: new Date("2025-01-01"),
participantName: "Carlos Alcaraz",
},
]);
const result = await getSurfaceElosForSeason("s1");
expect(result).toHaveLength(1);
expect(result[0].participantName).toBe("Carlos Alcaraz");
expect(result[0].eloHard).toBe(1800);
});
it("returns empty array when no Elo records exist", async () => {
mockDb.orderBy.mockResolvedValue([]);
const result = await getSurfaceElosForSeason("s1");
expect(result).toEqual([]);
});
});

129
app/models/surface-elo.ts Normal file
View file

@ -0,0 +1,129 @@
/**
* Model for Participant Surface Elos
*
* Manages surface-specific Elo ratings for tennis (and future surface-based sports).
* Each participant in a sports season can have separate Elo ratings for hard,
* clay, and grass courts.
*/
import { database } from "~/database/context";
import { participantSurfaceElos, participants } from "~/database/schema";
import { eq, sql } from "drizzle-orm";
export type CourtSurface = "hard" | "clay" | "grass";
export interface SurfaceEloRecord {
id: string;
participantId: string;
sportsSeasonId: string;
worldRanking: number | null;
eloHard: number | null;
eloClay: number | null;
eloGrass: number | null;
updatedAt: Date;
}
export interface SurfaceEloWithName extends SurfaceEloRecord {
participantName: string;
}
export interface SurfaceEloInput {
participantId: string;
sportsSeasonId: string;
worldRanking?: number | null;
eloHard?: number | null;
eloClay?: number | null;
eloGrass?: number | null;
}
/**
* Get all surface Elo records for a sports season, joined with participant names.
* Returns one record per participant (participants with no Elo record are excluded).
*/
export async function getSurfaceElosForSeason(
sportsSeasonId: string
): Promise<SurfaceEloWithName[]> {
const db = database();
const rows = await db
.select({
id: participantSurfaceElos.id,
participantId: participantSurfaceElos.participantId,
sportsSeasonId: participantSurfaceElos.sportsSeasonId,
worldRanking: participantSurfaceElos.worldRanking,
eloHard: participantSurfaceElos.eloHard,
eloClay: participantSurfaceElos.eloClay,
eloGrass: participantSurfaceElos.eloGrass,
updatedAt: participantSurfaceElos.updatedAt,
participantName: participants.name,
})
.from(participantSurfaceElos)
.innerJoin(participants, eq(participantSurfaceElos.participantId, participants.id))
.where(eq(participantSurfaceElos.sportsSeasonId, sportsSeasonId))
.orderBy(participants.name);
return rows;
}
/**
* Upsert surface Elo ratings for a batch of participants.
* Uses INSERT ... ON CONFLICT DO UPDATE so all three surface columns are
* overwritten atomically the admin always submits all three values.
*/
export async function batchUpsertSurfaceElos(
inputs: SurfaceEloInput[]
): Promise<void> {
if (inputs.length === 0) return;
const db = database();
const now = new Date();
await db
.insert(participantSurfaceElos)
.values(
inputs.map(({ participantId, sportsSeasonId, worldRanking, eloHard, eloClay, eloGrass }) => ({
participantId,
sportsSeasonId,
worldRanking: worldRanking ?? null,
eloHard: eloHard ?? null,
eloClay: eloClay ?? null,
eloGrass: eloGrass ?? null,
updatedAt: now,
}))
)
.onConflictDoUpdate({
target: [participantSurfaceElos.participantId, participantSurfaceElos.sportsSeasonId],
set: {
worldRanking: sql`excluded.world_ranking`,
eloHard: sql`excluded.elo_hard`,
eloClay: sql`excluded.elo_clay`,
eloGrass: sql`excluded.elo_grass`,
updatedAt: sql`excluded.updated_at`,
},
});
}
/**
* Returns a Map from participantId to surface Elos for use in the simulator.
* Participants with no record are absent from the map (simulator falls back to 1500).
*/
export async function getSurfaceEloMap(
sportsSeasonId: string
): Promise<Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null }>> {
const db = database();
const rows = await db
.select({
participantId: participantSurfaceElos.participantId,
worldRanking: participantSurfaceElos.worldRanking,
eloHard: participantSurfaceElos.eloHard,
eloClay: participantSurfaceElos.eloClay,
eloGrass: participantSurfaceElos.eloGrass,
})
.from(participantSurfaceElos)
.where(eq(participantSurfaceElos.sportsSeasonId, sportsSeasonId));
return new Map(rows.map((r) => [r.participantId, {
worldRanking: r.worldRanking,
eloHard: r.eloHard,
eloClay: r.eloClay,
eloGrass: r.eloGrass,
}]));
}

View file

@ -85,6 +85,10 @@ export default [
"sports-seasons/:id/elo-ratings",
"routes/admin.sports-seasons.$id.elo-ratings.tsx"
),
route(
"sports-seasons/:id/surface-elo",
"routes/admin.sports-seasons.$id.surface-elo.tsx"
),
route(
"sports-seasons/:id/standings",
"routes/admin.sports-seasons.$id.standings.tsx"

View file

@ -0,0 +1,686 @@
import { Form, redirect, useLoaderData, useActionData, useNavigation, useFetcher } from 'react-router';
import type { Route } from './+types/admin.sports-seasons.$id.surface-elo';
import { logger } from '~/lib/logger';
import { findSportsSeasonById, updateSportsSeason } from '~/models/sports-season';
import { findParticipantsBySportsSeasonId, createParticipant } from '~/models/participant';
import {
batchUpsertParticipantEVs,
} from '~/models/participant-expected-value';
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
import { getSurfaceElosForSeason, batchUpsertSurfaceElos } from '~/models/surface-elo';
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
import { calculateEV, type ScoringRules } from '~/services/ev-calculator';
import { recalculateStandings } from '~/models/scoring-calculator';
import { database } from '~/database/context';
import * as schema from '~/database/schema';
import { eq } from 'drizzle-orm';
import { Button } from '~/components/ui/button';
import { Input } from '~/components/ui/input';
import { Label } from '~/components/ui/label';
import { Textarea } from '~/components/ui/textarea';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '~/components/ui/card';
import { useEffect, useRef, useState } from 'react';
import { Loader2, CheckCircle2, AlertCircle, UserPlus } from 'lucide-react';
const DEFAULT_SCORING_RULES: ScoringRules = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 45,
pointsFor4th: 45,
pointsFor5th: 20,
pointsFor6th: 20,
pointsFor7th: 20,
pointsFor8th: 20,
};
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Surface Elo — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
}
export async function loader({ params }: Route.LoaderArgs) {
const sportsSeasonId = params.id;
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
if (!sportsSeason) {
throw new Response('Sports season not found', { status: 404 });
}
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
const existingElos = await getSurfaceElosForSeason(sportsSeasonId);
const eloMap: Record<string, { ranking: number | null; hard: number | null; clay: number | null; grass: number | null }> = {};
for (const r of existingElos) {
eloMap[r.participantId] = {
ranking: r.worldRanking,
hard: r.eloHard,
clay: r.eloClay,
grass: r.eloGrass,
};
}
return { sportsSeason, participants, eloMap };
}
type ActionData =
| { intent: 'create-participant'; success: true; participant: { id: string; name: string } }
| { intent: 'create-participant'; success: false; message: string }
| { success?: boolean; message?: string };
export async function action({ request, params }: Route.ActionArgs) {
const sportsSeasonId = params.id;
const formData = await request.formData();
const intent = formData.get('intent');
// Handle participant creation separately
if (intent === 'create-participant') {
const name = (formData.get('name') as string)?.trim();
if (!name) {
return { intent: 'create-participant', success: false, message: 'Name is required' } satisfies ActionData;
}
const participant = await createParticipant({ sportsSeasonId, name });
return {
intent: 'create-participant',
success: true,
participant: { id: participant.id, name: participant.name },
} satisfies ActionData;
}
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
if (!sportsSeason) {
return { success: false, message: 'Sports season not found' };
}
if (!sportsSeason.sport?.simulatorType) {
return { success: false, message: 'This sport has no simulator type configured.' };
}
if (sportsSeason.simulationStatus === 'running') {
return { success: false, message: 'A simulation is already running. Please wait.' };
}
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
// Parse surface Elos from form fields: ranking_{id}, hard_{id}, clay_{id}, grass_{id}
const surfaceEloInputs = participants
.map((p) => {
const ranking = parseIntOrNull(formData.get(`ranking_${p.id}`) as string);
const hard = parseIntOrNull(formData.get(`hard_${p.id}`) as string);
const clay = parseIntOrNull(formData.get(`clay_${p.id}`) as string);
const grass = parseIntOrNull(formData.get(`grass_${p.id}`) as string);
return { participantId: p.id, sportsSeasonId, worldRanking: ranking, eloHard: hard, eloClay: clay, eloGrass: grass };
})
.filter((r) => r.worldRanking !== null || r.eloHard !== null || r.eloClay !== null || r.eloGrass !== null);
if (surfaceEloInputs.length === 0) {
return { success: false, message: 'Please enter at least one Elo rating.' };
}
await batchUpsertSurfaceElos(surfaceEloInputs);
// Auto-run simulation
await updateSportsSeason(sportsSeasonId, { simulationStatus: 'running' });
try {
const simulator = getSimulator(sportsSeason.sport.simulatorType as SimulatorType);
const results = await simulator.simulate(sportsSeasonId);
if (results.length === 0) {
throw new Error('Simulation returned no results.');
}
const simulatedIds = new Set(results.map((r) => r.participantId));
const ZERO_PROBS = {
probFirst: 0, probSecond: 0, probThird: 0, probFourth: 0,
probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0,
};
const evInputs = [
...results.map((r) => ({
participantId: r.participantId,
sportsSeasonId,
probabilities: r.probabilities,
scoringRules: DEFAULT_SCORING_RULES,
source: 'elo_simulation' as const,
})),
...participants
.filter((p) => !simulatedIds.has(p.id))
.map((p) => ({
participantId: p.id,
sportsSeasonId,
probabilities: ZERO_PROBS,
scoringRules: DEFAULT_SCORING_RULES,
source: 'elo_simulation' as const,
})),
];
await batchUpsertParticipantEVs(evInputs);
// Refresh projected points in team standings
const seasonSports = await database().query.seasonSports.findMany({
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
});
await Promise.all(seasonSports.map(({ seasonId }) => recalculateStandings(seasonId)));
// EV snapshot
const today = new Date().toISOString().slice(0, 10);
await batchUpsertParticipantEvSnapshots(
results.map((r) => ({
participantId: r.participantId,
sportsSeasonId,
snapshotDate: today,
probFirst: r.probabilities.probFirst,
probSecond: r.probabilities.probSecond,
probThird: r.probabilities.probThird,
probFourth: r.probabilities.probFourth,
probFifth: r.probabilities.probFifth,
probSixth: r.probabilities.probSixth,
probSeventh: r.probabilities.probSeventh,
probEighth: r.probabilities.probEighth,
calculatedEV: calculateEV(r.probabilities, DEFAULT_SCORING_RULES),
source: r.source,
}))
);
await updateSportsSeason(sportsSeasonId, { simulationStatus: 'idle' });
} catch (error) {
await updateSportsSeason(sportsSeasonId, { simulationStatus: 'failed' });
logger.error('Error running tennis simulation:', error);
return {
success: false,
message: error instanceof Error ? error.message : 'Simulation failed',
};
}
return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`);
}
function parseIntOrNull(val: string | null | undefined): number | null {
if (!val || val.trim() === '') return null;
const n = Math.round(parseFloat(val.trim()));
return isNaN(n) || n <= 0 ? null : n;
}
function normalizeName(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ').trim();
}
// Bigram Dice coefficient for fuzzy name matching
function bigrams(s: string): string[] {
const result: string[] = [];
for (let i = 0; i < s.length - 1; i++) result.push(s.slice(i, i + 2));
return result;
}
function diceCoefficient(a: string, b: string): number {
if (a === b) return 1;
if (a.length < 2 || b.length < 2) return 0;
const bigA = bigrams(a);
const bigB = new Set(bigrams(b));
const intersection = bigA.filter((bg) => bigB.has(bg)).length;
return (2 * intersection) / (bigA.length + bigB.size);
}
type EloValues = Record<string, { ranking: string; hard: string; clay: string; grass: string }>;
interface MatchedItem {
participantId: string;
name: string;
ranking: number;
hard: number;
clay: number;
grass: number;
inputName: string;
}
interface Suggestion {
participantId: string;
name: string;
score: number;
}
interface UnmatchedItem {
inputName: string;
ranking: number;
hard: number;
clay: number;
grass: number;
suggestions: Suggestion[];
}
interface ParseResults {
matched: MatchedItem[];
unmatched: UnmatchedItem[];
}
type LocalParticipant = { id: string; name: string };
export default function AdminSportsSeasonSurfaceElo() {
const { sportsSeason, participants: loaderParticipants, eloMap } = useLoaderData<typeof loader>();
const actionData = useActionData<ActionData>();
const navigation = useNavigation();
const createFetcher = useFetcher<ActionData>();
const [localParticipants, setLocalParticipants] = useState<LocalParticipant[]>(loaderParticipants);
const [eloValues, setEloValues] = useState<EloValues>(() => {
const initial: EloValues = {};
loaderParticipants.forEach((p) => {
const existing = eloMap[p.id];
initial[p.id] = {
ranking: existing !== undefined && existing.ranking !== null ? String(existing.ranking) : '',
hard: existing !== undefined && existing.hard !== null ? String(existing.hard) : '',
clay: existing !== undefined && existing.clay !== null ? String(existing.clay) : '',
grass: existing !== undefined && existing.grass !== null ? String(existing.grass) : '',
};
});
return initial;
});
// Bulk import state: "Player Name, ranking, hardElo, clayElo, grassElo" one per line
const [bulkText, setBulkText] = useState('');
const [parseResults, setParseResults] = useState<ParseResults | null>(null);
// Store pending Elo values for participants being created
const pendingElosByName = useRef<Map<string, { ranking: number; hard: number; clay: number; grass: number }>>(new Map());
// When a participant is created, apply their Elos and remove from unmatched
useEffect(() => {
if (
createFetcher.state !== 'idle' ||
!createFetcher.data ||
!('intent' in createFetcher.data) ||
createFetcher.data.intent !== 'create-participant' ||
!createFetcher.data.success
) return;
const { participant } = createFetcher.data as Extract<ActionData, { intent: 'create-participant'; success: true }>;
// Add to local participants list
setLocalParticipants((prev) => {
if (prev.some((p) => p.id === participant.id)) return prev;
return [...prev, participant];
});
// Apply pending Elo values
const pending = pendingElosByName.current.get(participant.name);
if (pending) {
setEloValues((prev) => ({
...prev,
[participant.id]: {
ranking: String(pending.ranking),
hard: String(pending.hard),
clay: String(pending.clay),
grass: String(pending.grass),
},
}));
pendingElosByName.current.delete(participant.name);
}
// Remove from unmatched
setParseResults((prev) => {
if (!prev) return prev;
return {
...prev,
unmatched: prev.unmatched.filter((u) => u.inputName !== participant.name),
};
});
}, [createFetcher.state, createFetcher.data]);
function findParticipantMatch(inputName: string, pool: LocalParticipant[]) {
const normalizedInput = normalizeName(inputName);
const normalized = pool.map((p) => ({ p, n: normalizeName(p.name) }));
const exact = normalized.find(({ n }) => n === normalizedInput);
if (exact) return exact.p;
const contains = normalized.find(({ n }) => n.includes(normalizedInput) || normalizedInput.includes(n));
if (contains) return contains.p;
const inputWords = normalizedInput.split(' ').filter((w) => w.length > 2);
const overlap = normalized.find(({ n }) => {
const pWords = n.split(' ').filter((w) => w.length > 2);
const shared = inputWords.filter((w) => pWords.includes(w));
return shared.length > 0 && shared.length >= Math.min(inputWords.length, pWords.length) * 0.5;
});
return overlap?.p ?? null;
}
function getFuzzySuggestions(inputName: string, pool: LocalParticipant[], exclude: Set<string>): Suggestion[] {
const normalizedInput = normalizeName(inputName);
return pool
.filter((p) => !exclude.has(p.id))
.map((p) => ({ participantId: p.id, name: p.name, score: diceCoefficient(normalizedInput, normalizeName(p.name)) }))
.filter((s) => s.score >= 0.3)
.toSorted((a, b) => b.score - a.score)
.slice(0, 3);
}
function parseBulkText() {
const lines = bulkText.split('\n');
const matched: MatchedItem[] = [];
const unmatched: UnmatchedItem[] = [];
const seen = new Set<string>();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = /^(.+?)[,\t]\s*(\d{1,4})[,\t]\s*(\d{3,5}(?:\.\d+)?)[,\t]\s*(\d{3,5}(?:\.\d+)?)[,\t]\s*(\d{3,5}(?:\.\d+)?)\s*$/.exec(trimmed);
if (!match) continue;
const inputName = match[1].trim();
const ranking = parseInt(match[2], 10);
const hard = Math.round(parseFloat(match[3]));
const clay = Math.round(parseFloat(match[4]));
const grass = Math.round(parseFloat(match[5]));
const participant = findParticipantMatch(inputName, localParticipants);
if (participant && !seen.has(participant.id)) {
seen.add(participant.id);
matched.push({ participantId: participant.id, name: participant.name, ranking, hard, clay, grass, inputName });
} else {
// No match found, or the matched participant was already claimed by an earlier line
const suggestions = getFuzzySuggestions(inputName, localParticipants, seen);
unmatched.push({ inputName, ranking, hard, clay, grass, suggestions });
}
}
setParseResults({ matched, unmatched });
}
function assignSuggestion(item: UnmatchedItem, suggestion: Suggestion) {
setParseResults((prev) => {
if (!prev) return prev;
return {
matched: [
...prev.matched,
{
participantId: suggestion.participantId,
name: suggestion.name,
ranking: item.ranking,
hard: item.hard,
clay: item.clay,
grass: item.grass,
inputName: item.inputName,
},
],
unmatched: prev.unmatched.filter((u) => u.inputName !== item.inputName),
};
});
}
function handleCreateParticipant(item: UnmatchedItem) {
pendingElosByName.current.set(item.inputName, {
ranking: item.ranking,
hard: item.hard,
clay: item.clay,
grass: item.grass,
});
const fd = new FormData();
fd.set('intent', 'create-participant');
fd.set('name', item.inputName);
createFetcher.submit(fd, { method: 'post' });
}
function applyMatches() {
if (!parseResults) return;
const newValues = { ...eloValues };
for (const m of parseResults.matched) {
newValues[m.participantId] = {
ranking: String(m.ranking),
hard: String(m.hard),
clay: String(m.clay),
grass: String(m.grass),
};
}
setEloValues(newValues);
setParseResults(null);
setBulkText('');
}
const setField = (participantId: string, field: 'ranking' | 'hard' | 'clay' | 'grass', value: string) => {
setEloValues((prev) => ({
...prev,
[participantId]: { ...prev[participantId], [field]: value },
}));
};
const isSubmitting = navigation.state === 'submitting';
return (
<div className="container mx-auto py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">Surface Elo Ratings</h1>
<p className="text-muted-foreground">
{sportsSeason.sport.name} {sportsSeason.name}
</p>
</div>
{/* Bulk Import */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Bulk Import</CardTitle>
<CardDescription>
Paste ratings one per line. Format:{' '}
<code>Player Name, ranking, hardElo, clayElo, grassElo</code>. Player names are
fuzzy-matched to participants. Rankings are ATP/WTA (1 = world #1). Typical tennis
Elo ranges: 14002200.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Textarea
placeholder={`Carlos Alcaraz, 1, 2180, 2200, 2100\nJannik Sinner, 2, 2190, 2050, 2080\nNovak Djokovic, 3, 2120, 2150, 2090`}
value={bulkText}
onChange={(e) => { setBulkText(e.target.value); setParseResults(null); }}
rows={8}
className="font-mono text-sm"
/>
<Button type="button" variant="outline" onClick={parseBulkText} disabled={!bulkText.trim()}>
Parse Ratings
</Button>
{parseResults && (
<div className="space-y-3">
{parseResults.matched.length > 0 && (
<div>
<div className="flex items-center gap-2 text-sm font-medium text-emerald-400 mb-2">
<CheckCircle2 className="h-4 w-4" />
Matched ({parseResults.matched.length})
</div>
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/10 divide-y divide-emerald-500/20 text-sm">
{parseResults.matched.map((m) => (
<div key={m.participantId} className="flex justify-between px-3 py-1.5 gap-4">
<span className="text-muted-foreground">{m.inputName}</span>
<span className="font-medium">
{m.name} &rarr; #{m.ranking} H:{m.hard} / C:{m.clay} / G:{m.grass}
</span>
</div>
))}
</div>
</div>
)}
{parseResults.unmatched.length > 0 && (
<div>
<div className="flex items-center gap-2 text-sm font-medium text-amber-400 mb-2">
<AlertCircle className="h-4 w-4" />
Not matched ({parseResults.unmatched.length})
</div>
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 divide-y divide-amber-500/20 text-sm">
{parseResults.unmatched.map((u) => {
const isCreating =
createFetcher.state !== 'idle' &&
pendingElosByName.current.has(u.inputName);
return (
<div key={u.inputName} className="px-3 py-2 space-y-2">
<div className="font-medium text-amber-300">{u.inputName}</div>
{u.suggestions.length > 0 ? (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Did you mean</div>
{u.suggestions.map((s) => (
<div key={s.participantId} className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="h-6 text-xs px-2"
onClick={() => assignSuggestion(u, s)}
>
Use this
</Button>
<span className="text-muted-foreground">{s.name}</span>
<span className="text-xs text-muted-foreground/60">
({Math.round(s.score * 100)}% match)
</span>
</div>
))}
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 text-xs px-2 text-amber-400 hover:text-amber-300"
disabled={isCreating}
onClick={() => handleCreateParticipant(u)}
>
{isCreating ? (
<><Loader2 className="mr-1 h-3 w-3 animate-spin" /> Creating</>
) : (
<><UserPlus className="mr-1 h-3 w-3" /> Create new participant</>
)}
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">No close matches found.</span>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 text-xs px-2"
disabled={isCreating}
onClick={() => handleCreateParticipant(u)}
>
{isCreating ? (
<><Loader2 className="mr-1 h-3 w-3 animate-spin" /> Creating</>
) : (
<><UserPlus className="mr-1 h-3 w-3" /> Create participant</>
)}
</Button>
</div>
)}
</div>
);
})}
</div>
</div>
)}
{parseResults.matched.length > 0 && (
<Button type="button" onClick={applyMatches}>
Apply {parseResults.matched.length} matched ratings to form
</Button>
)}
</div>
)}
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<Card>
<CardHeader>
<CardTitle>Player Surface Elo Ratings</CardTitle>
<CardDescription>
Enter Elo ratings per surface. Saving will run the simulation and update expected
values. Leave blank for any surface if you don't have a rating a fallback of
1500 will be used.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
<div className="space-y-1">
{/* Header row */}
<div className="grid grid-cols-5 gap-3 text-xs font-semibold text-muted-foreground uppercase tracking-wide pb-1 border-b">
<span>Player</span>
<span>Rank</span>
<span>Hard</span>
<span>Clay</span>
<span>Grass</span>
</div>
{localParticipants.map((p) => (
<div key={p.id} className="grid grid-cols-5 gap-3 items-center py-1">
<Label htmlFor={`ranking_${p.id}`} className="truncate text-sm">
{p.name}
</Label>
<Input
type="number"
id={`ranking_${p.id}`}
name={`ranking_${p.id}`}
placeholder="1"
value={eloValues[p.id]?.ranking ?? ''}
onChange={(e) => setField(p.id, 'ranking', e.target.value)}
className="h-8 text-sm"
/>
{(['hard', 'clay', 'grass'] as const).map((surface) => (
<Input
key={surface}
type="number"
name={`${surface}_${p.id}`}
placeholder="1750"
value={eloValues[p.id]?.[surface] ?? ''}
onChange={(e) => setField(p.id, surface, e.target.value)}
className="h-8 text-sm"
/>
))}
</div>
))}
</div>
{actionData && !('intent' in actionData) && !actionData.success && actionData.message && (
<div className="text-sm text-destructive">{actionData.message}</div>
)}
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isSubmitting ? 'Saving & Running Simulation...' : 'Save Ratings & Run Simulation'}
</Button>
</Form>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>How It Works</CardTitle>
</CardHeader>
<CardContent className="text-sm space-y-2 text-muted-foreground">
<ol className="list-decimal list-inside space-y-2">
<li>Enter surface-specific Elo ratings for all players</li>
<li>For each of 10,000 simulations, simulate all 4 Grand Slams using the appropriate surface Elo</li>
<li>Top 32 by world ranking are seeded; remaining 96 are drawn randomly</li>
<li>Per-match win probability: p = 1 / (1 + 10^(ΔElo/400))</li>
<li>QP per round: Winner 20, Finalist 14, SF 9, QF 4, R16 1.5</li>
<li>Players are ranked by total QP across all 4 majors</li>
<li>Placement probabilities (1st8th) determine expected fantasy value</li>
</ol>
<div className="mt-4 text-xs space-y-1">
<div className="font-medium text-foreground">Grand Slam surfaces:</div>
<div>Australian Open Hard</div>
<div>French Open Clay</div>
<div>Wimbledon Grass</div>
<div>US Open Hard</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View file

@ -537,6 +537,16 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
<Calculator className="mr-2 h-4 w-4" />
Elo Ratings
</Button>
{sportsSeason.sport?.simulatorType === "tennis_qualifying_points" && (
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/surface-elo`)}
>
<Calculator className="mr-2 h-4 w-4" />
Surface Elo
</Button>
)}
{simulatorInfo && (
<Form method="post" action={`/admin/sports-seasons/${sportsSeason.id}/simulate`}>
<Button

View file

@ -0,0 +1,281 @@
import { describe, it, expect } from "vitest";
import { eloWinProb, buildDraw, simulateMajor } from "../tennis-simulator";
// ─── eloWinProb ───────────────────────────────────────────────────────────────
describe("eloWinProb", () => {
it("returns 0.5 for equal Elo", () => {
expect(eloWinProb(1800, 1800)).toBeCloseTo(0.5, 5);
expect(eloWinProb(1500, 1500)).toBeCloseTo(0.5, 5);
});
it("returns > 0.5 for positive Elo advantage, < 0.5 for negative", () => {
expect(eloWinProb(2000, 1600)).toBeGreaterThan(0.5);
expect(eloWinProb(1600, 2000)).toBeLessThan(0.5);
});
it("is symmetric: eloWinProb(a, b) + eloWinProb(b, a) = 1", () => {
expect(eloWinProb(2000, 1600) + eloWinProb(1600, 2000)).toBeCloseTo(1.0, 5);
expect(eloWinProb(1837, 1756) + eloWinProb(1756, 1837)).toBeCloseTo(1.0, 5);
});
it("returns value in (0, 1)", () => {
expect(eloWinProb(3000, 1000)).toBeLessThan(1);
expect(eloWinProb(3000, 1000)).toBeGreaterThan(0);
expect(eloWinProb(1000, 3000)).toBeLessThan(1);
expect(eloWinProb(1000, 3000)).toBeGreaterThan(0);
});
it("larger Elo gap → higher win probability (monotonic)", () => {
const p200 = eloWinProb(1900, 1700);
const p400 = eloWinProb(2100, 1700);
expect(p400).toBeGreaterThan(p200);
});
it("400-point gap gives ~91% win probability (standard Elo)", () => {
expect(eloWinProb(2200, 1800)).toBeCloseTo(0.909, 2);
});
});
// ─── buildDraw ────────────────────────────────────────────────────────────────
function makeIds(n: number): string[] {
return Array.from({ length: n }, (_, i) => `p${String(i + 1).padStart(3, "0")}`);
}
function makeEloMap(
ids: string[],
elos: number[],
rankings?: (number | null)[]
): Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null }> {
return new Map(ids.map((id, i) => [id, {
worldRanking: rankings ? (rankings[i] ?? null) : i + 1, // default: rank by index (1-indexed)
eloHard: elos[i],
eloClay: elos[i],
eloGrass: elos[i],
}]));
}
describe("buildDraw", () => {
const IDS = makeIds(128);
it("returns exactly 128 slots", () => {
const eloMap = makeEloMap(IDS, IDS.map((_, i) => 2000 - i));
expect(buildDraw(IDS, eloMap)).toHaveLength(128);
});
it("contains every participant exactly once", () => {
const eloMap = makeEloMap(IDS, IDS.map((_, i) => 2000 - i));
const draw = buildDraw(IDS, eloMap);
expect(new Set(draw).size).toBe(128);
for (const id of IDS) {
expect(draw).toContain(id);
}
});
it("places seed 1 (highest Elo) in slot 0", () => {
const elos = IDS.map((_, i) => 2000 - i); // descending, p001 is best
const eloMap = makeEloMap(IDS, elos);
const draw = buildDraw(IDS, eloMap);
expect(draw[0]).toBe("p001"); // seed 1 always goes to slot 0
});
it("places seed 2 (second-highest Elo) in slot 64", () => {
const elos = IDS.map((_, i) => 2000 - i);
const eloMap = makeEloMap(IDS, elos);
const draw = buildDraw(IDS, eloMap);
expect(draw[64]).toBe("p002"); // seed 2 always goes to slot 64
});
it("seeds 1 and 2 are in opposite halves (slots 063 and 64127)", () => {
const elos = IDS.map((_, i) => 2000 - i);
const eloMap = makeEloMap(IDS, elos);
// Run multiple times to confirm it's not random
for (let i = 0; i < 10; i++) {
const draw = buildDraw(IDS, eloMap);
const seed1Slot = draw.indexOf("p001");
const seed2Slot = draw.indexOf("p002");
const seed1InTopHalf = seed1Slot < 64;
const seed2InTopHalf = seed2Slot < 64;
expect(seed1InTopHalf).not.toBe(seed2InTopHalf);
}
});
it("seeds by worldRanking regardless of surface (ATP/WTA ranking used for all majors)", () => {
// p001 = world #2, p002 = world #1 — p002 should always be seed 1 regardless of surface Elo
const eloMap = new Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null }>();
eloMap.set("p001", { worldRanking: 2, eloHard: 2500, eloClay: 2500, eloGrass: 2500 }); // world #2 (high Elo)
eloMap.set("p002", { worldRanking: 1, eloHard: 1400, eloClay: 1400, eloGrass: 1400 }); // world #1 (low Elo)
for (let i = 2; i < 128; i++) {
eloMap.set(IDS[i], { worldRanking: i + 1, eloHard: 1500, eloClay: 1500, eloGrass: 1500 });
}
// p002 (world #1) should always be seed 1 → slot 0, regardless of Elo
expect(buildDraw(IDS, eloMap)[0]).toBe("p002");
});
it("falls back to Elo 1500 for players not in the Elo map", () => {
// Create a map with only 1 entry; remaining 127 get fallback 1500
const partialMap = new Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null }>();
partialMap.set("p001", { worldRanking: 1, eloHard: 2000, eloClay: null, eloGrass: null });
// Should not throw
expect(() => buildDraw(IDS, partialMap)).not.toThrow();
const draw = buildDraw(IDS, partialMap);
expect(draw).toHaveLength(128);
});
});
// ─── simulateMajor ────────────────────────────────────────────────────────────
describe("simulateMajor", () => {
const IDS = makeIds(128);
it("returns one QP result per participant in the draw", () => {
const elos = IDS.map(() => 1500);
const eloMap = makeEloMap(IDS, elos);
const draw = buildDraw(IDS, eloMap);
const results = simulateMajor(draw, eloMap, "hard");
expect(results).toHaveLength(128);
const ids = results.map((r) => r.participantId);
expect(new Set(ids).size).toBe(128);
});
it("QP values are non-negative", () => {
const elos = IDS.map(() => 1500);
const eloMap = makeEloMap(IDS, elos);
const draw = buildDraw(IDS, eloMap);
const results = simulateMajor(draw, eloMap, "hard");
for (const r of results) {
expect(r.qp).toBeGreaterThanOrEqual(0);
}
});
it("exactly one winner with 20 QP, one finalist with 14 QP", () => {
const elos = IDS.map(() => 1500);
const eloMap = makeEloMap(IDS, elos);
const draw = buildDraw(IDS, eloMap);
const results = simulateMajor(draw, eloMap, "hard");
const winners = results.filter((r) => r.qp === 20);
const finalists = results.filter((r) => r.qp === 14);
expect(winners).toHaveLength(1);
expect(finalists).toHaveLength(1);
});
it("exactly two SF losers with 9 QP", () => {
const elos = IDS.map(() => 1500);
const eloMap = makeEloMap(IDS, elos);
const draw = buildDraw(IDS, eloMap);
const results = simulateMajor(draw, eloMap, "hard");
expect(results.filter((r) => r.qp === 9)).toHaveLength(2);
});
it("exactly four QF losers with 4 QP", () => {
const elos = IDS.map(() => 1500);
const eloMap = makeEloMap(IDS, elos);
const draw = buildDraw(IDS, eloMap);
const results = simulateMajor(draw, eloMap, "hard");
expect(results.filter((r) => r.qp === 4)).toHaveLength(4);
});
it("exactly eight R16 losers with 1.5 QP", () => {
const elos = IDS.map(() => 1500);
const eloMap = makeEloMap(IDS, elos);
const draw = buildDraw(IDS, eloMap);
const results = simulateMajor(draw, eloMap, "hard");
expect(results.filter((r) => r.qp === 1.5)).toHaveLength(8);
});
it("remaining 112 players (R1R3 losers) earn 0 QP", () => {
const elos = IDS.map(() => 1500);
const eloMap = makeEloMap(IDS, elos);
const draw = buildDraw(IDS, eloMap);
const results = simulateMajor(draw, eloMap, "hard");
expect(results.filter((r) => r.qp === 0)).toHaveLength(112);
});
it("with a dominant player (Elo gap 1000+), they win the title almost always", () => {
// Give p001 an overwhelming Elo advantage
const dominantEloMap = new Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null }>();
dominantEloMap.set("p001", { worldRanking: 1, eloHard: 5000, eloClay: 5000, eloGrass: 5000 });
for (let i = 1; i < IDS.length; i++) {
dominantEloMap.set(IDS[i], { worldRanking: i + 1, eloHard: 1000, eloClay: 1000, eloGrass: 1000 });
}
let wins = 0;
const TRIALS = 200;
for (let i = 0; i < TRIALS; i++) {
const draw = buildDraw(IDS, dominantEloMap);
const results = simulateMajor(draw, dominantEloMap, "hard");
const p001 = results.find((r) => r.participantId === "p001");
if (p001?.qp === 20) wins++;
}
// With Elo 5000 vs 1000, win probability per match ≈ 99.99% → should win almost all trials
expect(wins).toBeGreaterThan(190);
});
it("uses the correct surface Elo for match win probability", () => {
// p001 dominates on clay but is average on hard; p002 is the opposite.
// With a 2000-point Elo gap, the dominant player wins every match.
const surfaceMap = new Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null }>();
surfaceMap.set("p001", { worldRanking: 1, eloHard: 1500, eloClay: 3500, eloGrass: 1500 });
surfaceMap.set("p002", { worldRanking: 2, eloHard: 3500, eloClay: 1500, eloGrass: 1500 });
for (let i = 2; i < 128; i++) {
surfaceMap.set(IDS[i], { worldRanking: i + 1, eloHard: 1000, eloClay: 1000, eloGrass: 1000 });
}
let p001ClayWins = 0;
let p002HardWins = 0;
const TRIALS = 50;
for (let i = 0; i < TRIALS; i++) {
const draw = buildDraw(IDS, surfaceMap);
const clayResults = simulateMajor(draw, surfaceMap, "clay");
const hardResults = simulateMajor(draw, surfaceMap, "hard");
if (clayResults.find((r) => r.participantId === "p001")?.qp === 20) p001ClayWins++;
if (hardResults.find((r) => r.participantId === "p002")?.qp === 20) p002HardWins++;
}
// With Elo 3500 vs ≤1500, each player should win essentially every trial on their surface
expect(p001ClayWins).toBeGreaterThan(45); // p001 dominates clay
expect(p002HardWins).toBeGreaterThan(45); // p002 dominates hard
});
it("total QP distributed per major: 20+14+2*9+4*4+8*1.5 = 80 QP", () => {
const elos = IDS.map(() => 1500);
const eloMap = makeEloMap(IDS, elos);
const draw = buildDraw(IDS, eloMap);
const results = simulateMajor(draw, eloMap, "hard");
const total = results.reduce((sum, r) => sum + r.qp, 0);
// 20 + 14 + 9*2 + 4*4 + 1.5*8 = 20 + 14 + 18 + 16 + 12 = 80
expect(total).toBeCloseTo(80, 5);
});
});
// ─── TennisSimulator column-sum property ──────────────────────────────────────
// Full integration test via the simulate() method would require DB mocking.
// The column-sum guarantee is verified by the math:
// - In each simulation, exactly one player holds each rank 18.
// - Therefore count[rank] sums to NUM_SIMULATIONS across all players.
// - Dividing by NUM_SIMULATIONS gives column sums of exactly 1.0.
// This is verified indirectly by the simulateMajor + buildDraw tests above.
describe("QP accumulation ranking (unit)", () => {
it("seed 1 wins more often than a random player across many trials", () => {
const IDS = makeIds(128);
const elos = IDS.map((_, i) => 2000 - i * 3); // descending by seeding
const eloMap = makeEloMap(IDS, elos);
let seed1Wins = 0;
const TRIALS = 500;
for (let i = 0; i < TRIALS; i++) {
const draw = buildDraw(IDS, eloMap);
const results = simulateMajor(draw, eloMap, "hard");
const seed1 = results.find((r) => r.participantId === "p001");
if (seed1?.qp === 20) seed1Wins++;
}
// Seed 1 has ~3 points advantage per player; against 127 opponents should win
// substantially more than 1/128 ≈ 0.78% of the time
const winRate = seed1Wins / TRIALS;
expect(winRate).toBeGreaterThan(0.03); // > 3% (well above random 0.78%)
});
});

View file

@ -17,6 +17,7 @@ import { NBASimulator } from "./nba-simulator";
import { NHLSimulator } from "./nhl-simulator";
import { AFLSimulator } from "./afl-simulator";
import { SnookerSimulator } from "./snooker-simulator";
import { TennisSimulator } from "./tennis-simulator";
export const SIMULATOR_TYPES = [
"f1_standings",
@ -30,6 +31,7 @@ export const SIMULATOR_TYPES = [
"nhl_bracket",
"afl_bracket",
"snooker_bracket",
"tennis_qualifying_points",
] as const;
export type SimulatorType = typeof SIMULATOR_TYPES[number];
@ -98,6 +100,10 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
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(),
},
tennis_qualifying_points: {
info: { name: "Tennis Grand Slam Monte Carlo", description: "Simulates all 4 Grand Slam majors (128-player seeded bracket) using surface-specific Elo ratings. Accumulates qualifying points per round (tie-split applied) and ranks players by total QP across the season." },
create: () => new TennisSimulator(),
},
};
export function getSimulator(simulatorType: SimulatorType): Simulator {

View file

@ -0,0 +1,375 @@
/**
* Tennis Grand Slam Simulator
*
* Monte Carlo simulation of the 4 Grand Slam majors using surface-specific Elo
* ratings. Qualifying points (QP) are accumulated across all majors; the final
* QP totals determine fantasy placements (1st8th).
*
* Algorithm:
* 1. Load the 4 Grand Slam scoring events (type = major_tournament).
* 2. For complete majors, read actual qualifyingPointsAwarded from eventResults.
* 3. For incomplete majors, simulate the 128-player seeded bracket.
* 4. Accumulate QP per player across all 4 majors in each simulation.
* 5. Rank players by total QP; tally 1st8th placement counts.
* 6. Return normalized SimulationResult[].
*
* QP per round (tie-splitting pre-applied per the rules):
* Winner 20 QP
* Finalist 14 QP
* SF loser ×2 9 QP each ((10+8)/2)
* QF loser ×4 4 QP each ((5+5+3+3)/4)
* R16 loser ×8 1.5 QP each ((2+2+2+2+1+1+1+1)/8)
* Earlier losers 0 QP
*
* Seeded draw (128 players, top 32 seeded by ATP/WTA world ranking):
* Seed 1 slot 0 (top of top half)
* Seed 2 slot 64 (top of bottom half) can only meet seed 1 in final
* Seeds 34 slots 32, 96 (quarter tops), randomly drawn
* Seeds 58 slots 16, 48, 80, 112 (eighth tops), randomly drawn
* Seeds 916 slots 8, 24, 40, 56, 72, 88, 104, 120 (sixteenth tops), randomly drawn
* Seeds 1732 slots 4, 12, 20, 28, 36, 44, 52, 60, 68, 76, 84, 92, 100, 108, 116, 124, randomly drawn
* Remaining 96 all remaining slots, randomly placed
*
* Surface mapping (matched against scoring event names):
* "Australian Open" hard
* "French Open" / "Roland Garros" clay
* "Wimbledon" grass
* "US Open" hard
*/
import { database } from "~/database/context";
import { eq, and, inArray } from "drizzle-orm";
import * as schema from "~/database/schema";
import { getSurfaceEloMap } from "~/models/surface-elo";
import type { Simulator, SimulationResult } from "./types";
// ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 10_000;
/**
* Elo divisor for per-match win probability.
* Standard chess Elo uses 400. A single tennis match is modelled as one Elo
* contest (no multi-game Bernoulli model needed), so 400 is appropriate.
* A 200-point Elo gap ~76% win probability; a 400-point gap ~91%.
*/
const ELO_DIVISOR = 400;
/** Fallback Elo for players with no stored surface rating. */
const FALLBACK_ELO = 1500;
// ─── QP constants (tie-splitting pre-applied) ─────────────────────────────────
const QP_WINNER = 20;
const QP_FINALIST = 14;
const QP_SF_LOSER = 9; // (10 + 8) / 2
const QP_QF_LOSER = 4; // (5 + 5 + 3 + 3) / 4
const QP_R16_LOSER = 1.5; // (2 + 2 + 2 + 2 + 1 + 1 + 1 + 1) / 8
// ─── Seeding draw slot positions ──────────────────────────────────────────────
const SEED1_SLOT = 0;
const SEED2_SLOT = 64;
const SEEDS_3_4_SLOTS = [32, 96] as const;
const SEEDS_5_8_SLOTS = [16, 48, 80, 112] as const;
const SEEDS_9_16_SLOTS = [8, 24, 40, 56, 72, 88, 104, 120] as const;
const SEEDS_17_32_SLOTS = [4, 12, 20, 28, 36, 44, 52, 60, 68, 76, 84, 92, 100, 108, 116, 124] as const;
// ─── Surface mapping ──────────────────────────────────────────────────────────
type CourtSurface = "hard" | "clay" | "grass";
const SLAM_SURFACES: Array<{ fragments: string[]; surface: CourtSurface }> = [
{ fragments: ["australian open"], surface: "hard" },
{ fragments: ["french open", "roland garros"], surface: "clay" },
{ fragments: ["wimbledon"], surface: "grass" },
{ fragments: ["us open"], surface: "hard" },
];
function getSurfaceForEvent(eventName: string): CourtSurface {
const lower = eventName.toLowerCase();
for (const { fragments, surface } of SLAM_SURFACES) {
if (fragments.some((f) => lower.includes(f))) return surface;
}
// Default to hard court if the name doesn't match — admin should use standard names.
return "hard";
}
// ─── Math helpers ─────────────────────────────────────────────────────────────
/**
* Per-match win probability for player 1 vs player 2 based on their Elo ratings.
* Uses the standard logistic function: p = 1 / (1 + 10^((R2 - R1) / ELO_DIVISOR))
* Exported for unit testing.
*/
export function eloWinProb(elo1: number, elo2: number): number {
return 1 / (1 + Math.pow(10, (elo2 - elo1) / ELO_DIVISOR));
}
/** Fisher-Yates in-place shuffle. Returns the array for chaining. */
function shuffle<T>(arr: T[]): T[] {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
// ─── Draw builder ─────────────────────────────────────────────────────────────
/**
* Build one seeded 128-slot draw for a major.
*
* Returns an array of 128 participant IDs in bracket order: pairs [0,1],
* [2,3], ... are R1 matches. Consecutive R1 winners form R2 matchups, etc.
* Seeds 132 are determined by ATP/WTA world ranking (ascending, 1 = top seed).
* Players with no world ranking are treated as unseeded (random placement).
* Remaining 96 unseeded players are placed randomly.
* Caller must pass exactly 128 participant IDs.
*/
export function buildDraw(
participantIds: string[],
eloMap: Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null } | undefined>
): string[] {
// Sort by world ranking ascending (lower number = better seed).
// Players with no ranking sort to the end (unseeded).
const sorted = [...participantIds].toSorted((a, b) => {
const ra = eloMap.get(a)?.worldRanking ?? Infinity;
const rb = eloMap.get(b)?.worldRanking ?? Infinity;
return ra - rb;
});
const seeds = sorted.slice(0, 32);
const unseeded = sorted.slice(32);
const slots: (string | null)[] = Array(128).fill(null);
// Seed 1 and 2 in opposite halves.
slots[SEED1_SLOT] = seeds[0];
slots[SEED2_SLOT] = seeds[1];
// Seeds 34: randomly into the two remaining quarter tops.
const q34 = shuffle([...SEEDS_3_4_SLOTS]);
slots[q34[0]] = seeds[2];
slots[q34[1]] = seeds[3];
// Seeds 58: randomly into the four remaining eighth tops.
const e58 = shuffle([...SEEDS_5_8_SLOTS]);
for (let i = 0; i < 4; i++) slots[e58[i]] = seeds[4 + i];
// Seeds 916: randomly into the eight remaining sixteenth tops.
const s916 = shuffle([...SEEDS_9_16_SLOTS]);
for (let i = 0; i < 8; i++) slots[s916[i]] = seeds[8 + i];
// Seeds 1732: randomly into the 16 remaining 32nd-section tops.
const s1732 = shuffle([...SEEDS_17_32_SLOTS]);
for (let i = 0; i < 16; i++) slots[s1732[i]] = seeds[16 + i];
// Fill remaining 96 slots with the unseeded players in random order.
const openSlots = slots.reduce<number[]>((acc, v, i) => { if (v === null) acc.push(i); return acc; }, []);
const shuffledUnseeded = shuffle([...unseeded]);
shuffledUnseeded.forEach((player, i) => { slots[openSlots[i]] = player; });
return slots as string[];
}
// ─── Single-major bracket simulation ──────────────────────────────────────────
interface QPResult {
participantId: string;
qp: number;
}
/**
* Simulate one Grand Slam major and return QP earned per participant.
* The draw is a pre-shuffled 128-slot array from buildDraw().
* Exported for unit testing.
*/
export function simulateMajor(draw: string[], eloMap: Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null } | undefined>, surface: CourtSurface): QPResult[] {
const qp = new Map<string, number>(draw.map((id) => [id, 0]));
const getElo = (id: string): number => {
const e = eloMap.get(id);
if (!e) return FALLBACK_ELO;
const v = surface === "hard" ? e.eloHard : surface === "clay" ? e.eloClay : e.eloGrass;
return v ?? FALLBACK_ELO;
};
const simMatch = (p1: string, p2: string): { winner: string; loser: string } => {
const p = eloWinProb(getElo(p1), getElo(p2));
const winner = Math.random() < p ? p1 : p2;
return { winner, loser: winner === p1 ? p2 : p1 };
};
// 7 rounds: R1(64 matches) R2(32) R3(16) R16(8) QF(4) SF(2) Final(1)
let current = [...draw]; // 128 players
// Rounds 13 award 0 QP; just advance winners.
for (let round = 0; round < 3; round++) {
const next: string[] = [];
for (let i = 0; i < current.length; i += 2) {
const { winner } = simMatch(current[i], current[i + 1]);
next.push(winner);
}
current = next;
}
// R16: 8 matches, losers get 1.5 QP each.
{
const next: string[] = [];
for (let i = 0; i < current.length; i += 2) {
const { winner, loser } = simMatch(current[i], current[i + 1]);
qp.set(loser, (qp.get(loser) ?? 0) + QP_R16_LOSER);
next.push(winner);
}
current = next;
}
// QF: 4 matches, losers get 4 QP each.
{
const next: string[] = [];
for (let i = 0; i < current.length; i += 2) {
const { winner, loser } = simMatch(current[i], current[i + 1]);
qp.set(loser, (qp.get(loser) ?? 0) + QP_QF_LOSER);
next.push(winner);
}
current = next;
}
// SF: 2 matches, losers get 9 QP each.
{
const next: string[] = [];
for (let i = 0; i < current.length; i += 2) {
const { winner, loser } = simMatch(current[i], current[i + 1]);
qp.set(loser, (qp.get(loser) ?? 0) + QP_SF_LOSER);
next.push(winner);
}
current = next;
}
// Final: 1 match.
const { winner: champion, loser: finalist } = simMatch(current[0], current[1]);
qp.set(champion, (qp.get(champion) ?? 0) + QP_WINNER);
qp.set(finalist, (qp.get(finalist) ?? 0) + QP_FINALIST);
return Array.from(qp.entries()).map(([participantId, qpVal]) => ({ participantId, qp: qpVal }));
}
// ─── Simulator ────────────────────────────────────────────────────────────────
export class TennisSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
// 1. Load all participants for this sports season.
const allParticipants = await db
.select({ id: schema.participants.id })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
if (allParticipants.length < 128) {
throw new Error(
`Tennis simulation requires at least 128 participants (got ${allParticipants.length}). ` +
`Ensure all 128 draw entries are added as participants before simulating.`
);
}
const participantIds = allParticipants.map((p) => p.id);
// 2. Load surface Elo ratings.
const surfaceEloMap = await getSurfaceEloMap(sportsSeasonId);
// 3. Load Grand Slam scoring events ordered by date.
const events = await db.query.scoringEvents.findMany({
where: and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.eventType, "major_tournament")
),
orderBy: (e, { asc }) => [asc(e.eventDate)],
});
if (events.length === 0) {
throw new Error(
`No major_tournament scoring events found for sports season ${sportsSeasonId}. ` +
`Create the 4 Grand Slam events first (e.g., "Australian Open", "French Open", ` +
`"Wimbledon", "US Open").`
);
}
// 4. For complete events, read actual QP from eventResults.
// Map: participantId → totalActualQP (across all completed majors).
const completedEventIds = events
.filter((e) => e.isComplete)
.map((e) => e.id);
const actualQPMap = new Map<string, number>(participantIds.map((id) => [id, 0]));
if (completedEventIds.length > 0) {
const actualResults = await db
.select({
participantId: schema.eventResults.participantId,
qualifyingPointsAwarded: schema.eventResults.qualifyingPointsAwarded,
})
.from(schema.eventResults)
.where(inArray(schema.eventResults.scoringEventId, completedEventIds));
for (const r of actualResults) {
if (r.qualifyingPointsAwarded !== null) {
const prev = actualQPMap.get(r.participantId) ?? 0;
actualQPMap.set(r.participantId, prev + parseFloat(r.qualifyingPointsAwarded));
}
}
}
// Incomplete majors that need to be simulated.
const incompleteMajors = events.filter((e) => !e.isComplete);
// 5. Monte Carlo loop.
// For each player, count how many times they finish 1st8th by QP rank.
const counts: number[][] = Array.from({ length: participantIds.length }, () => Array.from({ length: 8 }, () => 0));
const idToIndex = new Map<string, number>(participantIds.map((id, i) => [id, i]));
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
// Start each simulation from the locked actual QP.
const simQP = new Map<string, number>(actualQPMap);
// Simulate each incomplete major and accumulate QP.
for (const event of incompleteMajors) {
const surface = getSurfaceForEvent(event.name);
const draw = buildDraw(participantIds, surfaceEloMap);
const results = simulateMajor(draw, surfaceEloMap, surface);
for (const { participantId, qp } of results) {
simQP.set(participantId, (simQP.get(participantId) ?? 0) + qp);
}
}
// Rank all participants by total QP descending.
const ranked = [...simQP.entries()].toSorted((a, b) => b[1] - a[1]);
// Award placements 18.
for (let rank = 0; rank < Math.min(8, ranked.length); rank++) {
const [pid] = ranked[rank];
const idx = idToIndex.get(pid);
if (idx !== undefined) {
counts[idx][rank]++;
}
}
}
// 6. Convert counts to probabilities.
// Column sums are naturally 1.0: exactly one player holds each rank per simulation.
return participantIds.map((participantId, i) => ({
participantId,
probabilities: {
probFirst: counts[i][0] / NUM_SIMULATIONS,
probSecond: counts[i][1] / NUM_SIMULATIONS,
probThird: counts[i][2] / NUM_SIMULATIONS,
probFourth: counts[i][3] / NUM_SIMULATIONS,
probFifth: counts[i][4] / NUM_SIMULATIONS,
probSixth: counts[i][5] / NUM_SIMULATIONS,
probSeventh: counts[i][6] / NUM_SIMULATIONS,
probEighth: counts[i][7] / NUM_SIMULATIONS,
},
source: "tennis_grand_slam_monte_carlo",
}));
}
}

View file

@ -94,6 +94,7 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [
"nhl_bracket",
"afl_bracket",
"snooker_bracket",
"tennis_qualifying_points",
]);
export const playoffMatchGameStatusEnum = pgEnum("playoff_match_game_status", [
@ -1072,3 +1073,36 @@ export const pendingStandingsMappingsRelations = relations(pendingStandingsMappi
references: [sportsSeasons.id],
}),
}));
// ─── Participant Surface Elos ─────────────────────────────────────────────────
// Stores surface-specific Elo ratings for tennis (and future surface-based sports).
// One row per (participantId, sportsSeasonId); nullable per surface.
export const participantSurfaceElos = pgTable("participant_surface_elos", {
id: uuid("id").primaryKey().defaultRandom(),
participantId: uuid("participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
sportsSeasonId: uuid("sports_season_id")
.notNull()
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
worldRanking: integer("world_ranking"),
eloHard: integer("elo_hard"),
eloClay: integer("elo_clay"),
eloGrass: integer("elo_grass"),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
}, (t) => ({
uniqueParticipantSeason: uniqueIndex("participant_surface_elos_unique")
.on(t.participantId, t.sportsSeasonId),
}));
export const participantSurfaceElosRelations = relations(participantSurfaceElos, ({ one }) => ({
participant: one(participants, {
fields: [participantSurfaceElos.participantId],
references: [participants.id],
}),
sportsSeason: one(sportsSeasons, {
fields: [participantSurfaceElos.sportsSeasonId],
references: [sportsSeasons.id],
}),
}));

View file

@ -0,0 +1,24 @@
ALTER TYPE "public"."simulator_type" ADD VALUE 'tennis_qualifying_points';--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "participant_surface_elos" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"participant_id" uuid NOT NULL,
"sports_season_id" uuid NOT NULL,
"elo_hard" integer,
"elo_clay" integer,
"elo_grass" integer,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "participant_surface_elos" ADD CONSTRAINT "participant_surface_elos_participant_id_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."participants"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "participant_surface_elos" ADD CONSTRAINT "participant_surface_elos_sports_season_id_sports_seasons_id_fk" FOREIGN KEY ("sports_season_id") REFERENCES "public"."sports_seasons"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "participant_surface_elos_unique" ON "participant_surface_elos" USING btree ("participant_id","sports_season_id");

View file

@ -0,0 +1 @@
ALTER TABLE "participant_surface_elos" ADD COLUMN "world_ranking" integer;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -414,6 +414,20 @@
"when": 1774310000000,
"tag": "0058_add_unique_participant_ev",
"breakpoints": true
},
{
"idx": 59,
"version": "7",
"when": 1774329951326,
"tag": "0059_mysterious_jack_murdock",
"breakpoints": true
},
{
"idx": 60,
"version": "7",
"when": 1774330910098,
"tag": "0060_omniscient_outlaw_kid",
"breakpoints": true
}
]
}