import { describe, it, expect } from "vitest"; import { gameWinProb, seriesWinProb, sampleField, simulateSwiss, simulateChampionsStage, calcStage3ExitQP, simulateOneMajor, reconcileAdvancers, } from "../cs-major-simulator"; import type { AdvancedTeam, BracketMatchInput, SwissMatchInput, StageResultsMap } from "../cs-major-simulator"; // ─── Helpers ────────────────────────────────────────────────────────────────── function makeTeams(n: number, baseElo = 1800, baseRank = 1) { return Array.from({ length: n }, (_, i) => ({ id: `team-${i}`, elo: baseElo - i * 10, rank: baseRank + i, })); } function makeAdvancedTeams(n: number, losses = 0, baseElo = 1800, baseRank = 1): AdvancedTeam[] { return Array.from({ length: n }, (_, i) => ({ id: `team-${i}`, elo: baseElo - i * 10, rank: baseRank + i, losses, })); } function makeQPConfig(): Map { const config = new Map(); config.set(1, 500); config.set(2, 400); config.set(3, 325); config.set(4, 250); config.set(5, 175); config.set(6, 125); config.set(7, 75); config.set(8, 50); for (let i = 9; i <= 16; i++) config.set(i, (17 - i) * 10); return config; } // ─── gameWinProb ────────────────────────────────────────────────────────────── describe("gameWinProb", () => { it("returns 0.5 for equal Elo", () => { expect(gameWinProb(1800, 1800)).toBeCloseTo(0.5, 5); expect(gameWinProb(2000, 2000)).toBeCloseTo(0.5, 5); }); it("returns > 0.5 for positive advantage, < 0.5 for negative", () => { expect(gameWinProb(2000, 1700)).toBeGreaterThan(0.5); expect(gameWinProb(1700, 2000)).toBeLessThan(0.5); }); it("is symmetric: p(a,b) + p(b,a) = 1", () => { expect(gameWinProb(2000, 1700) + gameWinProb(1700, 2000)).toBeCloseTo(1.0, 5); }); it("stays strictly in (0, 1)", () => { const p = gameWinProb(3000, 1000); expect(p).toBeLessThan(1); expect(p).toBeGreaterThan(0); }); }); // ─── seriesWinProb ──────────────────────────────────────────────────────────── describe("seriesWinProb", () => { it("returns 0.5 for equal players (p=0.5) in any format", () => { expect(seriesWinProb(0.5, 2)).toBeCloseTo(0.5, 5); // Bo3 expect(seriesWinProb(0.5, 3)).toBeCloseTo(0.5, 5); // Bo5 expect(seriesWinProb(0.5, 4)).toBeCloseTo(0.5, 5); // Bo7 }); it("amplifies better team advantage in longer formats", () => { const bo3 = seriesWinProb(0.6, 2); const bo5 = seriesWinProb(0.6, 3); expect(bo5).toBeGreaterThan(bo3); }); it("approaches 1.0 as p → 1.0", () => { expect(seriesWinProb(0.9999, 2)).toBeCloseTo(1.0, 3); expect(seriesWinProb(0.9999, 3)).toBeCloseTo(1.0, 3); }); it("sums to 1 with complement", () => { const p = 0.63; expect(seriesWinProb(p, 2) + seriesWinProb(1 - p, 2)).toBeCloseTo(1.0, 5); expect(seriesWinProb(p, 3) + seriesWinProb(1 - p, 3)).toBeCloseTo(1.0, 5); }); it("Bo3 win prob with p=0.6 is in expected range", () => { // P(2-0) + P(2-1) = 0.36 + 2*0.36*0.4 = 0.36 + 0.288 = 0.648 expect(seriesWinProb(0.6, 2)).toBeCloseTo(0.648, 3); }); }); // ─── sampleField ────────────────────────────────────────────────────────────── describe("sampleField", () => { it("returns all teams when pool <= fieldSize", () => { const pool = makeTeams(20); const field = sampleField(pool, 32); expect(field).toHaveLength(20); }); it("returns fieldSize teams when pool > fieldSize", () => { const pool = makeTeams(50); const field = sampleField(pool, 32); expect(field).toHaveLength(32); }); it("always includes the top 12 ranked teams", () => { const pool = makeTeams(50); const top12Ids = new Set(pool.slice(0, 12).map((t) => t.id)); for (let i = 0; i < 20; i++) { const field = sampleField(pool, 32); const fieldIds = new Set(field.map((t) => t.id)); for (const id of top12Ids) { expect(fieldIds.has(id)).toBe(true); } } }); it("never includes more teams than fieldSize", () => { const pool = makeTeams(100); for (let i = 0; i < 10; i++) { expect(sampleField(pool, 32)).toHaveLength(32); } }); it("returns unique teams", () => { const pool = makeTeams(50); const field = sampleField(pool, 32); const ids = field.map((t) => t.id); expect(new Set(ids).size).toBe(ids.length); }); }); // ─── simulateSwiss ──────────────────────────────────────────────────────────── describe("simulateSwiss", () => { it("returns exactly 8 advanced and 8 eliminated from 16 teams", () => { const teams = makeTeams(16); const result = simulateSwiss(teams, false); expect(result.advanced).toHaveLength(8); expect(result.eliminated).toHaveLength(8); }); it("all advanced teams have 3 wins (implicit by advancement threshold)", () => { const teams = makeTeams(16); const result = simulateSwiss(teams, false); // Advanced teams reached 3 wins — we can't directly check wins here, // but we verify each participant appears in exactly one group const allIds = new Set([ ...result.advanced.map((t) => t.id), ...result.eliminated.map((t) => t.id), ]); expect(allIds.size).toBe(16); for (const team of teams) { expect(allIds.has(team.id)).toBe(true); } }); it("eliminated teams have wins 0, 1, or 2", () => { const teams = makeTeams(16); const result = simulateSwiss(teams, false); for (const t of result.eliminated) { expect(t.wins).toBeGreaterThanOrEqual(0); expect(t.wins).toBeLessThanOrEqual(2); } }); it("advanced teams have a losses field with value 0, 1, or 2", () => { const teams = makeTeams(16); const result = simulateSwiss(teams, false); for (const t of result.advanced) { expect(typeof t.losses).toBe("number"); expect(t.losses).toBeGreaterThanOrEqual(0); expect(t.losses).toBeLessThanOrEqual(2); } }); it("total wins + losses = total matches played (conservation check)", () => { const teams = makeTeams(16); const result = simulateSwiss(teams, false); // Eliminated teams have exactly 3 losses // Total losses = 8 * 3 = 24 // Total wins = sum of wins for all eliminated + 8 * 3 for advanced = ? // Each win by advanced team = 1 loss for an eliminated team const totalLossesEliminated = result.eliminated.length * 3; // 24 expect(totalLossesEliminated).toBe(24); }); it("works with Bo3 format (Swiss all Bo3)", () => { const teams = makeTeams(16); const result = simulateSwiss(teams, true); expect(result.advanced).toHaveLength(8); expect(result.eliminated).toHaveLength(8); }); it("works with decisiveMatchesBo3 enabled", () => { const teams = makeTeams(16); const result = simulateSwiss(teams, false, true); expect(result.advanced).toHaveLength(8); expect(result.eliminated).toHaveLength(8); }); it("returns empty arrays for 0 teams", () => { const result = simulateSwiss([], false); expect(result.advanced).toHaveLength(0); expect(result.eliminated).toHaveLength(0); }); it("throws for odd team count", () => { expect(() => simulateSwiss(makeTeams(15), false)).toThrow(/even number of teams/); expect(() => simulateSwiss(makeTeams(1), false)).toThrow(/even number of teams/); }); it("teams with much higher Elo advance more often (stochastic check)", () => { // Top 8 teams have Elo 2000+, bottom 8 have Elo 1000 const teams = [ ...Array.from({ length: 8 }, (_, i) => ({ id: `top-${i}`, elo: 2000, rank: i + 1 })), ...Array.from({ length: 8 }, (_, i) => ({ id: `bot-${i}`, elo: 1000, rank: i + 9 })), ]; let topAdvances = 0; const runs = 100; for (let i = 0; i < runs; i++) { const result = simulateSwiss(teams, false); for (const t of result.advanced) { if (t.id.startsWith("top-")) topAdvances++; } } // Top teams should advance much more than half the time across runs // Expected: ~7-8 top teams advance per run → topAdvances should be >> 400 (50%) expect(topAdvances / runs).toBeGreaterThan(6); }); }); // ─── simulateChampionsStage ─────────────────────────────────────────────────── describe("simulateChampionsStage", () => { it("throws if not exactly 8 teams", () => { expect(() => simulateChampionsStage(makeAdvancedTeams(7))).toThrow(); expect(() => simulateChampionsStage(makeAdvancedTeams(9))).toThrow(); }); it("assigns placements to all 8 teams", () => { const teams = makeAdvancedTeams(8); const result = simulateChampionsStage(teams); expect(result.placements.size).toBe(8); for (const team of teams) { expect(result.placements.has(team.id)).toBe(true); } }); it("has exactly one 1st, one 2nd, two placement-3s, four placement-5s", () => { const teams = makeAdvancedTeams(8); const result = simulateChampionsStage(teams); const placements = [...result.placements.values()]; expect(placements.filter((p) => p === 1)).toHaveLength(1); expect(placements.filter((p) => p === 2)).toHaveLength(1); expect(placements.filter((p) => p === 3)).toHaveLength(2); // both SF losers expect(placements.filter((p) => p === 5)).toHaveLength(4); // all QF losers }); it("seeds by losses ascending, then rank as tiebreaker", () => { // Team with 0 losses gets seeded 1st regardless of world rank. // Make all teams equal Elo so seeding determines matchups purely. // team-0 has 0 losses (seed 1) vs team-7 has 2 losses (seed 8). const teams: AdvancedTeam[] = [ { id: "a", elo: 1800, rank: 1, losses: 0 }, { id: "b", elo: 1800, rank: 2, losses: 0 }, { id: "c", elo: 1800, rank: 3, losses: 1 }, { id: "d", elo: 1800, rank: 4, losses: 1 }, { id: "e", elo: 1800, rank: 5, losses: 2 }, { id: "f", elo: 1800, rank: 6, losses: 2 }, { id: "g", elo: 1800, rank: 7, losses: 2 }, { id: "h", elo: 1800, rank: 8, losses: 2 }, ]; // With equal Elo, seeding by losses produces consistent bracket structure. // Just verify it runs correctly and produces valid placements. const result = simulateChampionsStage(teams); expect(result.placements.size).toBe(8); const values = [...result.placements.values()]; expect(values).toContain(1); expect(values).toContain(2); }); it("rank used as tiebreaker when losses are equal", () => { const teams = makeAdvancedTeams(8, 1); // all have losses = 1 const result = simulateChampionsStage(teams); expect(result.placements.size).toBe(8); }); it("top Elo team wins more often than last (stochastic)", () => { const teams = Array.from({ length: 8 }, (_, i): AdvancedTeam => ({ id: `team-${i}`, elo: 1800 - i * 100, rank: i + 1, losses: 0, })); let wins = 0; for (let i = 0; i < 1000; i++) { const result = simulateChampionsStage(teams); if (result.placements.get("team-0") === 1) wins++; } // Should win well above 12.5% (1/8 random baseline) expect(wins / 1000).toBeGreaterThan(0.25); }); }); // ─── calcStage3ExitQP ───────────────────────────────────────────────────────── // Placements 9–16 with descending QP: 9→80, 10→70, ..., 16→10 function makeStage3QPConfig(): Map { const config = new Map(); for (let i = 9; i <= 16; i++) { config.set(i, (17 - i) * 10); } return config; } describe("calcStage3ExitQP", () => { it("returns empty map for empty input", () => { const result = calcStage3ExitQP([], new Map()); expect(result.size).toBe(0); }); it("tie-splits QP within each wins group", () => { // 2 teams at 2 wins, 2 teams at 1 win, 4 teams at 0 wins const elim = [ { id: "a", wins: 2 }, { id: "b", wins: 2 }, // slots 9-10: avg (80+70)/2 = 75 { id: "c", wins: 1 }, { id: "d", wins: 1 }, // slots 11-12: avg (60+50)/2 = 55 { id: "e", wins: 0 }, { id: "f", wins: 0 }, { id: "g", wins: 0 }, { id: "h", wins: 0 }, // slots 13-16: avg (40+30+20+10)/4 = 25 ]; const config = makeStage3QPConfig(); const result = calcStage3ExitQP(elim, config); expect(result.get("a")).toBeCloseTo(75); expect(result.get("b")).toBeCloseTo(75); expect(result.get("c")).toBeCloseTo(55); expect(result.get("d")).toBeCloseTo(55); expect(result.get("e")).toBeCloseTo(25); expect(result.get("h")).toBeCloseTo(25); }); it("all teams with the same wins get identical averaged QP", () => { const elim = Array.from({ length: 8 }, (_, i) => ({ id: `t-${i}`, wins: 1 })); const config = makeStage3QPConfig(); const result = calcStage3ExitQP(elim, config); // Average of slots 9–16: (80+70+60+50+40+30+20+10) / 8 = 45 const expected = (80 + 70 + 60 + 50 + 40 + 30 + 20 + 10) / 8; for (const [, qp] of result) { expect(qp).toBeCloseTo(expected); } }); it("higher wins groups get higher QP", () => { const elim = [ { id: "high", wins: 2 }, { id: "mid", wins: 1 }, { id: "low", wins: 0 }, ]; // Pad to 8 to use real slots: add 5 more at wins=0 for (let i = 0; i < 5; i++) elim.push({ id: `pad-${i}`, wins: 0 }); const config = makeStage3QPConfig(); const result = calcStage3ExitQP(elim, config); const highQP = result.get("high") ?? 0; const midQP = result.get("mid") ?? 0; const lowQP = result.get("low") ?? 0; expect(highQP).toBeGreaterThan(midQP); expect(midQP).toBeGreaterThan(lowQP); }); }); // ─── simulateOneMajor ───────────────────────────────────────────────────────── describe("simulateOneMajor", () => { it("returns a QP entry for all pool teams (no stage data)", () => { const pool = makeTeams(32); const result = simulateOneMajor(pool, undefined, makeQPConfig()); expect(result.size).toBe(32); for (const [, qp] of result) { expect(qp).toBeGreaterThanOrEqual(0); } }); it("at least one team earns positive QP", () => { const pool = makeTeams(32); const result = simulateOneMajor(pool, undefined, makeQPConfig()); const nonZero = [...result.values()].filter((q) => q > 0); expect(nonZero.length).toBeGreaterThan(0); }); it("exactly 16 teams earn 0 QP (stage 1 and 2 exits)", () => { // Run multiple times to get a stable count — should always be 16 with 32-team pool const pool = makeTeams(32); const qpConfig = makeQPConfig(); for (let run = 0; run < 5; run++) { const result = simulateOneMajor(pool, undefined, qpConfig); const zeroCount = [...result.values()].filter((q) => q === 0).length; expect(zeroCount).toBe(16); } }); it("stage 1 eliminated teams earn 0 QP when stage is complete", () => { const pool = makeTeams(32); const qpConfig = makeQPConfig(); // Build stage results: 16 teams in stage 1, 8 in stage 2, 8 in stage 3 // Stage 1 eliminations: first 8 pool teams const stageResults = new Map(); for (let i = 0; i < 16; i++) { stageResults.set(`team-${i}`, { stageEntry: 1, stageEliminated: i < 8 ? 1 : null, // first 8 eliminated at stage 1 stageEliminatedWins: i < 8 ? (i % 3) : null, }); } for (let i = 16; i < 24; i++) { stageResults.set(`team-${i}`, { stageEntry: 2, stageEliminated: null, stageEliminatedWins: null }); } for (let i = 24; i < 32; i++) { stageResults.set(`team-${i}`, { stageEntry: 3, stageEliminated: null, stageEliminatedWins: null }); } const result = simulateOneMajor(pool, stageResults, qpConfig); // Stage 1 eliminated teams (team-0 through team-7) must get 0 QP for (let i = 0; i < 8; i++) { expect(result.get(`team-${i}`)).toBe(0); } }); it("works with a pool exactly equal to FIELD_SIZE (32 teams)", () => { // sampleField returns all teams when pool.length <= fieldSize const pool = makeTeams(32); expect(() => simulateOneMajor(pool, undefined, makeQPConfig())).not.toThrow(); }); it("QF and SF losers receive averaged QP (not individual slot QP)", () => { // With a uniform qpConfig we can verify tie-splitting: if slots 5-8 all have // distinct values, QF losers should all get the average, not random individual values. const qpConfig = new Map([ [1, 1000], [2, 800], [3, 600], [4, 400], [5, 300], [6, 200], [7, 100], [8, 50], ...Array.from({ length: 8 }, (_, i): [number, number] => [i + 9, 0]), ]); const expectedQFAvg = (300 + 200 + 100 + 50) / 4; // 162.5 const expectedSFAvg = (600 + 400) / 2; // 500 const pool = makeTeams(32); // Run a few times; every QF and SF loser should get the averaged values for (let run = 0; run < 5; run++) { const result = simulateOneMajor(pool, undefined, qpConfig); const qpValues = [...result.values()]; // The averaged values should appear in the results const hasQFAvg = qpValues.some((q) => Math.abs(q - expectedQFAvg) < 0.01); const hasSFAvg = qpValues.some((q) => Math.abs(q - expectedSFAvg) < 0.01); expect(hasQFAvg).toBe(true); expect(hasSFAvg).toBe(true); } }); }); // ─── Not-participating exclusion ────────────────────────────────────────────── // // simulateOneMajor uses sampleField to draw a 32-team field from the pool. // sampleField returns all teams when pool.length <= 32, so pools must be > 32 // for exclusion tests — otherwise the field shrinks below 32 and the Swiss // bracket gets an odd count. The actual simulator filters the pool before // calling simulateOneMajor, so the real-world scenario (pool of 20–30 teams, // 1 withdrawn) is safe: sampleField just uses all remaining teams up to 32. describe("not-participating exclusion (CS2)", () => { const qpConfig = makeQPConfig(); it("a team not in the pool gets 0 QP from that major", () => { // Use 40 teams so sampleField still draws 32 after the exclusion. const allTeams = makeTeams(40); const excluded = allTeams[0]; const reducedPool = allTeams.slice(1); // 39 teams — sampleField draws 32 from these const result = simulateOneMajor(reducedPool, undefined, qpConfig); expect(result.has(excluded.id)).toBe(false); }); it("excluding a dominant team from the pool means they never win", () => { const TRIALS = 200; // 40 teams: one dominant + 39 average. sampleField always draws 32. const dominantTeam = { id: "dominant", elo: 5000, rank: 1 }; const otherTeams = makeTeams(39, 1500, 2); const allPool = [dominantTeam, ...otherTeams]; const reducedPool = otherTeams; // dominant excluded let dominantWinsWithFull = 0; let dominantWinsWithReduced = 0; const firstPlaceQP = qpConfig.get(1) ?? 0; for (let i = 0; i < TRIALS; i++) { const fullResult = simulateOneMajor(allPool, undefined, qpConfig); if ((fullResult.get(dominantTeam.id) ?? 0) === firstPlaceQP) dominantWinsWithFull++; const reducedResult = simulateOneMajor(reducedPool, undefined, qpConfig); if ((reducedResult.get(dominantTeam.id) ?? 0) === firstPlaceQP) dominantWinsWithReduced++; } // Dominant team (Elo 5000 vs 1500) wins nearly every sampled major expect(dominantWinsWithFull).toBeGreaterThan(TRIALS * 0.8); // Excluded team never appears → 0 wins expect(dominantWinsWithReduced).toBe(0); }); it("per-event pool filtering is independent — exclusion in one event does not affect another", () => { // Verify that two separate filtered pools produce independent results. const allTeams = makeTeams(40); const excluded = allTeams[0]; const fullPool = allTeams; const reducedPool = allTeams.slice(1); // exclude team[0] from event 2 const resultEvent1 = simulateOneMajor(fullPool, undefined, qpConfig); const resultEvent2 = simulateOneMajor(reducedPool, undefined, qpConfig); expect(resultEvent1.has(excluded.id)).toBe(true); // present in event 1 expect(resultEvent2.has(excluded.id)).toBe(false); // excluded from event 2 }); }); // ─── simulateSwiss partial-stage locking (initialRecords) ───────────────────── describe("simulateSwiss with initialRecords (partial stage locking)", () => { it("locks in seeded results: 3-loss team stays eliminated, 3-win team stays advanced", () => { const teams = makeTeams(16); // Two pre-resolved teams keeps the remaining active count even. const initial = new Map([ ["team-0", { wins: 1, losses: 3 }], // eliminated, carries 1 win ["team-15", { wins: 3, losses: 2 }], // advanced, carries 2 losses ]); for (let i = 0; i < 50; i++) { const r = simulateSwiss(teams, false, false, initial); const elim0 = r.eliminated.find((t) => t.id === "team-0"); const adv15 = r.advanced.find((t) => t.id === "team-15"); expect(elim0).toBeDefined(); expect(elim0?.wins).toBe(1); // recorded win count preserved expect(r.advanced.some((t) => t.id === "team-0")).toBe(false); expect(adv15).toBeDefined(); expect(adv15?.losses).toBe(2); // recorded loss count preserved expect(r.eliminated.some((t) => t.id === "team-15")).toBe(false); } }); it("a fresh stage (empty initialRecords) still returns 8 advanced / 8 eliminated", () => { const r = simulateSwiss(makeTeams(16), false, false, new Map()); expect(r.advanced).toHaveLength(8); expect(r.eliminated).toHaveLength(8); }); }); // ─── reconcileAdvancers (ragged-snapshot repair, protecting locked teams) ───── const adv = (id: string, losses: number, rank: number): AdvancedTeam => ({ id, elo: 1500, rank, losses }); const elim = (id: string, wins: number, rank: number) => ({ id, elo: 1500, rank, wins }); describe("reconcileAdvancers", () => { it("is a no-op when the advancer count already matches the target", () => { const input = { advanced: [adv("a", 0, 1), adv("b", 1, 2)], eliminated: [elim("c", 2, 3)], }; expect(reconcileAdvancers(input, 2)).toBe(input); // same reference, untouched }); it("demotes simulated advancers before locked ones when over-filled", () => { // 3 advancers, target 2. The locked team is the weakest (most losses) so a // naive demotion would drop it; protection must keep it and demote a // simulated team instead. const input = { advanced: [adv("sim-strong", 0, 1), adv("sim-weak", 2, 3), adv("locked", 2, 2)], eliminated: [] as Array<{ id: string; elo: number; rank: number; wins: number }>, }; const result = reconcileAdvancers(input, 2, new Set(["locked"])); expect(result.advanced.map((t) => t.id).toSorted()).toEqual(["locked", "sim-strong"]); expect(result.eliminated.map((t) => t.id)).toEqual(["sim-weak"]); }); it("promotes non-locked eliminated teams before locked ones when under-filled", () => { // 1 advancer, target 2 → must promote one eliminated team. The locked // (real) elimination must be preserved; the non-locked team is promoted // even though it is the weaker (fewer wins) of the two. const input = { advanced: [adv("a", 0, 1)], eliminated: [elim("locked-strong", 2, 2), elim("sim-weak", 0, 3)], }; const result = reconcileAdvancers(input, 2, new Set(["locked-strong"])); expect(result.advanced.map((t) => t.id).toSorted()).toEqual(["a", "sim-weak"]); expect(result.eliminated.map((t) => t.id)).toEqual(["locked-strong"]); }); it("falls back to locked teams only when non-locked candidates run out", () => { // 3 advancers, target 1, all locked → forced to demote locked teams, // keeping the strongest (fewest losses). const input = { advanced: [adv("x", 2, 3), adv("y", 0, 1), adv("z", 1, 2)], eliminated: [] as Array<{ id: string; elo: number; rank: number; wins: number }>, }; const result = reconcileAdvancers(input, 1, new Set(["x", "y", "z"])); expect(result.advanced.map((t) => t.id)).toEqual(["y"]); // strongest kept }); }); // ─── simulateChampionsStage honoring a real bracket ─────────────────────────── function makeChampTeams(): AdvancedTeam[] { // a strongest … h weakest, all 0 losses. return ["a", "b", "c", "d", "e", "f", "g", "h"].map((id, i) => ({ id, elo: 2000 - i * 100, rank: i + 1, losses: 0, })); } /** Bracket where the weakest team (h) wins everything, contradicting Elo. */ function makeFullBracket(): BracketMatchInput[] { return [ { round: "Quarterfinals", matchNumber: 1, participant1Id: "a", participant2Id: "h", winnerId: "h", isComplete: true }, { round: "Quarterfinals", matchNumber: 2, participant1Id: "b", participant2Id: "g", winnerId: "g", isComplete: true }, { round: "Quarterfinals", matchNumber: 3, participant1Id: "c", participant2Id: "f", winnerId: "f", isComplete: true }, { round: "Quarterfinals", matchNumber: 4, participant1Id: "d", participant2Id: "e", winnerId: "e", isComplete: true }, { round: "Semifinals", matchNumber: 1, participant1Id: "h", participant2Id: "g", winnerId: "h", isComplete: true }, { round: "Semifinals", matchNumber: 2, participant1Id: "f", participant2Id: "e", winnerId: "f", isComplete: true }, { round: "Finals", matchNumber: 1, participant1Id: "h", participant2Id: "f", winnerId: "h", isComplete: true }, ]; } describe("simulateChampionsStage honoring a real bracket", () => { it("a fully completed bracket yields deterministic placements (ignores Elo)", () => { const teams = makeChampTeams(); const bracket = makeFullBracket(); for (let i = 0; i < 50; i++) { const result = simulateChampionsStage(teams, bracket); expect(result.placements.get("h")).toBe(1); // weakest team won it all expect(result.placements.get("f")).toBe(2); expect(result.placements.get("g")).toBe(3); // SF losers expect(result.placements.get("e")).toBe(3); for (const id of ["a", "b", "c", "d"]) { // QF losers expect(result.placements.get(id)).toBe(5); } } }); it("a partially completed bracket fixes decided rounds, randomizes only the rest", () => { const teams = makeChampTeams(); // QF + SF complete, Final undecided → finalists fixed (h, f), champion varies. const bracket = makeFullBracket().map((m) => m.round === "Finals" ? { ...m, winnerId: null, isComplete: false } : m ); const championSeen = new Set(); for (let i = 0; i < 100; i++) { const result = simulateChampionsStage(teams, bracket); // SF losers and QF losers are locked regardless of the Final. expect(result.placements.get("g")).toBe(3); expect(result.placements.get("e")).toBe(3); for (const id of ["a", "b", "c", "d"]) expect(result.placements.get(id)).toBe(5); // Champion/finalist are always the two SF winners. const champion = [...result.placements.entries()].find(([, p]) => p === 1)?.[0]; const finalist = [...result.placements.entries()].find(([, p]) => p === 2)?.[0]; expect(["h", "f"]).toContain(champion); expect(["h", "f"]).toContain(finalist); expect(champion).not.toBe(finalist); championSeen.add(result.placements.get("h") === 1 ? 1 : 0); } // Over 100 runs with the Final undecided, h should win at least once and lose // at least once (it's the weaker of the two finalists by Elo but not certain). expect(championSeen.size).toBeGreaterThanOrEqual(1); }); it("falls back to Elo-based seeding when no bracket is provided", () => { const teams = makeChampTeams(); let aWins = 0; for (let i = 0; i < 500; i++) { if (simulateChampionsStage(teams).placements.get("a") === 1) aWins++; } expect(aWins / 500).toBeGreaterThan(0.25); // strongest team wins well above 1/8 }); it("ignores recorded results when the bracket's teams are not this field", () => { // Bracket describes a different set of teams (x1…x8) than the actual // qualifiers (a…h), so it is not a usable real bracket: recorded winners // must not bleed into the Elo-seeded simulation of the real field. const teams = makeChampTeams(); const foreignBracket: BracketMatchInput[] = makeFullBracket().map((m) => ({ ...m, participant1Id: m.participant1Id ? `x-${m.participant1Id}` : null, participant2Id: m.participant2Id ? `x-${m.participant2Id}` : null, winnerId: m.winnerId ? `x-${m.winnerId}` : null, })); let aWins = 0; for (let i = 0; i < 500; i++) { const result = simulateChampionsStage(teams, foreignBracket); // All 8 real teams still get a placement (the bracket was not applied). expect(result.placements.size).toBe(8); if (result.placements.get("a") === 1) aWins++; } // Strongest real team wins well above 1/8 → Elo seeding drove it, not the // foreign bracket (which would have forced its own — absent — winner). expect(aWins / 500).toBeGreaterThan(0.25); }); }); // ─── simulateOneMajor full conditioning (stages + bracket + recorded QP) ────── /** * Stage results that fully lock the field down to 8 Champions Stage qualifiers * (team-24…team-31): team-0–7 out at stage 1, team-8–15 out at stage 2, * team-16–23 out at stage 3. */ function makeLockedStageResults(): StageResultsMap { const stageResults: StageResultsMap = new Map(); for (let i = 0; i < 8; i++) stageResults.set(`team-${i}`, { stageEntry: 1, stageEliminated: 1, stageEliminatedWins: 1 }); for (let i = 8; i < 16; i++) stageResults.set(`team-${i}`, { stageEntry: 1, stageEliminated: 2, stageEliminatedWins: 1 }); for (let i = 16; i < 24; i++) stageResults.set(`team-${i}`, { stageEntry: 2, stageEliminated: 3, stageEliminatedWins: 1 }); for (let i = 24; i < 32; i++) stageResults.set(`team-${i}`, { stageEntry: 3, stageEliminated: null, stageEliminatedWins: null }); return stageResults; } const m = (round: string, matchNumber: number, p1: string, p2: string, winner: string): BracketMatchInput => ({ round, matchNumber, participant1Id: p1, participant2Id: p2, winnerId: winner, isComplete: true }); /** Champions bracket among team-24…team-31 where team-31 wins everything. */ function makeQualifierBracket(): BracketMatchInput[] { return [ m("Quarterfinals", 1, "team-31", "team-24", "team-31"), m("Quarterfinals", 2, "team-30", "team-25", "team-30"), m("Quarterfinals", 3, "team-29", "team-26", "team-29"), m("Quarterfinals", 4, "team-28", "team-27", "team-28"), m("Semifinals", 1, "team-31", "team-30", "team-31"), m("Semifinals", 2, "team-29", "team-28", "team-29"), m("Finals", 1, "team-31", "team-29", "team-31"), ]; } const loss = (loser: string, winner: string): SwissMatchInput => ({ matchStage: 1, participant1Id: loser, participant2Id: winner, winnerId: winner }); describe("simulateOneMajor full conditioning", () => { const qpConfig = makeQPConfig(); it("honors a completed bracket: the recorded champion deterministically earns 1st-place QP", () => { const pool = makeTeams(32); const stageResults = makeLockedStageResults(); const bracket = makeQualifierBracket(); for (let run = 0; run < 10; run++) { const result = simulateOneMajor(pool, stageResults, qpConfig, { bracketMatches: bracket }); expect(result.get("team-31")).toBe(qpConfig.get(1)); // 500, the champion // team-29 lost the final → 2nd place QP (400). expect(result.get("team-29")).toBe(qpConfig.get(2)); // Stage 1 exits earn nothing. for (let i = 0; i < 8; i++) expect(result.get(`team-${i}`)).toBe(0); } }); it("uses recorded provisional QP verbatim for locked-eliminated teams", () => { const pool = makeTeams(32); const stageResults = makeLockedStageResults(); const recordedResults = new Map([["team-0", 123]]); // a stage-1 exit (normally 0) const result = simulateOneMajor(pool, stageResults, qpConfig, { recordedResults }); expect(result.get("team-0")).toBe(123); // recorded value overrides the derived 0 expect(result.get("team-1")).toBe(0); // no recorded entry → derived value stays }); it("locks in partial stage progress from Swiss match results", () => { const pool = makeTeams(32); // Stage entrants assigned, but NO eliminations recorded (stage 1 incomplete). const stageResults: StageResultsMap = new Map(); for (let i = 0; i < 16; i++) stageResults.set(`team-${i}`, { stageEntry: 1, stageEliminated: null, stageEliminatedWins: null }); for (let i = 16; i < 24; i++) stageResults.set(`team-${i}`, { stageEntry: 2, stageEliminated: null, stageEliminatedWins: null }); for (let i = 24; i < 32; i++) stageResults.set(`team-${i}`, { stageEntry: 3, stageEliminated: null, stageEliminatedWins: null }); // Swiss results give team-0 and team-1 three stage-1 losses each (eliminated). const swissMatches: SwissMatchInput[] = [ loss("team-0", "team-2"), loss("team-0", "team-3"), loss("team-0", "team-4"), loss("team-1", "team-5"), loss("team-1", "team-6"), loss("team-1", "team-7"), ]; for (let run = 0; run < 20; run++) { const result = simulateOneMajor(pool, stageResults, qpConfig, { swissMatches }); // Teams with 3 recorded stage-1 losses are eliminated → 0 QP, never champions. expect(result.get("team-0")).toBe(0); expect(result.get("team-1")).toBe(0); } }); });