diff --git a/app/services/simulations/__tests__/cs-major-simulator.test.ts b/app/services/simulations/__tests__/cs-major-simulator.test.ts index f57efad..fee4aae 100644 --- a/app/services/simulations/__tests__/cs-major-simulator.test.ts +++ b/app/services/simulations/__tests__/cs-major-simulator.test.ts @@ -5,9 +5,13 @@ import { sampleField, simulateSwiss, simulateChampionsStage, + calcStage3ExitQP, + simulateOneMajor, } from "../cs-major-simulator"; +import type { AdvancedTeam } from "../cs-major-simulator"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── -// Helper to build a pool of teams function makeTeams(n: number, baseElo = 1800, baseRank = 1) { return Array.from({ length: n }, (_, i) => ({ id: `team-${i}`, @@ -16,6 +20,29 @@ function makeTeams(n: number, baseElo = 1800, baseRank = 1) { })); } +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", () => { @@ -149,6 +176,16 @@ describe("simulateSwiss", () => { } }); + 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); @@ -167,6 +204,24 @@ describe("simulateSwiss", () => { 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 = [ @@ -193,12 +248,12 @@ describe("simulateSwiss", () => { describe("simulateChampionsStage", () => { it("throws if not exactly 8 teams", () => { - expect(() => simulateChampionsStage(makeTeams(7))).toThrow(); - expect(() => simulateChampionsStage(makeTeams(9))).toThrow(); + expect(() => simulateChampionsStage(makeAdvancedTeams(7))).toThrow(); + expect(() => simulateChampionsStage(makeAdvancedTeams(9))).toThrow(); }); it("assigns placements to all 8 teams", () => { - const teams = makeTeams(8); + const teams = makeAdvancedTeams(8); const result = simulateChampionsStage(teams); expect(result.placements.size).toBe(8); for (const team of teams) { @@ -206,25 +261,51 @@ describe("simulateChampionsStage", () => { } }); - it("has exactly one 1st, one 2nd, two 3rd/4th, four 5th-8th", () => { - const teams = makeTeams(8); + 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 && p <= 4)).toHaveLength(2); - expect(placements.filter(p => p >= 5 && p <= 8)).toHaveLength(4); + 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)", () => { - // Use a clear Elo spread so team-0 has a decisive advantage. - // makeTeams(8) only gives a 70-pt gap (1800→1730) which puts the - // true win rate right at the 0.2 threshold — extremely flaky. - // 100-pt steps (1800→1100) give team-0 a ~40% win rate, well clear of 0.25. - const teams = Array.from({ length: 8 }, (_, i) => ({ + 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++) { @@ -235,3 +316,163 @@ describe("simulateChampionsStage", () => { expect(wins / 1000).toBeGreaterThan(0.25); }); }); + +// ─── calcStage3ExitQP ───────────────────────────────────────────────────────── + +describe("calcStage3ExitQP", () => { + function makeStage3QPConfig(): Map { + // Placements 9–16 with descending QP: 9→80, 10→70, ..., 16→10 + const config = new Map(); + for (let i = 9; i <= 16; i++) { + config.set(i, (17 - i) * 10); + } + return config; + } + + 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); + + expect(result.get("high")!).toBeGreaterThan(result.get("mid")!); + expect(result.get("mid")!).toBeGreaterThan(result.get("low")!); + }); +}); + +// ─── 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); + } + }); +}); diff --git a/app/services/simulations/cs-major-simulator.ts b/app/services/simulations/cs-major-simulator.ts index 425606a..538a929 100644 --- a/app/services/simulations/cs-major-simulator.ts +++ b/app/services/simulations/cs-major-simulator.ts @@ -5,9 +5,11 @@ * accumulate across both majors; final QP totals determine fantasy placements (1st–8th). * * CS2 Major format (32 teams, 3 Swiss stages + playoffs): - * Stage 1 (Opening Stage): 16 teams, Swiss, Bo1 matches - * Stage 2 (Elimination Stage): 16 teams (8 Challengers + 8 from Stage 1), Swiss, Bo1/Bo3 - * Stage 3 (Decider Stage): 16 teams (8 Legends + 8 from Stage 2), Swiss, all Bo3 + * Stage 1 (Opening Stage): 16 teams, Swiss, all Bo1 + * Stage 2 (Challengers Stage): 16 teams (8 Challengers + 8 from Stage 1), Swiss, + * Bo1 normally, Bo3 for decisive matches (either team + * can advance or be eliminated) + * Stage 3 (Legends Stage): 16 teams (8 Legends + 8 from Stage 2), Swiss, all Bo3 * Champions Stage: 8 teams, single-elimination (QF Bo3, SF Bo3, GF Bo5) * * Field selection (per iteration): @@ -21,12 +23,22 @@ * - With explicit stage data: use stageEntry from cs2MajorStageResults * - Without: top 8 by world ranking → Stage 3, next 8 → Stage 2, bottom 16 → Stage 1 * + * Champions Stage seeding: + * - Seeds are assigned by Stage 3 performance: fewer losses = higher seed. + * - World rank is used as tiebreaker within the same loss count. + * - When stage data is used (stage3Complete), losses are unknown and rank is used directly. + * * QP for Stage 3 exits (placements 9–16) is sub-ranked by W-L record: * - 2-3 teams → higher placements within 9–16 * - 1-3 teams → middle placements * - 0-3 teams → lowest placements within 9–16 * QP is tie-split (averaged) within each W-L group. * + * QP for Champions Stage: + * - QF losers (4 teams, placements 5–8): tie-split averaged across slots 5–8. + * - SF losers (2 teams, placements 3–4): tie-split averaged across slots 3–4. + * - Finalist and Champion earn their exact placement QP. + * * Stages 1 and 2 exits (placements 17–32) earn 0 QP. */ @@ -109,6 +121,15 @@ interface TeamWithElo { rank: number; // HLTV world ranking (lower = better) } +/** + * A team that has advanced through a Swiss stage, with their loss count at advancement. + * Used for Champions Stage seeding: fewer losses = higher seed (better stage performance). + */ +export interface AdvancedTeam extends TeamWithElo { + /** Number of losses accumulated when advancing (0 = 3-0, 1 = 3-1, 2 = 3-2). */ + losses: number; +} + /** * Sample a 32-team field from the pool. * - Top GUARANTEED_COUNT (12) by rank are always included. @@ -153,8 +174,8 @@ function weightedSampleWithoutReplacement( // ─── Swiss stage simulation ─────────────────────────────────────────────────── interface SwissResult { - /** Teams that advanced (3 wins). */ - advanced: Array<{ id: string; elo: number; rank: number }>; + /** Teams that advanced (3 wins), with their loss count at advancement. */ + advanced: AdvancedTeam[]; /** Teams eliminated, with their win count at time of elimination. */ eliminated: Array<{ id: string; elo: number; rank: number; wins: number }>; } @@ -165,18 +186,30 @@ interface SwissResult { * randomly within each record group. First to 3 wins advances; first to 3 losses * is eliminated. * - * @param teams - Teams entering this stage. - * @param bo3 - If true, use Bo3 series win probability; otherwise Bo1 (single game). - * @returns advanced and eliminated arrays. + * @param teams - Teams entering this stage (must be an even count). + * @param bo3 - If true, all matches are Bo3; otherwise Bo1. + * @param decisiveMatchesBo3 - If true, matches where either team can advance or + * be eliminated (≥2 wins or ≥2 losses) are promoted to + * Bo3 regardless of the `bo3` flag. Used for Stage 2. + * @returns advanced (with losses) and eliminated (with wins) arrays. * * Exported for unit testing. */ -export function simulateSwiss(teams: TeamWithElo[], bo3: boolean): SwissResult { +export function simulateSwiss( + teams: TeamWithElo[], + bo3: boolean, + decisiveMatchesBo3 = false +): SwissResult { + if (teams.length === 0) return { advanced: [], eliminated: [] }; + if (teams.length % 2 !== 0) { + throw new Error(`simulateSwiss requires an even number of teams, got ${teams.length}`); + } + const wins = new Map(teams.map((t) => [t.id, 0])); const losses = new Map(teams.map((t) => [t.id, 0])); const eloMap = new Map(teams.map((t) => [t.id, t.elo])); - const advanced: SwissResult["advanced"] = []; + const advanced: AdvancedTeam[] = []; const eliminated: SwissResult["eliminated"] = []; const advancedIds = new Set(); const eliminatedIds = new Set(); @@ -200,9 +233,17 @@ export function simulateSwiss(teams: TeamWithElo[], bo3: boolean): SwissResult { for (const [t1, t2] of pairs) { const e1 = eloMap.get(t1.id) ?? FALLBACK_ELO; const e2 = eloMap.get(t2.id) ?? FALLBACK_ELO; - const p = bo3 + + // A match is "decisive" if either team can advance (2W) or be eliminated (2L) + const w1 = wins.get(t1.id) ?? 0; + const l1 = losses.get(t1.id) ?? 0; + const w2 = wins.get(t2.id) ?? 0; + const l2 = losses.get(t2.id) ?? 0; + const isDecisive = decisiveMatchesBo3 && (w1 >= 2 || l1 >= 2 || w2 >= 2 || l2 >= 2); + + const p = (bo3 || isDecisive) ? seriesWinProb(gameWinProb(e1, e2), 2) // Bo3: first to 2 - : gameWinProb(e1, e2); // Bo1: single game + : gameWinProb(e1, e2); // Bo1: single game const t1Wins = Math.random() < p; const winnerId = t1Wins ? t1.id : t2.id; @@ -214,7 +255,7 @@ export function simulateSwiss(teams: TeamWithElo[], bo3: boolean): SwissResult { if ((wins.get(winnerId) ?? 0) >= 3) { advancedIds.add(winnerId); const t = teamById.get(winnerId); - if (t) advanced.push({ id: t.id, elo: t.elo, rank: t.rank }); + if (t) advanced.push({ id: t.id, elo: t.elo, rank: t.rank, losses: losses.get(winnerId) ?? 0 }); } if ((losses.get(loserId) ?? 0) >= 3) { eliminatedIds.add(loserId); @@ -272,24 +313,36 @@ function pairGroups(groups: Map): Array<[TeamWithElo, Tea // ─── Champions Stage (8-team single-elimination bracket) ───────────────────── interface ChampionsResult { - placements: Map; // participantId → placement (1–8) + /** + * participantId → placement group: + * 1 = champion, 2 = finalist + * 3 = both SF losers (tie-split QP across slots 3–4 in the caller) + * 5 = all 4 QF losers (tie-split QP across slots 5–8 in the caller) + */ + placements: Map; } /** * Simulate the Champions Stage 8-team single-elimination bracket. - * Seeds are assigned by rank within the 8 advancing teams. - * Rounds: QF (Bo3), SF (Bo3), GF (Bo5). + * Seeds are assigned by stage performance (losses ascending), with world rank + * as tiebreaker. Rounds: QF (Bo3), SF (Bo3), GF (Bo5). + * + * QF losers are all assigned placement 5 (tie-split across slots 5–8 by caller). + * SF losers are both assigned placement 3 (tie-split across slots 3–4 by caller). * Exported for unit testing. */ -export function simulateChampionsStage(teams: TeamWithElo[]): ChampionsResult { +export function simulateChampionsStage(teams: AdvancedTeam[]): ChampionsResult { if (teams.length !== 8) { throw new Error(`simulateChampionsStage expects exactly 8 teams, got ${teams.length}`); } - // Seed by rank ascending - const seeded = [...teams].toSorted((a, b) => a.rank - b.rank); + // Seed by stage performance: fewer losses = higher seed; rank as tiebreaker + const seeded = [...teams].toSorted((a, b) => + a.losses !== b.losses ? a.losses - b.losses : a.rank - b.rank + ); + // Standard 8-team seeding: 1v8, 4v5, 3v6, 2v7 - const bracket: [TeamWithElo, TeamWithElo][] = [ + const bracket: [AdvancedTeam, AdvancedTeam][] = [ [seeded[0], seeded[7]], [seeded[3], seeded[4]], [seeded[2], seeded[5]], @@ -298,33 +351,30 @@ export function simulateChampionsStage(teams: TeamWithElo[]): ChampionsResult { const placements = new Map(); - const simMatch = (t1: TeamWithElo, t2: TeamWithElo, winsNeeded: number): TeamWithElo => { + const simMatch = (t1: AdvancedTeam, t2: AdvancedTeam, winsNeeded: number): AdvancedTeam => { const p = seriesWinProb(gameWinProb(t1.elo, t2.elo), winsNeeded); return Math.random() < p ? t1 : t2; }; // Quarterfinals (Bo3 = first to 2) - const sfTeams: TeamWithElo[] = []; + const sfTeams: AdvancedTeam[] = []; for (const [t1, t2] of bracket) { const winner = simMatch(t1, t2, 2); const loser = winner.id === t1.id ? t2 : t1; sfTeams.push(winner); - placements.set(loser.id, 7); // QF losers: 5th–8th (averaged to 6.5, use 7 for counting) + placements.set(loser.id, 5); // QF losers: all get placement 5 (tie-split 5–8 by caller) } - // Assign QF losers to placements 5–8 - const qfLosers = [...placements.keys()]; - qfLosers.forEach((id, i) => placements.set(id, 5 + i)); // Semifinals (Bo3 = first to 2) - const finalTeams: TeamWithElo[] = []; - const sfLosers: TeamWithElo[] = []; + const finalTeams: AdvancedTeam[] = []; + const sfLosers: AdvancedTeam[] = []; for (let i = 0; i < sfTeams.length; i += 2) { const winner = simMatch(sfTeams[i], sfTeams[i + 1], 2); const loser = winner.id === sfTeams[i].id ? sfTeams[i + 1] : sfTeams[i]; finalTeams.push(winner); sfLosers.push(loser); } - sfLosers.forEach((t, i) => placements.set(t.id, 3 + i)); + sfLosers.forEach((t) => placements.set(t.id, 3)); // SF losers: both get placement 3 (tie-split 3–4) // Grand Final (Bo5 = first to 3) const champion = simMatch(finalTeams[0], finalTeams[1], 3); @@ -342,10 +392,12 @@ export function simulateChampionsStage(teams: TeamWithElo[]): ChampionsResult { * Teams are ranked within 9–16 by wins (2-3 > 1-3 > 0-3). * Within the same wins count, QP is tie-split (averaged) across placement slots. * - * qpConfig: array indexed by placement (1-indexed), qpConfig[placement] = QP value. - * Placements 9–16 correspond to indices 9–16. + * qpConfig: map from placement (1-indexed) to QP value. + * Placements 9–16 correspond to stage 3 exit slots. + * + * Exported for unit testing. */ -function calcStage3ExitQP( +export function calcStage3ExitQP( elimTeams: Array<{ id: string; wins: number }>, qpConfig: Map ): Map { @@ -411,16 +463,16 @@ export class CSMajorSimulator implements Simulator { } } - // Build pool: all participants with Elo ratings, sorted by rank + // Build pool: all participants, using FALLBACK_ELO for those without ratings, sorted by rank const pool: TeamWithElo[] = participantIds - .filter((id) => eloMap.has(id)) .map((id) => { const e = eloMap.get(id); return { id, elo: e?.elo ?? FALLBACK_ELO, rank: e?.rank ?? 9999 }; }) .toSorted((a, b) => a.rank - b.rank); - if (pool.length === 0) { + const hasAnyElo = participantIds.some((id) => eloMap.has(id)); + if (!hasAnyElo) { throw new Error( `No participants with Elo ratings found for sports season ${sportsSeasonId}. ` + `Enter Elo ratings via the CS Elo admin page before simulating.` @@ -470,18 +522,43 @@ export class CSMajorSimulator implements Simulator { } } - // 6. Load stage results for each event (for explicit field composition). - const eventStageResults = new Map>>(); - for (const event of events) { - const stageResults = await getCs2StageResultsMapForEvent(event.id); - if (stageResults.size > 0) { - eventStageResults.set(event.id, stageResults); - } - } + // 6. Load stage results for all events in parallel. + const stageResultEntries = await Promise.all( + events.map(async (event) => { + const results = await getCs2StageResultsMapForEvent(event.id); + return [event.id, results] as const; + }) + ); + const eventStageResults = new Map( + stageResultEntries.filter(([, r]) => r.size > 0) + ); const incompleteEvents = events.filter((e) => !e.isComplete); - // 7. Monte Carlo loop. + // 7. Short-circuit: if all events are complete, return deterministic probabilities + // based on actual QP totals — no simulation needed. + if (incompleteEvents.length === 0) { + const ranked = [...actualQPMap.entries()].toSorted((a, b) => b[1] - a[1]); + return participantIds.map((participantId) => { + const rank = ranked.findIndex(([id]) => id === participantId) + 1; // 1-indexed + return { + participantId, + probabilities: { + probFirst: rank === 1 ? 1.0 : 0.0, + probSecond: rank === 2 ? 1.0 : 0.0, + probThird: rank === 3 ? 1.0 : 0.0, + probFourth: rank === 4 ? 1.0 : 0.0, + probFifth: rank === 5 ? 1.0 : 0.0, + probSixth: rank === 6 ? 1.0 : 0.0, + probSeventh: rank === 7 ? 1.0 : 0.0, + probEighth: rank === 8 ? 1.0 : 0.0, + }, + source: "cs2_major_qualifying_points_monte_carlo", + }; + }); + } + + // 8. Monte Carlo loop. const counts: number[][] = Array.from({ length: participantIds.length }, () => Array(8).fill(0)); const idToIndex = new Map(participantIds.map((id, i) => [id, i])); @@ -505,7 +582,7 @@ export class CSMajorSimulator implements Simulator { } } - // 8. Convert counts to probabilities. + // 9. Convert counts to probabilities. return participantIds.map((participantId, i) => ({ participantId, probabilities: { @@ -525,17 +602,23 @@ export class CSMajorSimulator implements Simulator { // ─── Single major simulation ─────────────────────────────────────────────────── +/** Average QP across a range of placement slots (for tie-splitting). */ +function avgSlotQP(slots: number[], qpConfig: Map): number { + return slots.reduce((sum, s) => sum + (qpConfig.get(s) ?? 0), 0) / slots.length; +} + /** * Simulate one CS2 Major and return QP earned per participant. * * If stage results are provided, uses them to determine field composition * and to lock in results for any completed stages, only simulating the - * remaining stages. A stage is considered complete when the expected 8 + * remaining stages. A stage is considered complete when at least 8 * eliminations for that stage have been recorded. * * Returns a Map from participantId → QP earned in this major. + * Exported for unit testing. */ -function simulateOneMajor( +export function simulateOneMajor( pool: TeamWithElo[], stageResults: Map | undefined, qpConfig: Map @@ -544,20 +627,21 @@ function simulateOneMajor( const poolById = new Map(pool.map((t) => [t.id, t])); // ── Determine field and stage assignments ───────────────────────────────── + // stage2Direct / stage3Direct are AdvancedTeam[] (losses = 0: no prior Swiss stage) let stage1Teams: TeamWithElo[]; - let stage2Direct: TeamWithElo[]; - let stage3Direct: TeamWithElo[]; + let stage2Direct: AdvancedTeam[]; + let stage3Direct: AdvancedTeam[]; if (stageResults && stageResults.size > 0) { const s1: TeamWithElo[] = []; - const s2: TeamWithElo[] = []; - const s3: TeamWithElo[] = []; + const s2: AdvancedTeam[] = []; + const s3: AdvancedTeam[] = []; for (const [participantId, result] of stageResults) { const team = poolById.get(participantId); if (!team) continue; if (result.stageEntry === 1) s1.push(team); - else if (result.stageEntry === 2) s2.push(team); - else if (result.stageEntry === 3) s3.push(team); + else if (result.stageEntry === 2) s2.push({ ...team, losses: 0 }); + else if (result.stageEntry === 3) s3.push({ ...team, losses: 0 }); } stage1Teams = s1; stage2Direct = s2; @@ -565,14 +649,13 @@ function simulateOneMajor( } else { const field = sampleField(pool, FIELD_SIZE); const sorted = field.toSorted((a, b) => a.rank - b.rank); - stage3Direct = sorted.slice(0, 8); - stage2Direct = sorted.slice(8, 16); + stage3Direct = sorted.slice(0, 8).map((t) => ({ ...t, losses: 0 })); + stage2Direct = sorted.slice(8, 16).map((t) => ({ ...t, losses: 0 })); stage1Teams = sorted.slice(16, 32); } // ── Lock in known stage results ─────────────────────────────────────────── - // A stage is "complete" when exactly 8 teams have been recorded as - // eliminated at that stage (the expected output of each Swiss stage). + // A stage is "complete" when at least 8 teams have been eliminated at that stage. const eliminatedAtStage = (stageNum: number) => stageResults ? [...stageResults.entries()] @@ -589,18 +672,20 @@ function simulateOneMajor( const stage2Elim = eliminatedAtStage(2); const stage3Elim = eliminatedAtStage(3); - const stage1Complete = stage1Elim.length === 8; - const stage2Complete = stage2Elim.length === 8; - const stage3Complete = stage3Elim.length === 8; + const stage1Complete = stage1Elim.length >= 8; + const stage2Complete = stage2Elim.length >= 8; + const stage3Complete = stage3Elim.length >= 8; - // ── Stage 1 ─────────────────────────────────────────────────────────────── - let stage1Advanced: TeamWithElo[]; + // ── Stage 1 (Opening) — all Bo1 ─────────────────────────────────────────── + let stage1Advanced: AdvancedTeam[]; let stage1EliminatedFinal: Array<{ id: string; elo: number; rank: number; wins: number }>; if (stage1Complete) { - // Use known results: teams that entered Stage 1 but weren't eliminated there advanced const stage1EliminatedIds = new Set(stage1Elim.map((t) => t.id)); - stage1Advanced = stage1Teams.filter((t) => !stage1EliminatedIds.has(t.id)); + // Loss count from stage data is unknown; use 2 as conservative fallback + stage1Advanced = stage1Teams + .filter((t) => !stage1EliminatedIds.has(t.id)) + .map((t): AdvancedTeam => ({ ...t, losses: 2 })); stage1EliminatedFinal = stage1Elim; } else { const result = simulateSwiss(stage1Teams, false); @@ -608,32 +693,37 @@ function simulateOneMajor( stage1EliminatedFinal = result.eliminated; } - // ── Stage 2 ─────────────────────────────────────────────────────────────── - const stage2Teams = [...stage2Direct, ...stage1Advanced]; - let stage2Advanced: TeamWithElo[]; + // ── Stage 2 (Challengers) — Bo1, Bo3 for decisive matches ──────────────── + const stage2Teams: AdvancedTeam[] = [...stage2Direct, ...stage1Advanced]; + let stage2Advanced: AdvancedTeam[]; let stage2EliminatedFinal: Array<{ id: string; elo: number; rank: number; wins: number }>; if (stage2Complete) { const stage2EliminatedIds = new Set(stage2Elim.map((t) => t.id)); - stage2Advanced = stage2Teams.filter((t) => !stage2EliminatedIds.has(t.id)); + stage2Advanced = stage2Teams + .filter((t) => !stage2EliminatedIds.has(t.id)) + .map((t): AdvancedTeam => ({ ...t, losses: 2 })); stage2EliminatedFinal = stage2Elim; } else { - const result = simulateSwiss(stage2Teams, false); + const result = simulateSwiss(stage2Teams, false, true); // decisive matches → Bo3 stage2Advanced = result.advanced; stage2EliminatedFinal = result.eliminated; } - // ── Stage 3 ─────────────────────────────────────────────────────────────── - const stage3Teams = [...stage3Direct, ...stage2Advanced]; - let champTeams: TeamWithElo[]; + // ── Stage 3 (Legends) — all Bo3 ────────────────────────────────────────── + const stage3Teams: AdvancedTeam[] = [...stage3Direct, ...stage2Advanced]; + let champTeams: AdvancedTeam[]; let stage3EliminatedFinal: Array<{ id: string; elo: number; rank: number; wins: number }>; if (stage3Complete) { const stage3EliminatedIds = new Set(stage3Elim.map((t) => t.id)); - champTeams = stage3Teams.filter((t) => !stage3EliminatedIds.has(t.id)); + // Stage 3 loss counts are not available from stage data; fall back to rank-based seeding + champTeams = stage3Teams + .filter((t) => !stage3EliminatedIds.has(t.id)) + .map((t): AdvancedTeam => ({ ...t, losses: 2 })); stage3EliminatedFinal = stage3Elim; } else { - const result = simulateSwiss(stage3Teams, true); + const result = simulateSwiss(stage3Teams, true); // all Bo3 champTeams = result.advanced; stage3EliminatedFinal = result.eliminated; } @@ -641,9 +731,19 @@ function simulateOneMajor( // ── Champions Stage ─────────────────────────────────────────────────────── const champResult = simulateChampionsStage(champTeams); - // ── Assign QP ───────────────────────────────────────────────────────────── + // ── Assign QP — tie-split QF losers (5–8) and SF losers (3–4) ──────────── + // Group placements: placement 5 = QF losers, placement 3 = SF losers + const byPlacement = new Map(); for (const [pid, placement] of champResult.placements) { - qpMap.set(pid, qpConfig.get(placement) ?? 0); + if (!byPlacement.has(placement)) byPlacement.set(placement, []); + byPlacement.get(placement)!.push(pid); + } + for (const [placement, pids] of byPlacement) { + const slots = Array.from({ length: pids.length }, (_, i) => placement + i); + const avgQP = avgSlotQP(slots, qpConfig); + for (const pid of pids) { + qpMap.set(pid, avgQP); + } } const stage3ExitQP = calcStage3ExitQP(stage3EliminatedFinal, qpConfig);