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:
parent
f4031d2a38
commit
d7ba20d9ea
14 changed files with 9753 additions and 0 deletions
131
app/models/__tests__/surface-elo.test.ts
Normal file
131
app/models/__tests__/surface-elo.test.ts
Normal 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
129
app/models/surface-elo.ts
Normal 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,
|
||||
}]));
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
686
app/routes/admin.sports-seasons.$id.surface-elo.tsx
Normal file
686
app/routes/admin.sports-seasons.$id.surface-elo.tsx
Normal 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: 1400–2200.
|
||||
</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} → #{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 (1st–8th) 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
281
app/services/simulations/__tests__/tennis-simulator.test.ts
Normal file
281
app/services/simulations/__tests__/tennis-simulator.test.ts
Normal 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 0–63 and 64–127)", () => {
|
||||
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 (R1–R3 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 1–8.
|
||||
// - 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%)
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
375
app/services/simulations/tennis-simulator.ts
Normal file
375
app/services/simulations/tennis-simulator.ts
Normal 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 (1st–8th).
|
||||
*
|
||||
* 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 1st–8th 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 3–4 → slots 32, 96 (quarter tops), randomly drawn
|
||||
* Seeds 5–8 → slots 16, 48, 80, 112 (eighth tops), randomly drawn
|
||||
* Seeds 9–16 → slots 8, 24, 40, 56, 72, 88, 104, 120 (sixteenth tops), randomly drawn
|
||||
* Seeds 17–32 → 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 1–32 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 3–4: 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 5–8: 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 9–16: 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 17–32: 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 1–3 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 1st–8th 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 1–8.
|
||||
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",
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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],
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
24
drizzle/0059_mysterious_jack_murdock.sql
Normal file
24
drizzle/0059_mysterious_jack_murdock.sql
Normal 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");
|
||||
1
drizzle/0060_omniscient_outlaw_kid.sql
Normal file
1
drizzle/0060_omniscient_outlaw_kid.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "participant_surface_elos" ADD COLUMN "world_ranking" integer;
|
||||
4026
drizzle/meta/0059_snapshot.json
Normal file
4026
drizzle/meta/0059_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4032
drizzle/meta/0060_snapshot.json
Normal file
4032
drizzle/meta/0060_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue