Fix CS Major simulator bugs and accuracy issues (#276)

* Fix CS Major simulator bugs and accuracy issues (fixes #275)

- Fix crash when pool teams are missing Elo ratings by including all
  participants with FALLBACK_ELO, ensuring Champions Stage always gets
  exactly 8 teams
- Add AdvancedTeam type tracking losses-at-advancement; seed Champions
  Stage by stage performance (fewer losses = higher seed) instead of
  world rank alone
- Promote decisive Stage 2 matches (either team at ≥2W or ≥2L) to Bo3,
  matching the real Challengers Stage format
- Tie-split QP for QF losers (slots 5–8) and SF losers (slots 3–4)
  instead of assigning arbitrary individual placements
- Change stage-complete threshold from === 8 to >= 8 for robustness
- Validate even team count in simulateSwiss to prevent silent infinite loops
- Short-circuit Monte Carlo loop when all events are complete, returning
  deterministic 0/1 probabilities
- Parallelize stage results DB fetches with Promise.all
- Export calcStage3ExitQP and simulateOneMajor; add test coverage for
  both, plus new simulateSwiss and simulateChampionsStage edge cases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix oxlint violations in CS Major simulator

- Replace non-null assertion (!) with null-safe guard in simulateOneMajor
- Replace non-null assertions in test expectations with nullish coalescing
- Move makeStage3QPConfig out of describe block (no captured variables)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-04-07 17:28:24 -04:00 committed by GitHub
parent 102cb781ad
commit ad49d6246d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 436 additions and 91 deletions

View file

@ -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<number, number> {
const config = new Map<number, number>();
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,166 @@ describe("simulateChampionsStage", () => {
expect(wins / 1000).toBeGreaterThan(0.25);
});
});
// ─── calcStage3ExitQP ─────────────────────────────────────────────────────────
// Placements 916 with descending QP: 9→80, 10→70, ..., 16→10
function makeStage3QPConfig(): Map<number, number> {
const config = new Map<number, number>();
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 916: (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<string, {
stageEntry: number;
stageEliminated: number | null;
stageEliminatedWins: number | null;
}>();
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);
}
});
});

View file

@ -5,9 +5,11 @@
* accumulate across both majors; final QP totals determine fantasy placements (1st8th).
*
* 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 916) is sub-ranked by W-L record:
* - 2-3 teams higher placements within 916
* - 1-3 teams middle placements
* - 0-3 teams lowest placements within 916
* QP is tie-split (averaged) within each W-L group.
*
* QP for Champions Stage:
* - QF losers (4 teams, placements 58): tie-split averaged across slots 58.
* - SF losers (2 teams, placements 34): tie-split averaged across slots 34.
* - Finalist and Champion earn their exact placement QP.
*
* Stages 1 and 2 exits (placements 1732) 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<T>(
// ─── 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<string, number>(teams.map((t) => [t.id, 0]));
const losses = new Map<string, number>(teams.map((t) => [t.id, 0]));
const eloMap = new Map<string, number>(teams.map((t) => [t.id, t.elo]));
const advanced: SwissResult["advanced"] = [];
const advanced: AdvancedTeam[] = [];
const eliminated: SwissResult["eliminated"] = [];
const advancedIds = new Set<string>();
const eliminatedIds = new Set<string>();
@ -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<string, TeamWithElo[]>): Array<[TeamWithElo, Tea
// ─── Champions Stage (8-team single-elimination bracket) ─────────────────────
interface ChampionsResult {
placements: Map<string, number>; // participantId → placement (18)
/**
* participantId placement group:
* 1 = champion, 2 = finalist
* 3 = both SF losers (tie-split QP across slots 34 in the caller)
* 5 = all 4 QF losers (tie-split QP across slots 58 in the caller)
*/
placements: Map<string, number>;
}
/**
* 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 58 by caller).
* SF losers are both assigned placement 3 (tie-split across slots 34 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<string, number>();
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: 5th8th (averaged to 6.5, use 7 for counting)
placements.set(loser.id, 5); // QF losers: all get placement 5 (tie-split 58 by caller)
}
// Assign QF losers to placements 58
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 34)
// 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 916 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 916 correspond to indices 916.
* qpConfig: map from placement (1-indexed) to QP value.
* Placements 916 correspond to stage 3 exit slots.
*
* Exported for unit testing.
*/
function calcStage3ExitQP(
export function calcStage3ExitQP(
elimTeams: Array<{ id: string; wins: number }>,
qpConfig: Map<number, number>
): Map<string, number> {
@ -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<string, Awaited<ReturnType<typeof getCs2StageResultsMapForEvent>>>();
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<string, number>(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, number>): 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<string, { stageEntry: number; stageEliminated: number | null; stageEliminatedWins: number | null }> | undefined,
qpConfig: Map<number, number>
@ -544,20 +627,21 @@ function simulateOneMajor(
const poolById = new Map<string, TeamWithElo>(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,20 @@ function simulateOneMajor(
// ── Champions Stage ───────────────────────────────────────────────────────
const champResult = simulateChampionsStage(champTeams);
// ── Assign QP ─────────────────────────────────────────────────────────────
// ── Assign QP — tie-split QF losers (58) and SF losers (34) ────────────
// Group placements: placement 5 = QF losers, placement 3 = SF losers
const byPlacement = new Map<number, string[]>();
for (const [pid, placement] of champResult.placements) {
qpMap.set(pid, qpConfig.get(placement) ?? 0);
if (!byPlacement.has(placement)) byPlacement.set(placement, []);
const group = byPlacement.get(placement);
if (group) group.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);