/** * 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 { getExcludedByEventMap } from "~/models/event-result"; import { findParticipantsBySportsSeasonId } from "~/models/season-participant"; import { findPlayoffMatchesByEventId } from "~/models/playoff-match"; import { getQPConfig, calculateSplitQualifyingPoints } from "~/models/qualifying-points"; import { resolveStructureSource, type IdTranslator } from "./shared-major"; import { BRACKET_TEMPLATES } from "~/lib/bracket-templates"; 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(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[] { // 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((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; } /** QP awarded at each scoring stage of a Grand Slam (tie-split pre-applied). */ export interface SlamQP { winner: number; finalist: number; sf: number; // each SF loser qf: number; // each QF loser r16: number; // each R16 loser } /** * Default QP — the historical hardcoded tie-split averages. Used when a season's * QP config isn't supplied (e.g. unit tests). The live simulator derives these * from getQPConfig so simulated QP matches what the bracket actually awards. */ export const DEFAULT_SLAM_QP: SlamQP = { winner: QP_WINNER, finalist: QP_FINALIST, sf: QP_SF_LOSER, qf: QP_QF_LOSER, r16: QP_R16_LOSER, }; /** A real (DB) bracket match used to condition an in-progress simulation. */ export interface RealBracketMatch { round: string; matchNumber: number; winnerId: string | null; isComplete: boolean; } // Round structure is derived from the tennis_128 template so the simulator and // the scorer share one source of truth — renaming a round or changing the draw // size in the template can't silently desync the bracket-conditioned sim. const TENNIS_TEMPLATE = BRACKET_TEMPLATES.tennis_128; const SLAM_ROUND_NAMES = TENNIS_TEMPLATE.rounds.map((r) => r.name); const FIRST_ROUND = TENNIS_TEMPLATE.rounds[0]; const FINAL_ROUND_NAME = SLAM_ROUND_NAMES[SLAM_ROUND_NAMES.length - 1]; // Scoring rounds before the final, ordered final-adjacent first ([SF, QF, R16]), // so loser QP tiers (sf, qf, r16) map positionally rather than by hardcoded name. const NON_FINAL_SCORING_ROUNDS = TENNIS_TEMPLATE.rounds .filter((r) => r.isScoring && r.name !== FINAL_ROUND_NAME) .map((r) => r.name) .toReversed(); /** Build the round→matchNumber→winnerId lookup honored during simulation. */ export function buildHonoredMap( realBracket: RealBracketMatch[] ): Map> { const honored = new Map>(); for (const m of realBracket) { if (m.isComplete && m.winnerId) { let r = honored.get(m.round); if (!r) { r = new Map(); honored.set(m.round, r); } r.set(m.matchNumber, m.winnerId); } } return honored; } /** * Simulate one Grand Slam major and return QP earned per participant. * The draw is a 128-slot array (from buildDraw, or from a real bracket's draw). * * When `opts.realBracket` is supplied, completed matches are HONORED (their * winners advance instead of being re-simulated) and only undecided matches are * played out — the tennis analog of CS2's bracket-conditioned simulation. The * draw passed in must already reflect the real bracket's Round-of-128 ordering. * * Exported for unit testing. */ export function simulateMajor( draw: string[], eloMap: Map, surface: CourtSurface, opts: { realBracket?: RealBracketMatch[]; honored?: Map>; qp?: SlamQP; excluded?: Set; } = {} ): QPResult[] { const qpValues = opts.qp ?? DEFAULT_SLAM_QP; const excluded = opts.excluded; const qp = new Map(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 }; }; // round name → (matchNumber → completed winner id). Prefer the prebuilt map // (hoisted out of the Monte Carlo loop by the caller); otherwise derive it. const honored = opts.honored ?? (opts.realBracket ? buildHonoredMap(opts.realBracket) : undefined); // Per-round loser QP, mapped positionally from the template's scoring rounds. const loserQpByRound: Record = {}; const tiers = [qpValues.sf, qpValues.qf, qpValues.r16]; NON_FINAL_SCORING_ROUNDS.forEach((name, i) => { if (i < tiers.length) loserQpByRound[name] = tiers[i]; }); let current = [...draw]; for (const roundName of SLAM_ROUND_NAMES) { const honoredRound = honored?.get(roundName); const next: string[] = []; for (let i = 0; i < current.length; i += 2) { const p1 = current[i]; const p2 = current[i + 1]; const matchNumber = i / 2 + 1; // Honor a completed match only if its winner is actually one of the two // players at this slot (guards against inconsistent partial entry). const forced = honoredRound?.get(matchNumber); let winner: string; let loser: string; if (forced && (forced === p1 || forced === p2)) { winner = forced; loser = forced === p1 ? p2 : p1; } else if (excluded && excluded.has(p1) !== excluded.has(p2)) { // Exactly one player withdrew (not-participating) and the match isn't // decided yet → the present player advances by walkover. winner = excluded.has(p1) ? p2 : p1; loser = winner === p1 ? p2 : p1; } else { ({ winner, loser } = simMatch(p1, p2)); } if (roundName === FINAL_ROUND_NAME) { qp.set(winner, (qp.get(winner) ?? 0) + qpValues.winner); qp.set(loser, (qp.get(loser) ?? 0) + qpValues.finalist); } else { const lq = loserQpByRound[roundName]; if (lq) qp.set(loser, (qp.get(loser) ?? 0) + lq); } next.push(winner); } current = next; } return Array.from(qp.entries()).map(([participantId, qpVal]) => ({ participantId, qp: qpVal })); } /** * Reconstruct a 128-slot draw from a real bracket's Round-of-128 matches. * Match N (1-indexed) occupies slots [2N-2, 2N-1]. Returns null if the draw is * not fully populated (so the caller falls back to a synthetic seeded draw). * `tr` maps the source window's participant ids into the local window's. */ export function drawFromBracket( matches: Array<{ round: string; matchNumber: number; participant1Id: string | null; participant2Id: string | null }>, tr: IdTranslator ): string[] | null { // First round name + match count come from the template (single source). const firstRoundMatches = FIRST_ROUND.matchCount; const slotCount = firstRoundMatches * 2; const r1 = matches.filter((m) => m.round === FIRST_ROUND.name); if (r1.length !== firstRoundMatches) return null; const slots: (string | null)[] = Array(slotCount).fill(null); for (const m of r1) { if (m.matchNumber < 1 || m.matchNumber > firstRoundMatches) return null; slots[(m.matchNumber - 1) * 2] = tr(m.participant1Id); slots[(m.matchNumber - 1) * 2 + 1] = tr(m.participant2Id); } if (slots.some((s) => s === null)) return null; return slots as string[]; } // ─── Simulator ──────────────────────────────────────────────────────────────── export class TennisSimulator implements Simulator { async simulate(sportsSeasonId: string): Promise { const db = database(); // 1. Load all participants for this sports season. const allParticipants = await db .select({ id: schema.seasonParticipants.id }) .from(schema.seasonParticipants) .where(eq(schema.seasonParticipants.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(participantIds.map((id) => [id, 0])); if (completedEventIds.length > 0) { const actualResults = await db .select({ participantId: schema.eventResults.seasonParticipantId, 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); // Load not-participating exclusions for each incomplete major. const excludedByEvent = await getExcludedByEventMap(incompleteMajors.map((e) => e.id)); // Per-stage QP derived from the season's QP config (tie-split) so simulated QP // matches what the bracket actually awards via processQualifyingBracketEvent. // Falls back to the historical defaults if the season has no QP config. const qpConfigArray = await getQPConfig(sportsSeasonId); const qpMap = new Map( qpConfigArray.map((c) => [c.placement, parseFloat(c.points)]) ); const slamQP: SlamQP = qpConfigArray.length > 0 ? { winner: calculateSplitQualifyingPoints(1, 1, qpMap), finalist: calculateSplitQualifyingPoints(2, 1, qpMap), sf: calculateSplitQualifyingPoints(3, 2, qpMap), qf: calculateSplitQualifyingPoints(5, 4, qpMap), r16: calculateSplitQualifyingPoints(9, 8, qpMap), } : DEFAULT_SLAM_QP; // Precompute, once, how each incomplete major is simulated. If a real bracket // exists (and is fully drawn), condition on it: a fixed draw from the bracket // + completed matches honored (read from the primary window for siblings, with // id translation). Otherwise fall back to a fresh synthetic seeded draw. const localParticipants = await findParticipantsBySportsSeasonId(sportsSeasonId); const translatorCache = new Map(); interface MajorPlan { eventId: string; surface: CourtSurface; fixedDraw: string[] | null; // honored is built ONCE here (not per Monte Carlo iteration). honored: Map> | null; excluded: Set; } const majorPlans: MajorPlan[] = []; for (const event of incompleteMajors) { const surface = getSurfaceForEvent(event.name); const excluded = excludedByEvent.get(event.id) ?? new Set(); const { sourceId, tr } = await resolveStructureSource( event, localParticipants, translatorCache ); const matches = await findPlayoffMatchesByEventId(sourceId); let fixedDraw: string[] | null = null; let honored: Map> | null = null; if (matches.length > 0) { fixedDraw = drawFromBracket( matches.map((m) => ({ round: m.round, matchNumber: m.matchNumber, participant1Id: m.participant1Id, participant2Id: m.participant2Id, })), tr ); if (fixedDraw) { honored = buildHonoredMap( matches.map((m) => ({ round: m.round, matchNumber: m.matchNumber, winnerId: tr(m.winnerId), isComplete: m.isComplete, })) ); } } majorPlans.push({ eventId: event.id, surface, fixedDraw, honored, excluded }); } // 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(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(actualQPMap); // Simulate each incomplete major and accumulate QP. for (const plan of majorPlans) { let results: QPResult[]; if (plan.fixedDraw && plan.honored) { // Real bracket: fixed draw, completed matches honored, withdrawn // players walk over in undecided matches. results = simulateMajor(plan.fixedDraw, surfaceEloMap, plan.surface, { honored: plan.honored, qp: slamQP, excluded: plan.excluded, }); } else { const activeIds = participantIds.filter((id) => !plan.excluded.has(id)); // Fall back to full participant list if too few remain after exclusions // (buildDraw requires >= 128 players to fill all bracket slots). const drawIds = activeIds.length >= 128 ? activeIds : participantIds; const draw = buildDraw(drawIds, surfaceEloMap); results = simulateMajor(draw, surfaceEloMap, plan.surface, { qp: slamQP }); } 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", })); } }