* Add notParticipating flag to allow excluding withdrawn participants from qualifying-points simulators Adds a `not_participating` boolean column to `event_results` so admins can mark a participant as not competing in a specific upcoming major (e.g. Alcaraz withdrawing from Wimbledon due to injury). The golf, tennis, and CS2 major simulators now query this flag for incomplete events and exclude those participants from the event's draw/field, redistributing probability weight to the remaining field. Admin UI for qualifying major_tournament events gains a "Not Participating" card to mark/unmark withdrawals before the event runs. https://claude.ai/code/session_01HxNPLEXzr5Km3suWrJe2F9 * Address code review feedback on not-participating flag Security: unmark-not-participating now validates the result exists, belongs to this event, and is actually a DNP row before deleting. mark-not-participating now returns a user-friendly error on duplicate-key constraint violations. Code quality: extract shared getExcludedByEventMap() utility to event-result model, eliminating the duplicated 20-line exclusion-loading block that was copy-pasted into all three simulators. Fix hasParticipantResult() to exclude notParticipating rows so it correctly reflects actual competition participation. Remove optional chaining on the non-optional notParticipatingIds field in the admin UI. Fix misleading empty-state message. Tests: replace the misleading first tennis DNP test (which never used the activeIds variable it created) with a test that explicitly validates the fallback behaviour when too few players remain after exclusion. Add three CS2 DNP tests covering the excluded-team-gets-zero-QP path, the redistribution of wins, and per-event pool independence. https://claude.ai/code/session_01HxNPLEXzr5Km3suWrJe2F9 * Fix lint errors: replace non-null assertions in CS2 DNP test https://claude.ai/code/session_01HxNPLEXzr5Km3suWrJe2F9 --------- Co-authored-by: Claude <noreply@anthropic.com>
339 lines
15 KiB
TypeScript
339 lines
15 KiB
TypeScript
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.
|
||
|
||
// ─── Not-participating exclusion ──────────────────────────────────────────────
|
||
|
||
describe("not-participating exclusion (tennis)", () => {
|
||
it("falls back to full participant list when too few remain after exclusions (< 128)", () => {
|
||
// With exactly 128 season participants and 1 excluded, activeIds.length = 127 < 128.
|
||
// The simulator uses the full list rather than building a broken bracket.
|
||
const IDS = makeIds(128);
|
||
const elos = IDS.map(() => 1500);
|
||
const eloMap = makeEloMap(IDS, elos);
|
||
const activeIds = IDS.filter((id) => id !== "p001"); // 127 — triggers fallback
|
||
expect(activeIds).toHaveLength(127);
|
||
// Fallback: build with full IDS, no crash, draw is valid 128-slot bracket
|
||
const drawIds = activeIds.length >= 128 ? activeIds : IDS;
|
||
const draw = buildDraw(drawIds, eloMap);
|
||
expect(draw).toHaveLength(128);
|
||
expect(draw).toContain("p001"); // included via fallback
|
||
});
|
||
|
||
it("when activeIds.length >= 128, excluded player's draw slot is freed", () => {
|
||
// 130 participants; exclude 2 → 128 remain → draw contains only active players
|
||
const LARGE_IDS = makeIds(130);
|
||
const elos = LARGE_IDS.map(() => 1500);
|
||
const eloMap = makeEloMap(LARGE_IDS, elos);
|
||
|
||
const excluded = new Set(["p001", "p002"]);
|
||
const activeIds = LARGE_IDS.filter((id) => !excluded.has(id)); // 128 players
|
||
|
||
expect(activeIds).toHaveLength(128);
|
||
const draw = buildDraw(activeIds, eloMap);
|
||
expect(draw).toHaveLength(128);
|
||
expect(draw).not.toContain("p001");
|
||
expect(draw).not.toContain("p002");
|
||
});
|
||
|
||
it("excluding the dominant player redistributes wins to the remaining field", () => {
|
||
const LARGE_IDS = makeIds(130);
|
||
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 < LARGE_IDS.length; i++) {
|
||
dominantEloMap.set(LARGE_IDS[i], { worldRanking: i + 1, eloHard: 1500, eloClay: 1500, eloGrass: 1500 });
|
||
}
|
||
|
||
const activeIds = LARGE_IDS.filter((id) => id !== "p001"); // exclude dominant player
|
||
expect(activeIds.length).toBeGreaterThanOrEqual(128);
|
||
|
||
const TRIALS = 100;
|
||
let p001Wins = 0;
|
||
for (let i = 0; i < TRIALS; i++) {
|
||
const draw = buildDraw(activeIds, dominantEloMap);
|
||
const results = simulateMajor(draw, dominantEloMap, "grass");
|
||
if (results.find((r) => r.participantId === "p001")?.qp === 20) p001Wins++;
|
||
}
|
||
// Excluded player should not appear in any draw → 0 wins
|
||
expect(p001Wins).toBe(0);
|
||
});
|
||
});
|
||
|
||
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 = 2000;
|
||
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 Elo points advantage per seed; should win substantially
|
||
// more than 1/128 ≈ 0.78% of the time. Threshold at 0.02 is still well
|
||
// above random; 2000 trials keeps std dev small enough to be non-flaky.
|
||
const winRate = seed1Wins / TRIALS;
|
||
expect(winRate).toBeGreaterThan(0.02); // > 2% (well above random 0.78%)
|
||
});
|
||
});
|