brackt/app/services/simulations/__tests__/tennis-simulator.test.ts
Chris Parsons 9480501932
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m12s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m24s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 3m27s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m25s
🚀 Deploy / 🐳 Build (push) Successful in 1m14s
🚀 Deploy / 🚀 Deploy (push) Successful in 14s
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

533 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect } from "vitest";
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";
// ─── 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 063 and 64127)", () => {
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 (R1R3 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 18.
// - 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%)
});
});
// ─── 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);
});
});