Add tennis Grand Slam simulator with surface Elo ratings, fixes #116 (#216)
Implements a Monte Carlo simulator for men's/women's tennis seasons scored
on the qualifying_points pattern. Simulates all 4 Grand Slam majors
(Australian Open, French Open, Wimbledon, US Open) using surface-specific
Elo ratings and ATP/WTA world rankings for seeding.
New table: participant_surface_elos — one row per (participant, season)
storing worldRanking, eloHard, eloClay, eloGrass.
Key design decisions:
- Seeding uses ATP/WTA world ranking (not Elo), matching real draw procedure
- Top 32 seeded with standard slot placement (1→0, 2→64, 3-4→quarters, etc.)
- QP per round with tie-splitting pre-applied: W=20, F=14, SF=9, QF=4, R16=1.5
- Completed majors read actual qualifyingPointsAwarded from eventResults
- 10,000 Monte Carlo simulations; column sums naturally 1.0 (no normalization)
Admin UI at /admin/sports-seasons/:id/surface-elo:
- 5-column grid (Player | Rank | Hard | Clay | Grass)
- Bulk import: "Name, ranking, hardElo, clayElo, grassElo" one per line
- Fuzzy name matching (bigram Dice coefficient) with "Did you mean?" suggestions
- Inline participant creation for unmatched names via useFetcher
- Saves Elos and auto-runs simulation on submit
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:59:35 -07:00
|
|
|
|
import { describe, it, expect } from "vitest";
|
Unify majors: score once, fan out across windows + tennis bracket EV
Make a "major" (golf/tennis/CS2) scored once on its canonical tournament
and fan out to every linked sports_season window and league.
Fan-out & completion (app/services/sync-tournament-results.ts):
- syncTournamentResults now marks each synced window's event complete
(gated by markComplete), recalculates affected leagues, and counts
recalc failures so a stale league can't hide behind a "completed" badge
- syncMajorFromPrimaryEvent promotes a primary window's derived results to
canonical tournament_results (deleting rows for dropped placements) and
fans out to siblings; fanOutMajorIfPrimary guards on the primary
- placement removals now propagate (stale rows reset to filler)
Primary-event model (scoring_events.is_primary, migration 0122):
- getPrimaryEventForTournament / isReadOnlySibling / ensurePrimaryEvent /
setPrimaryEvent; event creation auto-seeds a primary for bracket majors;
"Make primary" button on the tournament page
- per-window event/bracket/cs2 pages are read-only for non-primary linked
events (not-participating stays editable)
Tennis Grand Slam bracket (tennis_128 template + TEMPLATE_ROUND_CONFIG):
- bracket-scored qualifying major via the existing bracket pipeline
- simulator conditions in-progress EV on the real bracket (honoring
completed matches, walkover for withdrawals), QP derived from config,
round structure read from the template; CS2 + tennis share resolveStructureSource
Backfill (scripts/backfill-major-linking.ts): one-time idempotent reconcile
of existing majors (link orphans, designate primary, promote canonical, sync).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:32:22 -07:00
|
|
|
|
import {
|
|
|
|
|
|
eloWinProb,
|
|
|
|
|
|
buildDraw,
|
|
|
|
|
|
simulateMajor,
|
|
|
|
|
|
drawFromBracket,
|
|
|
|
|
|
buildHonoredMap,
|
|
|
|
|
|
DEFAULT_SLAM_QP,
|
|
|
|
|
|
type RealBracketMatch,
|
|
|
|
|
|
} from "../tennis-simulator";
|
|
|
|
|
|
import { deriveBracketQualifyingStates, getRoundConfig } from "~/models/scoring-calculator";
|
|
|
|
|
|
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
|
Add tennis Grand Slam simulator with surface Elo ratings, fixes #116 (#216)
Implements a Monte Carlo simulator for men's/women's tennis seasons scored
on the qualifying_points pattern. Simulates all 4 Grand Slam majors
(Australian Open, French Open, Wimbledon, US Open) using surface-specific
Elo ratings and ATP/WTA world rankings for seeding.
New table: participant_surface_elos — one row per (participant, season)
storing worldRanking, eloHard, eloClay, eloGrass.
Key design decisions:
- Seeding uses ATP/WTA world ranking (not Elo), matching real draw procedure
- Top 32 seeded with standard slot placement (1→0, 2→64, 3-4→quarters, etc.)
- QP per round with tie-splitting pre-applied: W=20, F=14, SF=9, QF=4, R16=1.5
- Completed majors read actual qualifyingPointsAwarded from eventResults
- 10,000 Monte Carlo simulations; column sums naturally 1.0 (no normalization)
Admin UI at /admin/sports-seasons/:id/surface-elo:
- 5-column grid (Player | Rank | Hard | Clay | Grass)
- Bulk import: "Name, ranking, hardElo, clayElo, grassElo" one per line
- Fuzzy name matching (bigram Dice coefficient) with "Did you mean?" suggestions
- Inline participant creation for unmatched names via useFetcher
- Saves Elos and auto-runs simulation on submit
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:59:35 -07:00
|
|
|
|
|
|
|
|
|
|
// ─── 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.
|
|
|
|
|
|
|
2026-05-19 11:13:32 -07:00
|
|
|
|
// ─── 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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
Add tennis Grand Slam simulator with surface Elo ratings, fixes #116 (#216)
Implements a Monte Carlo simulator for men's/women's tennis seasons scored
on the qualifying_points pattern. Simulates all 4 Grand Slam majors
(Australian Open, French Open, Wimbledon, US Open) using surface-specific
Elo ratings and ATP/WTA world rankings for seeding.
New table: participant_surface_elos — one row per (participant, season)
storing worldRanking, eloHard, eloClay, eloGrass.
Key design decisions:
- Seeding uses ATP/WTA world ranking (not Elo), matching real draw procedure
- Top 32 seeded with standard slot placement (1→0, 2→64, 3-4→quarters, etc.)
- QP per round with tie-splitting pre-applied: W=20, F=14, SF=9, QF=4, R16=1.5
- Completed majors read actual qualifyingPointsAwarded from eventResults
- 10,000 Monte Carlo simulations; column sums naturally 1.0 (no normalization)
Admin UI at /admin/sports-seasons/:id/surface-elo:
- 5-column grid (Player | Rank | Hard | Clay | Grass)
- Bulk import: "Name, ranking, hardElo, clayElo, grassElo" one per line
- Fuzzy name matching (bigram Dice coefficient) with "Did you mean?" suggestions
- Inline participant creation for unmatched names via useFetcher
- Saves Elos and auto-runs simulation on submit
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:59:35 -07:00
|
|
|
|
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;
|
Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225)
* Add MLB playoff simulator with AL/NL division standings, fixes #121
- New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners
(weighted by p_div) and 3 WC teams per league, then simulating
WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series.
Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available.
- New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API,
mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits.
- New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division
winner per division section + Wild Card section with 3-spot playoff line,
headings read "AL/NL League" instead of "Conference".
- Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections
(divisionSpots + wcSpots params); conferenceLabel now flows through group
objects rather than being derived from displayMode in the render.
- DB migration 0062 adds `mlb_bracket` to simulator_type enum.
- 37 new tests across simulator, adapter, and component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix flaky tennis simulator test: increase trials and lower threshold
500 trials with a ~3–5% expected win rate had enough variance to
occasionally land below the 0.03 threshold (~2% failure rate).
Bumping to 2000 trials reduces the std dev by 2x; lowering the
threshold to 0.02 keeps the assertion meaningful (still well above
random 0.78%) while eliminating the flakiness.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:02 -07:00
|
|
|
|
const TRIALS = 2000;
|
Add tennis Grand Slam simulator with surface Elo ratings, fixes #116 (#216)
Implements a Monte Carlo simulator for men's/women's tennis seasons scored
on the qualifying_points pattern. Simulates all 4 Grand Slam majors
(Australian Open, French Open, Wimbledon, US Open) using surface-specific
Elo ratings and ATP/WTA world rankings for seeding.
New table: participant_surface_elos — one row per (participant, season)
storing worldRanking, eloHard, eloClay, eloGrass.
Key design decisions:
- Seeding uses ATP/WTA world ranking (not Elo), matching real draw procedure
- Top 32 seeded with standard slot placement (1→0, 2→64, 3-4→quarters, etc.)
- QP per round with tie-splitting pre-applied: W=20, F=14, SF=9, QF=4, R16=1.5
- Completed majors read actual qualifyingPointsAwarded from eventResults
- 10,000 Monte Carlo simulations; column sums naturally 1.0 (no normalization)
Admin UI at /admin/sports-seasons/:id/surface-elo:
- 5-column grid (Player | Rank | Hard | Clay | Grass)
- Bulk import: "Name, ranking, hardElo, clayElo, grassElo" one per line
- Fuzzy name matching (bigram Dice coefficient) with "Did you mean?" suggestions
- Inline participant creation for unmatched names via useFetcher
- Saves Elos and auto-runs simulation on submit
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:59:35 -07:00
|
|
|
|
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++;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225)
* Add MLB playoff simulator with AL/NL division standings, fixes #121
- New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners
(weighted by p_div) and 3 WC teams per league, then simulating
WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series.
Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available.
- New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API,
mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits.
- New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division
winner per division section + Wild Card section with 3-spot playoff line,
headings read "AL/NL League" instead of "Conference".
- Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections
(divisionSpots + wcSpots params); conferenceLabel now flows through group
objects rather than being derived from displayMode in the render.
- DB migration 0062 adds `mlb_bracket` to simulator_type enum.
- 37 new tests across simulator, adapter, and component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix flaky tennis simulator test: increase trials and lower threshold
500 trials with a ~3–5% expected win rate had enough variance to
occasionally land below the 0.03 threshold (~2% failure rate).
Bumping to 2000 trials reduces the std dev by 2x; lowering the
threshold to 0.02 keeps the assertion meaningful (still well above
random 0.78%) while eliminating the flakiness.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:02 -07:00
|
|
|
|
// 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.
|
Add tennis Grand Slam simulator with surface Elo ratings, fixes #116 (#216)
Implements a Monte Carlo simulator for men's/women's tennis seasons scored
on the qualifying_points pattern. Simulates all 4 Grand Slam majors
(Australian Open, French Open, Wimbledon, US Open) using surface-specific
Elo ratings and ATP/WTA world rankings for seeding.
New table: participant_surface_elos — one row per (participant, season)
storing worldRanking, eloHard, eloClay, eloGrass.
Key design decisions:
- Seeding uses ATP/WTA world ranking (not Elo), matching real draw procedure
- Top 32 seeded with standard slot placement (1→0, 2→64, 3-4→quarters, etc.)
- QP per round with tie-splitting pre-applied: W=20, F=14, SF=9, QF=4, R16=1.5
- Completed majors read actual qualifyingPointsAwarded from eventResults
- 10,000 Monte Carlo simulations; column sums naturally 1.0 (no normalization)
Admin UI at /admin/sports-seasons/:id/surface-elo:
- 5-column grid (Player | Rank | Hard | Clay | Grass)
- Bulk import: "Name, ranking, hardElo, clayElo, grassElo" one per line
- Fuzzy name matching (bigram Dice coefficient) with "Did you mean?" suggestions
- Inline participant creation for unmatched names via useFetcher
- Saves Elos and auto-runs simulation on submit
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:59:35 -07:00
|
|
|
|
const winRate = seed1Wins / TRIALS;
|
Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225)
* Add MLB playoff simulator with AL/NL division standings, fixes #121
- New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners
(weighted by p_div) and 3 WC teams per league, then simulating
WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series.
Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available.
- New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API,
mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits.
- New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division
winner per division section + Wild Card section with 3-spot playoff line,
headings read "AL/NL League" instead of "Conference".
- Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections
(divisionSpots + wcSpots params); conferenceLabel now flows through group
objects rather than being derived from displayMode in the render.
- DB migration 0062 adds `mlb_bracket` to simulator_type enum.
- 37 new tests across simulator, adapter, and component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix flaky tennis simulator test: increase trials and lower threshold
500 trials with a ~3–5% expected win rate had enough variance to
occasionally land below the 0.03 threshold (~2% failure rate).
Bumping to 2000 trials reduces the std dev by 2x; lowering the
threshold to 0.02 keeps the assertion meaningful (still well above
random 0.78%) while eliminating the flakiness.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:02 -07:00
|
|
|
|
expect(winRate).toBeGreaterThan(0.02); // > 2% (well above random 0.78%)
|
Add tennis Grand Slam simulator with surface Elo ratings, fixes #116 (#216)
Implements a Monte Carlo simulator for men's/women's tennis seasons scored
on the qualifying_points pattern. Simulates all 4 Grand Slam majors
(Australian Open, French Open, Wimbledon, US Open) using surface-specific
Elo ratings and ATP/WTA world rankings for seeding.
New table: participant_surface_elos — one row per (participant, season)
storing worldRanking, eloHard, eloClay, eloGrass.
Key design decisions:
- Seeding uses ATP/WTA world ranking (not Elo), matching real draw procedure
- Top 32 seeded with standard slot placement (1→0, 2→64, 3-4→quarters, etc.)
- QP per round with tie-splitting pre-applied: W=20, F=14, SF=9, QF=4, R16=1.5
- Completed majors read actual qualifyingPointsAwarded from eventResults
- 10,000 Monte Carlo simulations; column sums naturally 1.0 (no normalization)
Admin UI at /admin/sports-seasons/:id/surface-elo:
- 5-column grid (Player | Rank | Hard | Clay | Grass)
- Bulk import: "Name, ranking, hardElo, clayElo, grassElo" one per line
- Fuzzy name matching (bigram Dice coefficient) with "Did you mean?" suggestions
- Inline participant creation for unmatched names via useFetcher
- Saves Elos and auto-runs simulation on submit
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:59:35 -07:00
|
|
|
|
});
|
|
|
|
|
|
});
|
Unify majors: score once, fan out across windows + tennis bracket EV
Make a "major" (golf/tennis/CS2) scored once on its canonical tournament
and fan out to every linked sports_season window and league.
Fan-out & completion (app/services/sync-tournament-results.ts):
- syncTournamentResults now marks each synced window's event complete
(gated by markComplete), recalculates affected leagues, and counts
recalc failures so a stale league can't hide behind a "completed" badge
- syncMajorFromPrimaryEvent promotes a primary window's derived results to
canonical tournament_results (deleting rows for dropped placements) and
fans out to siblings; fanOutMajorIfPrimary guards on the primary
- placement removals now propagate (stale rows reset to filler)
Primary-event model (scoring_events.is_primary, migration 0122):
- getPrimaryEventForTournament / isReadOnlySibling / ensurePrimaryEvent /
setPrimaryEvent; event creation auto-seeds a primary for bracket majors;
"Make primary" button on the tournament page
- per-window event/bracket/cs2 pages are read-only for non-primary linked
events (not-participating stays editable)
Tennis Grand Slam bracket (tennis_128 template + TEMPLATE_ROUND_CONFIG):
- bracket-scored qualifying major via the existing bracket pipeline
- simulator conditions in-progress EV on the real bracket (honoring
completed matches, walkover for withdrawals), QP derived from config,
round structure read from the template; CS2 + tennis share resolveStructureSource
Backfill (scripts/backfill-major-linking.ts): one-time idempotent reconcile
of existing majors (link orphans, designate primary, promote canonical, sync).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:32:22 -07:00
|
|
|
|
|
|
|
|
|
|
// ─── drawFromBracket ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("drawFromBracket", () => {
|
|
|
|
|
|
const ids = makeIds(128);
|
|
|
|
|
|
// 64 Round-of-128 matches: match N holds ids[2N-2], ids[2N-1].
|
|
|
|
|
|
const fullR128 = Array.from({ length: 64 }, (_, i) => ({
|
|
|
|
|
|
round: "Round of 128",
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: ids[i * 2],
|
|
|
|
|
|
participant2Id: ids[i * 2 + 1],
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
it("reconstructs a 128-slot draw in match order", () => {
|
|
|
|
|
|
const draw = drawFromBracket(fullR128, (id) => id);
|
|
|
|
|
|
expect(draw).not.toBeNull();
|
|
|
|
|
|
expect(draw).toHaveLength(128);
|
|
|
|
|
|
expect(draw?.[0]).toBe("p001");
|
|
|
|
|
|
expect(draw?.[1]).toBe("p002");
|
|
|
|
|
|
expect(draw?.[127]).toBe("p128");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns null when the draw is not fully populated", () => {
|
|
|
|
|
|
const partial = fullR128.slice(0, 60); // only 60 of 64 matches
|
|
|
|
|
|
expect(drawFromBracket(partial, (id) => id)).toBeNull();
|
|
|
|
|
|
const withNull = fullR128.map((m, i) =>
|
|
|
|
|
|
i === 0 ? { ...m, participant1Id: null } : m
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(drawFromBracket(withNull, (id) => id)).toBeNull();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("applies the id translator", () => {
|
|
|
|
|
|
const draw = drawFromBracket(fullR128, (id) => (id ? `x-${id}` : id));
|
|
|
|
|
|
expect(draw?.[0]).toBe("x-p001");
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ─── simulateMajor honoring a real bracket ──────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("simulateMajor with realBracket", () => {
|
|
|
|
|
|
const ids = makeIds(128);
|
|
|
|
|
|
const eloMap = makeEloMap(ids, ids.map(() => 1500));
|
|
|
|
|
|
|
|
|
|
|
|
// A fully-decided bracket where the lower draw index always wins. Champion is
|
|
|
|
|
|
// therefore ids[0]; the result is deterministic regardless of RNG.
|
|
|
|
|
|
function decideBracket(draw: string[]): RealBracketMatch[] {
|
|
|
|
|
|
const rounds = [
|
|
|
|
|
|
"Round of 128", "Round of 64", "Round of 32", "Round of 16",
|
|
|
|
|
|
"Quarterfinals", "Semifinals", "Final",
|
|
|
|
|
|
];
|
|
|
|
|
|
const order = new Map(draw.map((id, i) => [id, i]));
|
|
|
|
|
|
const matches: RealBracketMatch[] = [];
|
|
|
|
|
|
let current = [...draw];
|
|
|
|
|
|
for (const round of rounds) {
|
|
|
|
|
|
const next: string[] = [];
|
|
|
|
|
|
for (let i = 0; i < current.length; i += 2) {
|
|
|
|
|
|
const p1 = current[i];
|
|
|
|
|
|
const p2 = current[i + 1];
|
|
|
|
|
|
const winner = (order.get(p1) ?? 0) < (order.get(p2) ?? 0) ? p1 : p2;
|
|
|
|
|
|
matches.push({ round, matchNumber: i / 2 + 1, winnerId: winner, isComplete: true });
|
|
|
|
|
|
next.push(winner);
|
|
|
|
|
|
}
|
|
|
|
|
|
current = next;
|
|
|
|
|
|
}
|
|
|
|
|
|
return matches;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
it("honors a fully-decided bracket deterministically (champion = draw[0])", () => {
|
|
|
|
|
|
const draw = [...ids];
|
|
|
|
|
|
const realBracket = decideBracket(draw);
|
|
|
|
|
|
const results = simulateMajor(draw, eloMap, "hard", { realBracket });
|
|
|
|
|
|
const champ = results.find((r) => r.qp === DEFAULT_SLAM_QP.winner);
|
|
|
|
|
|
expect(champ?.participantId).toBe(draw[0]);
|
|
|
|
|
|
// Structural counts are unchanged by honoring.
|
|
|
|
|
|
expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.winner)).toHaveLength(1);
|
|
|
|
|
|
expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.finalist)).toHaveLength(1);
|
|
|
|
|
|
expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.sf)).toHaveLength(2);
|
|
|
|
|
|
expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.qf)).toHaveLength(4);
|
|
|
|
|
|
expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.r16)).toHaveLength(8);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("honors a completed Final winner even against a heavy Elo underdog", () => {
|
|
|
|
|
|
// Underdog at draw[0] (low Elo) but the Final is recorded as their win.
|
|
|
|
|
|
const draw = [...ids];
|
|
|
|
|
|
const skewed = makeEloMap(draw, draw.map((_, i) => (i === 0 ? 100 : 3000)));
|
|
|
|
|
|
const realBracket = decideBracket(draw); // draw[0] wins everything
|
|
|
|
|
|
const results = simulateMajor(draw, skewed, "hard", { realBracket });
|
|
|
|
|
|
const champ = results.find((r) => r.qp === DEFAULT_SLAM_QP.winner);
|
|
|
|
|
|
expect(champ?.participantId).toBe(draw[0]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("uses config QP values from opts.qp", () => {
|
|
|
|
|
|
const draw = [...ids];
|
|
|
|
|
|
const realBracket = decideBracket(draw);
|
|
|
|
|
|
const qp = { winner: 100, finalist: 70, sf: 40, qf: 20, r16: 5 };
|
|
|
|
|
|
const results = simulateMajor(draw, eloMap, "hard", { realBracket, qp });
|
|
|
|
|
|
expect(results.find((r) => r.participantId === draw[0])?.qp).toBe(100);
|
|
|
|
|
|
expect(results.filter((r) => r.qp === 70)).toHaveLength(1);
|
|
|
|
|
|
expect(results.filter((r) => r.qp === 5)).toHaveLength(8);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ─── tennis_128 bracket scoring (QP placement derivation) ───────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("tennis_128 qualifying bracket scoring", () => {
|
|
|
|
|
|
it("derives placements 1/2/3/5/9 from the tennis_128 round config", () => {
|
|
|
|
|
|
const template = BRACKET_TEMPLATES.tennis_128;
|
|
|
|
|
|
// A wins R16 → QF → SF → Final (champion). Losers exit at each scoring round.
|
|
|
|
|
|
const matches = [
|
|
|
|
|
|
{ round: "Round of 16", winnerId: "A", loserId: "I", participant1Id: "A", participant2Id: "I" },
|
|
|
|
|
|
{ round: "Quarterfinals", winnerId: "A", loserId: "E", participant1Id: "A", participant2Id: "E" },
|
|
|
|
|
|
{ round: "Semifinals", winnerId: "A", loserId: "C", participant1Id: "A", participant2Id: "C" },
|
|
|
|
|
|
{ round: "Final", winnerId: "A", loserId: "B", participant1Id: "A", participant2Id: "B" },
|
|
|
|
|
|
];
|
|
|
|
|
|
const states = deriveBracketQualifyingStates(
|
|
|
|
|
|
matches,
|
|
|
|
|
|
template.rounds,
|
|
|
|
|
|
(round) => getRoundConfig(round, "tennis_128")
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(states.get("A")?.placement).toBe(1); // champion
|
|
|
|
|
|
expect(states.get("B")?.placement).toBe(2); // Final loser
|
|
|
|
|
|
expect(states.get("C")).toMatchObject({ placement: 3, tieCount: 2 }); // SF loser
|
|
|
|
|
|
expect(states.get("E")).toMatchObject({ placement: 5, tieCount: 4 }); // QF loser
|
|
|
|
|
|
expect(states.get("I")).toMatchObject({ placement: 9, tieCount: 8 }); // R16 loser
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ─── exclusions (not-participating) + prebuilt honored map ──────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("simulateMajor exclusions & prebuilt honored", () => {
|
|
|
|
|
|
const ids = makeIds(128);
|
|
|
|
|
|
|
|
|
|
|
|
it("a withdrawn player walks over (earns 0 QP) even with a dominant Elo", () => {
|
|
|
|
|
|
// draw[0] would otherwise win almost everything; excluding them must zero them.
|
|
|
|
|
|
const dominant = makeEloMap(ids, ids.map((_, i) => (i === 0 ? 5000 : 1000)));
|
|
|
|
|
|
const excluded = new Set([ids[0]]);
|
|
|
|
|
|
for (let t = 0; t < 20; t++) {
|
|
|
|
|
|
const results = simulateMajor(ids, dominant, "hard", { excluded });
|
|
|
|
|
|
expect(results.find((r) => r.participantId === ids[0])?.qp).toBe(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("a completed match is still honored even if the winner is later excluded", () => {
|
|
|
|
|
|
// Fully-decided bracket says draw[0] wins; excluding them must NOT override a
|
|
|
|
|
|
// recorded real result (the match already happened).
|
|
|
|
|
|
function decide(draw: string[]): RealBracketMatch[] {
|
|
|
|
|
|
const rounds = [
|
|
|
|
|
|
"Round of 128", "Round of 64", "Round of 32", "Round of 16",
|
|
|
|
|
|
"Quarterfinals", "Semifinals", "Final",
|
|
|
|
|
|
];
|
|
|
|
|
|
const order = new Map(draw.map((id, i) => [id, i]));
|
|
|
|
|
|
const matches: RealBracketMatch[] = [];
|
|
|
|
|
|
let cur = [...draw];
|
|
|
|
|
|
for (const round of rounds) {
|
|
|
|
|
|
const next: string[] = [];
|
|
|
|
|
|
for (let i = 0; i < cur.length; i += 2) {
|
|
|
|
|
|
const w = (order.get(cur[i]) ?? 0) < (order.get(cur[i + 1]) ?? 0) ? cur[i] : cur[i + 1];
|
|
|
|
|
|
matches.push({ round, matchNumber: i / 2 + 1, winnerId: w, isComplete: true });
|
|
|
|
|
|
next.push(w);
|
|
|
|
|
|
}
|
|
|
|
|
|
cur = next;
|
|
|
|
|
|
}
|
|
|
|
|
|
return matches;
|
|
|
|
|
|
}
|
|
|
|
|
|
const honored = buildHonoredMap(decide(ids));
|
|
|
|
|
|
const eloMap = makeEloMap(ids, ids.map(() => 1500));
|
|
|
|
|
|
const results = simulateMajor(ids, eloMap, "hard", {
|
|
|
|
|
|
honored,
|
|
|
|
|
|
excluded: new Set([ids[0]]),
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(results.find((r) => r.qp === DEFAULT_SLAM_QP.winner)?.participantId).toBe(ids[0]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("prebuilt honored map produces the same champion as realBracket input", () => {
|
|
|
|
|
|
const realBracket: RealBracketMatch[] = [
|
|
|
|
|
|
{ round: "Final", matchNumber: 1, winnerId: null, isComplete: false },
|
|
|
|
|
|
];
|
|
|
|
|
|
const honored = buildHonoredMap(realBracket);
|
|
|
|
|
|
// Empty honored (no completed matches) → no crash, full simulation runs.
|
|
|
|
|
|
const eloMap = makeEloMap(ids, ids.map(() => 1500));
|
|
|
|
|
|
const results = simulateMajor(ids, eloMap, "hard", { honored });
|
|
|
|
|
|
expect(results.filter((r) => r.qp === DEFAULT_SLAM_QP.winner)).toHaveLength(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|