/** * CS2 Major Qualifying Points Simulator * * Monte Carlo simulation of the 2 CS2 Majors per year. Qualifying points (QP) * 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 * Champions Stage: 8 teams, single-elimination (QF Bo3, SF Bo3, GF Bo5) * * Field selection (per iteration): * - Top 12 participants by world ranking are always in the simulated field. * - Remaining spots (up to 32 total) are sampled from the rest of the pool, * weighted by 1/rank (lower rank = higher inclusion probability). * - If cs2MajorStageResults records exist for an event, those explicit * stage assignments are used instead of sampling/rank inference. * * Stage assignment within the 32-team field: * - With explicit stage data: use stageEntry from cs2MajorStageResults * - Without: top 8 by world ranking → Stage 3, next 8 → Stage 2, bottom 16 → Stage 1 * * 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. * * Stages 1 and 2 exits (placements 17–32) earn 0 QP. */ import { database } from "~/database/context"; import { eq, and, inArray } from "drizzle-orm"; import * as schema from "~/database/schema"; import { getQPConfig } from "~/models/qualifying-points"; import { getCs2StageResultsMapForEvent } from "~/models/cs2-major-stage"; import type { Simulator, SimulationResult } from "./types"; // ─── Simulation parameters ──────────────────────────────────────────────────── const NUM_SIMULATIONS = 10_000; /** Total field size per CS2 Major. */ const FIELD_SIZE = 32; /** Number of teams guaranteed in the simulated field (always included). */ const GUARANTEED_COUNT = 12; /** * Elo divisor for per-game win probability. * Standard chess Elo uses 400. CS2 maps well to single-game level. * 200-pt gap ≈ 76% win probability; 400-pt gap ≈ 91%. */ const ELO_DIVISOR = 400; /** Fallback Elo for teams with no stored rating. */ const FALLBACK_ELO = 1500; // ─── Math helpers ───────────────────────────────────────────────────────────── /** * Per-game win probability for team 1 vs team 2. * Exported for unit testing. */ export function gameWinProb(elo1: number, elo2: number): number { return 1 / (1 + Math.pow(10, (elo2 - elo1) / ELO_DIVISOR)); } /** * Match win probability using the Bernoulli series model. * For a best-of-(2S-1) match (first to S wins): * P(win) = Σ_{k=0}^{S-1} C(S-1+k, k) × p^S × (1-p)^k * Exported for unit testing. */ export function seriesWinProb(p: number, winsNeeded: number): number { let prob = 0; for (let k = 0; k < winsNeeded; k++) { prob += binomialCoeff(winsNeeded - 1 + k, k) * Math.pow(p, winsNeeded) * Math.pow(1 - p, k); } return prob; } /** Binomial coefficient C(n, k). */ function binomialCoeff(n: number, k: number): number { if (k === 0) return 1; if (k > n - k) k = n - k; let result = 1; for (let i = 0; i < k; i++) { result = (result * (n - i)) / (i + 1); } return result; } /** Fisher-Yates in-place shuffle. Returns the array for chaining. */ function shuffle(arr: T[]): T[] { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } // ─── Field selection ────────────────────────────────────────────────────────── interface TeamWithElo { id: string; elo: number; rank: number; // HLTV world ranking (lower = better) } /** * Sample a 32-team field from the pool. * - Top GUARANTEED_COUNT (12) by rank are always included. * - Remaining spots are weighted-randomly sampled from the rest, weight = 1/rank. * - If pool.length <= FIELD_SIZE, returns all teams. * Exported for unit testing. */ export function sampleField( pool: TeamWithElo[], fieldSize: number = FIELD_SIZE ): TeamWithElo[] { const sorted = [...pool].toSorted((a, b) => a.rank - b.rank); if (sorted.length <= fieldSize) return sorted; const guaranteed = sorted.slice(0, GUARANTEED_COUNT); const rest = sorted.slice(GUARANTEED_COUNT); const needed = fieldSize - guaranteed.length; if (needed <= 0) return guaranteed; if (rest.length <= needed) return [...guaranteed, ...rest]; // Weighted sample without replacement, weight = 1/rank const weights = rest.map((t) => 1 / t.rank); const sampled = weightedSampleWithoutReplacement(rest, weights, needed); return [...guaranteed, ...sampled]; } /** Weighted sampling without replacement using the Gumbel-max trick. */ function weightedSampleWithoutReplacement( items: T[], weights: number[], count: number ): T[] { const keys = weights.map((w) => -Math.log(Math.random()) / w); const indexed = items.map((item, i) => ({ item, key: keys[i] })); const sortedIndexed = indexed.toSorted((a, b) => a.key - b.key); return sortedIndexed.slice(0, count).map((x) => x.item); } // ─── Swiss stage simulation ─────────────────────────────────────────────────── interface SwissResult { /** Teams that advanced (3 wins). */ advanced: Array<{ id: string; elo: number; rank: number }>; /** Teams eliminated, with their win count at time of elimination. */ eliminated: Array<{ id: string; elo: number; rank: number; wins: number }>; } /** * Simulate one Swiss-format stage. * Teams are grouped by their (wins, losses) record each round. Teams are paired * 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. * * Exported for unit testing. */ export function simulateSwiss(teams: TeamWithElo[], bo3: boolean): SwissResult { 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 eliminated: SwissResult["eliminated"] = []; const advancedIds = new Set(); const eliminatedIds = new Set(); const teamById = new Map(teams.map((t) => [t.id, t])); // Run rounds until every team has reached 3W or 3L while (advancedIds.size + eliminatedIds.size < teams.length) { // Collect active teams grouped by (W, L) record const active = teams.filter((t) => !advancedIds.has(t.id) && !eliminatedIds.has(t.id)); const groups = groupByRecord(active, wins, losses); // If a group has an odd number, move one team to the nearest adjacent group // (simplified: skip teams that can't be paired — they sit out this round) const pairs = pairGroups(groups); // Safety: if no pairs can be formed (shouldn't happen with even team counts // but guards against an infinite loop if an odd active count somehow occurs) if (pairs.length === 0) break; 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 ? seriesWinProb(gameWinProb(e1, e2), 2) // Bo3: first to 2 : gameWinProb(e1, e2); // Bo1: single game const t1Wins = Math.random() < p; const winnerId = t1Wins ? t1.id : t2.id; const loserId = t1Wins ? t2.id : t1.id; wins.set(winnerId, (wins.get(winnerId) ?? 0) + 1); losses.set(loserId, (losses.get(loserId) ?? 0) + 1); 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 ((losses.get(loserId) ?? 0) >= 3) { eliminatedIds.add(loserId); const t = teamById.get(loserId); if (t) eliminated.push({ id: t.id, elo: t.elo, rank: t.rank, wins: wins.get(loserId) ?? 0 }); } } } return { advanced, eliminated }; } /** Group active teams by (wins, losses) record key. */ function groupByRecord( active: TeamWithElo[], wins: Map, losses: Map ): Map { const groups = new Map(); for (const t of active) { const key = `${wins.get(t.id) ?? 0}-${losses.get(t.id) ?? 0}`; if (!groups.has(key)) groups.set(key, []); groups.get(key)?.push(t); } return groups; } /** * Pair teams within each record group. Teams in groups with an odd count * are handled by merging the leftover into an adjacent group (simpler: skip * them for this round — they sit out and play next round). */ function pairGroups(groups: Map): Array<[TeamWithElo, TeamWithElo]> { const pairs: Array<[TeamWithElo, TeamWithElo]> = []; const leftover: TeamWithElo[] = []; for (const group of groups.values()) { const shuffled = shuffle([...group]); for (let i = 0; i + 1 < shuffled.length; i += 2) { pairs.push([shuffled[i], shuffled[i + 1]]); } if (shuffled.length % 2 !== 0) { leftover.push(shuffled[shuffled.length - 1]); } } // Pair leftover teams with each other (different records, cross-group match) for (let i = 0; i + 1 < leftover.length; i += 2) { pairs.push([leftover[i], leftover[i + 1]]); } return pairs; } // ─── Champions Stage (8-team single-elimination bracket) ───────────────────── interface ChampionsResult { placements: Map; // participantId → placement (1–8) } /** * 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). * Exported for unit testing. */ export function simulateChampionsStage(teams: TeamWithElo[]): 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); // Standard 8-team seeding: 1v8, 4v5, 3v6, 2v7 const bracket: [TeamWithElo, TeamWithElo][] = [ [seeded[0], seeded[7]], [seeded[3], seeded[4]], [seeded[2], seeded[5]], [seeded[1], seeded[6]], ]; const placements = new Map(); const simMatch = (t1: TeamWithElo, t2: TeamWithElo, winsNeeded: number): TeamWithElo => { const p = seriesWinProb(gameWinProb(t1.elo, t2.elo), winsNeeded); return Math.random() < p ? t1 : t2; }; // Quarterfinals (Bo3 = first to 2) const sfTeams: TeamWithElo[] = []; 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) } // 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[] = []; 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)); // Grand Final (Bo5 = first to 3) const champion = simMatch(finalTeams[0], finalTeams[1], 3); const finalist = champion.id === finalTeams[0].id ? finalTeams[1] : finalTeams[0]; placements.set(champion.id, 1); placements.set(finalist.id, 2); return { placements }; } // ─── QP helpers ─────────────────────────────────────────────────────────────── /** * Calculate QP for Stage 3 exits based on their W-L record. * 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. */ function calcStage3ExitQP( elimTeams: Array<{ id: string; wins: number }>, qpConfig: Map ): Map { // Group teams by wins count (0, 1, 2) const byWins = new Map(); for (const t of elimTeams) { if (!byWins.has(t.wins)) byWins.set(t.wins, []); byWins.get(t.wins)?.push(t.id); } // Assign placement slots 9–16 from highest wins first const result = new Map(); const winsGroups = [...byWins.entries()].toSorted((a, b) => b[0] - a[0]); // descending wins let slotStart = 9; for (const [, ids] of winsGroups) { const slots = Array.from({ length: ids.length }, (_, i) => slotStart + i); const avgQP = slots.reduce((sum, s) => sum + (qpConfig.get(s) ?? 0), 0) / slots.length; for (const id of ids) { result.set(id, avgQP); } slotStart += ids.length; } return result; } // ─── Simulator ──────────────────────────────────────────────────────────────── export class CSMajorSimulator implements Simulator { async simulate(sportsSeasonId: string): Promise { const db = database(); // 1. Load all participants for this sports season. const allParticipants = await db .select({ id: schema.participants.id }) .from(schema.participants) .where(eq(schema.participants.sportsSeasonId, sportsSeasonId)); const participantIds = allParticipants.map((p) => p.id); if (participantIds.length === 0) { throw new Error(`No participants found for sports season ${sportsSeasonId}.`); } // 2. Load Elo ratings and world rankings from participantExpectedValues. const evRows = await db .select({ participantId: schema.participantExpectedValues.participantId, sourceElo: schema.participantExpectedValues.sourceElo, worldRanking: schema.participantExpectedValues.worldRanking, }) .from(schema.participantExpectedValues) .where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)); const eloMap = new Map(); for (const row of evRows) { if (row.sourceElo !== null) { eloMap.set(row.participantId, { elo: row.sourceElo, rank: row.worldRanking ?? 9999, }); } } // Build pool: all participants with Elo 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) { throw new Error( `No participants with Elo ratings found for sports season ${sportsSeasonId}. ` + `Enter Elo ratings via the CS Elo admin page before simulating.` ); } // 3. Load QP config (placements 1–16 earn QP; 17+ earn 0). const qpConfigArray = await getQPConfig(sportsSeasonId); const qpConfig = new Map( qpConfigArray.map((c) => [c.placement, parseFloat(c.points)]) ); // 4. Load all CS major scoring events for this sports season. const events = await db.query.scoringEvents.findMany({ where: and( eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), eq(schema.scoringEvents.eventType, "major_tournament") ), orderBy: (e, { asc }) => [asc(e.eventDate)], }); if (events.length === 0) { throw new Error( `No major_tournament scoring events found for sports season ${sportsSeasonId}. ` + `Create the CS Major scoring events first.` ); } // 5. For completed events, read actual QP from eventResults. const completedEventIds = events.filter((e) => e.isComplete).map((e) => e.id); const actualQPMap = new Map(participantIds.map((id) => [id, 0])); if (completedEventIds.length > 0) { const actualResults = await db .select({ participantId: schema.eventResults.participantId, qualifyingPointsAwarded: schema.eventResults.qualifyingPointsAwarded, }) .from(schema.eventResults) .where(inArray(schema.eventResults.scoringEventId, completedEventIds)); for (const r of actualResults) { if (r.qualifyingPointsAwarded !== null) { const prev = actualQPMap.get(r.participantId) ?? 0; actualQPMap.set(r.participantId, prev + parseFloat(r.qualifyingPointsAwarded)); } } } // 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); } } const incompleteEvents = events.filter((e) => !e.isComplete); // 7. 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])); for (let sim = 0; sim < NUM_SIMULATIONS; sim++) { const simQP = new Map(actualQPMap); for (const event of incompleteEvents) { const stageResultsForEvent = eventStageResults.get(event.id); const eventQP = simulateOneMajor(pool, stageResultsForEvent, qpConfig); for (const [pid, qp] of eventQP) { simQP.set(pid, (simQP.get(pid) ?? 0) + qp); } } // Rank all participants by total QP descending. const ranked = [...simQP.entries()].toSorted((a, b) => b[1] - a[1]); for (let rank = 0; rank < Math.min(8, ranked.length); rank++) { const [pid] = ranked[rank]; const idx = idToIndex.get(pid); if (idx !== undefined) counts[idx][rank]++; } } // 8. Convert counts to probabilities. return participantIds.map((participantId, i) => ({ participantId, probabilities: { probFirst: counts[i][0] / NUM_SIMULATIONS, probSecond: counts[i][1] / NUM_SIMULATIONS, probThird: counts[i][2] / NUM_SIMULATIONS, probFourth: counts[i][3] / NUM_SIMULATIONS, probFifth: counts[i][4] / NUM_SIMULATIONS, probSixth: counts[i][5] / NUM_SIMULATIONS, probSeventh: counts[i][6] / NUM_SIMULATIONS, probEighth: counts[i][7] / NUM_SIMULATIONS, }, source: "cs2_major_qualifying_points_monte_carlo", })); } } // ─── Single major simulation ─────────────────────────────────────────────────── /** * 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 * eliminations for that stage have been recorded. * * Returns a Map from participantId → QP earned in this major. */ function simulateOneMajor( pool: TeamWithElo[], stageResults: Map | undefined, qpConfig: Map ): Map { const qpMap = new Map(); const poolById = new Map(pool.map((t) => [t.id, t])); // ── Determine field and stage assignments ───────────────────────────────── let stage1Teams: TeamWithElo[]; let stage2Direct: TeamWithElo[]; let stage3Direct: TeamWithElo[]; if (stageResults && stageResults.size > 0) { const s1: TeamWithElo[] = []; const s2: TeamWithElo[] = []; const s3: TeamWithElo[] = []; 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); } stage1Teams = s1; stage2Direct = s2; stage3Direct = s3; } 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); 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). const eliminatedAtStage = (stageNum: number) => stageResults ? [...stageResults.entries()] .filter(([, r]) => r.stageEliminated === stageNum) .map(([id, r]) => ({ id, elo: poolById.get(id)?.elo ?? FALLBACK_ELO, rank: poolById.get(id)?.rank ?? 9999, wins: r.stageEliminatedWins ?? 0, })) : []; const stage1Elim = eliminatedAtStage(1); const stage2Elim = eliminatedAtStage(2); const stage3Elim = eliminatedAtStage(3); const stage1Complete = stage1Elim.length === 8; const stage2Complete = stage2Elim.length === 8; const stage3Complete = stage3Elim.length === 8; // ── Stage 1 ─────────────────────────────────────────────────────────────── let stage1Advanced: TeamWithElo[]; 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)); stage1EliminatedFinal = stage1Elim; } else { const result = simulateSwiss(stage1Teams, false); stage1Advanced = result.advanced; stage1EliminatedFinal = result.eliminated; } // ── Stage 2 ─────────────────────────────────────────────────────────────── const stage2Teams = [...stage2Direct, ...stage1Advanced]; let stage2Advanced: TeamWithElo[]; 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)); stage2EliminatedFinal = stage2Elim; } else { const result = simulateSwiss(stage2Teams, false); stage2Advanced = result.advanced; stage2EliminatedFinal = result.eliminated; } // ── Stage 3 ─────────────────────────────────────────────────────────────── const stage3Teams = [...stage3Direct, ...stage2Advanced]; let champTeams: TeamWithElo[]; 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)); stage3EliminatedFinal = stage3Elim; } else { const result = simulateSwiss(stage3Teams, true); champTeams = result.advanced; stage3EliminatedFinal = result.eliminated; } // ── Champions Stage ─────────────────────────────────────────────────────── const champResult = simulateChampionsStage(champTeams); // ── Assign QP ───────────────────────────────────────────────────────────── for (const [pid, placement] of champResult.placements) { qpMap.set(pid, qpConfig.get(placement) ?? 0); } const stage3ExitQP = calcStage3ExitQP(stage3EliminatedFinal, qpConfig); for (const [pid, qp] of stage3ExitQP) { qpMap.set(pid, qp); } for (const t of stage1EliminatedFinal) qpMap.set(t.id, 0); for (const t of stage2EliminatedFinal) qpMap.set(t.id, 0); return qpMap; }