Two problems addressed:
1. Favorites' P(1st) was too sharp (e.g. NHL top teams ~20% vs ~12% implied).
- The NHL simulator hardcoded its parity factor (1000) and ignored the
season config's parityFactor, so the knob meant to flatten the
distribution did nothing. It also re-blended raw futures odds into every
game on top of the odds->Elo conversion, double-counting the same signal.
- NHL now reads parityFactor/iterations/seasonGames/overtimeRate from config
and no longer re-blends odds per game (odds enter once, via the central
odds->Elo resolver). Honoring parity 2500 flattens a top team from ~29% to
~13% title odds.
2. "Season Config" and "Input Policy" were two forms over the same stored
object that didn't reflect each other, and the engine-knob half was inert
for many simulators.
- Every simulator now reads its engine knobs (iterations everywhere;
parityFactor for all Elo-based sims) from the merged config, passed in by
the runner via the Simulator interface. Defaults equal the former
hardcoded constants, so behavior is unchanged unless a season overrides.
- The admin simulator page is now a single "Simulator Configuration" card
with structured Engine and Input-derivation sections (profile-driven, so
each sport shows only the knobs it honors) plus an Advanced raw-JSON
escape hatch — all writing the same config.
Also: centralized the duplicated configNumber helpers into config-access.ts;
the central odds->Elo resolver now maps onto the configured Elo floor/ceiling
so those bounds set the odds-derived spread (a real flattening dial).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAFMogMkFJf52YpHyCDvuf
568 lines
24 KiB
TypeScript
568 lines
24 KiB
TypeScript
/**
|
||
* 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";
|
||
import { positiveConfigNumber } from "./config-access";
|
||
|
||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||
|
||
const DEFAULT_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;
|
||
}
|
||
|
||
/** 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<string, Map<number, string>> {
|
||
const honored = new Map<string, Map<number, string>>();
|
||
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<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null } | undefined>,
|
||
surface: CourtSurface,
|
||
opts: {
|
||
realBracket?: RealBracketMatch[];
|
||
honored?: Map<string, Map<number, string>>;
|
||
qp?: SlamQP;
|
||
excluded?: Set<string>;
|
||
} = {}
|
||
): QPResult[] {
|
||
const qpValues = opts.qp ?? DEFAULT_SLAM_QP;
|
||
const excluded = opts.excluded;
|
||
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 };
|
||
};
|
||
|
||
// 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<string, number> = {};
|
||
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, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
||
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<string, number>(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<number, number>(
|
||
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<string, IdTranslator>();
|
||
interface MajorPlan {
|
||
eventId: string;
|
||
surface: CourtSurface;
|
||
fixedDraw: string[] | null;
|
||
// honored is built ONCE here (not per Monte Carlo iteration).
|
||
honored: Map<string, Map<number, string>> | null;
|
||
excluded: Set<string>;
|
||
}
|
||
const majorPlans: MajorPlan[] = [];
|
||
for (const event of incompleteMajors) {
|
||
const surface = getSurfaceForEvent(event.name);
|
||
const excluded = excludedByEvent.get(event.id) ?? new Set<string>();
|
||
const { sourceId, tr } = await resolveStructureSource(
|
||
event,
|
||
localParticipants,
|
||
translatorCache
|
||
);
|
||
const matches = await findPlayoffMatchesByEventId(sourceId);
|
||
let fixedDraw: string[] | null = null;
|
||
let honored: Map<string, Map<number, string>> | 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<string, number>(participantIds.map((id, i) => [id, i]));
|
||
|
||
for (let sim = 0; sim < numSimulations; 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 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] / numSimulations,
|
||
probSecond: counts[i][1] / numSimulations,
|
||
probThird: counts[i][2] / numSimulations,
|
||
probFourth: counts[i][3] / numSimulations,
|
||
probFifth: counts[i][4] / numSimulations,
|
||
probSixth: counts[i][5] / numSimulations,
|
||
probSeventh: counts[i][6] / numSimulations,
|
||
probEighth: counts[i][7] / numSimulations,
|
||
},
|
||
source: "tennis_grand_slam_monte_carlo",
|
||
}));
|
||
}
|
||
}
|