/** * 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, 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): * - 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 * * 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. */ 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, computeStage3ExitQP as calcStage3ExitQP, type Cs2StageResult } from "~/models/cs2-major-stage"; import { findParticipantsBySportsSeasonId } from "~/models/season-participant"; export { calcStage3ExitQP }; import { getExcludedByEventMap } from "~/models/event-result"; import { findPlayoffMatchesByEventId } from "~/models/playoff-match"; import { findSeasonMatchesByScoringEventId } from "~/models/season-match"; import { resolveStructureSource, IDENTITY_TR, type IdTranslator, type StructureSource, } from "./shared-major"; 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) } /** * 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; } /** A single CS2 Major stage result row, as the simulator consumes it. */ export type StageResultsMap = Map< string, { stageEntry: number; stageEliminated: number | null; stageEliminatedWins: number | null } >; /** * A Champions Stage bracket match (subset of the playoffMatches row used by the * simulator). Lets the simulator honor real, already-played bracket results * instead of re-simulating the whole 8-team bracket each iteration. */ export interface BracketMatchInput { round: string; // "Quarterfinals" | "Semifinals" | "Finals" matchNumber: number; // QF: 1–4, SF: 1–2, Finals: 1 participant1Id: string | null; participant2Id: string | null; winnerId: string | null; isComplete: boolean; } /** * A Swiss-stage match (subset of the seasonMatches row). Used to reconstruct * each team's current W–L record mid-stage so already-played rounds are locked * in and only the undecided remainder is simulated. */ export interface SwissMatchInput { matchStage: number | null; // 1, 2, or 3 participant1Id: string | null; participant2Id: string | null; winnerId: string | null; } /** Optional already-known results that condition a single-major simulation. */ export interface SimulateOneMajorOptions { /** Real Champions Stage bracket results, if entered. */ bracketMatches?: BracketMatchInput[]; /** Real Swiss match results, used to reconstruct mid-stage records. */ swissMatches?: SwissMatchInput[]; /** * Per-stage reconstructed (wins, losses) records, keyed by stage number. * These depend only on `swissMatches` and are invariant across Monte Carlo * iterations, so the caller may precompute them once and pass them in to * avoid re-deriving them on every iteration. When omitted, they are * reconstructed from `swissMatches` on demand. */ swissRecords?: Map>; /** participantId → QP already recorded in event_results (provisional/locked). */ recordedResults?: Map; } /** * 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), 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 }>; } /** * 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 (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. * @param initialRecords - Optional per-team starting (wins, losses). Teams * already at the 3W/3L threshold are locked in as * advanced/eliminated and only the undecided * remainder is simulated. Defaults to 0–0 for all * teams (a fresh stage). * @returns advanced (with losses) and eliminated (with wins) arrays. * * Exported for unit testing. */ export function simulateSwiss( teams: TeamWithElo[], bo3: boolean, decisiveMatchesBo3 = false, initialRecords?: Map ): 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, initialRecords?.get(t.id)?.wins ?? 0])); const losses = new Map(teams.map((t) => [t.id, initialRecords?.get(t.id)?.losses ?? 0])); const eloMap = new Map(teams.map((t) => [t.id, t.elo])); const advanced: AdvancedTeam[] = []; const eliminated: SwissResult["eliminated"] = []; const advancedIds = new Set(); const eliminatedIds = new Set(); const teamById = new Map(teams.map((t) => [t.id, t])); // Pre-resolve teams whose seeded record already meets a threshold (locked // results carried in from real match data): 3+ wins = advanced, 3+ losses = // eliminated. Teams below both thresholds continue in the Swiss loop below. for (const t of teams) { const w = wins.get(t.id) ?? 0; const l = losses.get(t.id) ?? 0; if (w >= 3) { advancedIds.add(t.id); advanced.push({ id: t.id, elo: t.elo, rank: t.rank, losses: l }); } else if (l >= 3) { eliminatedIds.add(t.id); eliminated.push({ id: t.id, elo: t.elo, rank: t.rank, wins: w }); } } // 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; // 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 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, losses: losses.get(winnerId) ?? 0 }); } 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 }); } } } // Safety net: a ragged mid-stage snapshot (records that don't fall on a clean // round boundary) can leave a team unpaired and unresolved. Resolve any // stragglers by their current record so the partition is always complete. for (const t of teams) { if (advancedIds.has(t.id) || eliminatedIds.has(t.id)) continue; const w = wins.get(t.id) ?? 0; const l = losses.get(t.id) ?? 0; if (w >= l) { advancedIds.add(t.id); advanced.push({ id: t.id, elo: t.elo, rank: t.rank, losses: l }); } else { eliminatedIds.add(t.id); eliminated.push({ id: t.id, elo: t.elo, rank: t.rank, wins: w }); } } 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 { /** * 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. * Rounds: QF (Bo3), SF (Bo3), GF (Bo5). * * When `bracketMatches` describes a real, fully-seeded bracket (4 QF matches * whose participants are all in this field), the actual QF pairings are used — * this preserves the real Stage 3 seeding the admin built — and any match * already played (`isComplete` with a `winnerId`) is honored instead of * re-simulated. Undecided matches fall back to the Elo-based model. Without a * usable bracket, seeds are assigned by stage performance (losses ascending, * world rank as tiebreaker) with standard 1v8/4v5/3v6/2v7 pairings. * * Feed mapping matches advanceWinner: QF1/QF2 → SF1, QF3/QF4 → SF2, SF1/SF2 → F. * * 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: AdvancedTeam[], bracketMatches?: BracketMatchInput[] ): ChampionsResult { if (teams.length !== 8) { throw new Error(`simulateChampionsStage expects exactly 8 teams, got ${teams.length}`); } const byId = new Map(teams.map((t) => [t.id, t])); const placements = new Map(); const simMatch = (t1: AdvancedTeam, t2: AdvancedTeam, winsNeeded: number): AdvancedTeam => { const p = seriesWinProb(gameWinProb(t1.elo, t2.elo), winsNeeded); return Math.random() < p ? t1 : t2; }; // Determine QF pairings from the real bracket when it is fully seeded with // teams from this field; otherwise seed by stage performance. const qf = bracketMatches ?.filter((m) => m.round === "Quarterfinals") .toSorted((a, b) => a.matchNumber - b.matchNumber); const realBracket = qf?.length === 4 && qf.every( (m) => m.participant1Id !== null && m.participant2Id !== null && byId.has(m.participant1Id) && byId.has(m.participant2Id) ); // Only honor recorded results when the real bracket pairings are in use; // applying them to fictitious Elo-seeded pairings would mix real outcomes // into matchups that never existed. const recordedMatch = (round: string, matchNumber: number): BracketMatchInput | undefined => realBracket ? bracketMatches?.find((m) => m.round === round && m.matchNumber === matchNumber) : undefined; // Honor a completed match's recorded winner; otherwise simulate. const resolve = ( t1: AdvancedTeam, t2: AdvancedTeam, winsNeeded: number, rec: BracketMatchInput | undefined ): AdvancedTeam => { if (rec?.isComplete && rec.winnerId) { if (rec.winnerId === t1.id) return t1; if (rec.winnerId === t2.id) return t2; } return simMatch(t1, t2, winsNeeded); }; let qfPairs: [AdvancedTeam, AdvancedTeam][]; if (realBracket && qf) { qfPairs = qf.map((m) => [ byId.get(m.participant1Id as string) as AdvancedTeam, byId.get(m.participant2Id as string) as AdvancedTeam, ]); } else { // 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 qfPairs = [ [seeded[0], seeded[7]], [seeded[3], seeded[4]], [seeded[2], seeded[5]], [seeded[1], seeded[6]], ]; } // Quarterfinals (Bo3 = first to 2) const sfFeeders: AdvancedTeam[] = []; qfPairs.forEach(([t1, t2], i) => { const winner = resolve(t1, t2, 2, recordedMatch("Quarterfinals", i + 1)); const loser = winner.id === t1.id ? t2 : t1; placements.set(loser.id, 5); // QF losers: all get placement 5 (tie-split 5–8 by caller) sfFeeders[i] = winner; }); // Semifinals (Bo3 = first to 2): SF1 = QF1/QF2 winners, SF2 = QF3/QF4 winners const finalFeeders: AdvancedTeam[] = []; for (let i = 0; i < 2; i++) { const t1 = sfFeeders[i * 2]; const t2 = sfFeeders[i * 2 + 1]; const winner = resolve(t1, t2, 2, recordedMatch("Semifinals", i + 1)); const loser = winner.id === t1.id ? t2 : t1; placements.set(loser.id, 3); // SF losers: both get placement 3 (tie-split 3–4) finalFeeders[i] = winner; } // Grand Final (Bo5 = first to 3) const champion = resolve(finalFeeders[0], finalFeeders[1], 3, recordedMatch("Finals", 1)); const finalist = champion.id === finalFeeders[0].id ? finalFeeders[1] : finalFeeders[0]; placements.set(champion.id, 1); placements.set(finalist.id, 2); return { placements }; } // ─── QP helpers ─────────────────────────────────────────────────────────────── // ─── 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.seasonParticipants.id }) .from(schema.seasonParticipants) .where(eq(schema.seasonParticipants.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.seasonParticipantExpectedValues.participantId, sourceElo: schema.seasonParticipantExpectedValues.sourceElo, worldRanking: schema.seasonParticipantExpectedValues.worldRanking, }) .from(schema.seasonParticipantExpectedValues) .where(eq(schema.seasonParticipantExpectedValues.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, using FALLBACK_ELO for those without ratings, sorted by rank const pool: TeamWithElo[] = participantIds .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); 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.` ); } // 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.` ); } // 4b. Shared-major structure resolution. A tournament-linked, non-primary // event holds no bracket/stage/Swiss rows of its own — they live on the // primary window. For each such event, read the structure from the primary // and translate the primary window's season_participant ids into THIS // window's ids via the shared canonical participant, so the simulation // conditions on real in-progress results instead of re-simulating from // scratch. (Provisional QP already fans out to event_results, so it is read // locally below and needs no translation.) const localParticipants = await findParticipantsBySportsSeasonId(sportsSeasonId); // Composed translator (primary→local) cached per primary sports season — // a window's majors may have primaries in several different windows. const translatorByPrimarySeason = new Map(); const structureByEventId = new Map( await Promise.all( events.map( async (e) => [ e.id, await resolveStructureSource(e, localParticipants, translatorByPrimarySeason), ] as const ) ) ); // 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.seasonParticipantId, 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 all events in parallel (from the structure // source — the primary window for siblings — translating participant ids). const stageResultEntries = await Promise.all( events.map(async (event) => { const { sourceId, tr } = structureByEventId.get(event.id) ?? { sourceId: event.id, tr: IDENTITY_TR, }; const results = await getCs2StageResultsMapForEvent(sourceId); const translated = new Map(); for (const [pid, r] of results) { const t = tr(pid) ?? pid; translated.set(t, { ...r, participantId: t }); } return [event.id, translated] as const; }) ); const eventStageResults = new Map( stageResultEntries.filter(([, r]) => r.size > 0) ); const incompleteEvents = events.filter((e) => !e.isComplete); const incompleteEventIds = incompleteEvents.map((e) => e.id); // Load not-participating exclusions for each incomplete event. const excludedByEvent = await getExcludedByEventMap(incompleteEventIds); // 6b. For incomplete events, load already-known results so the simulation // conditions on them instead of re-simulating: the Champions Stage // bracket, the Swiss match results, and provisional recorded QP. // Each entry carries its own translation fn (identity for primaries, primary // →this-window for siblings) applied to every participant id read below. const [bracketEntries, swissEntries] = await Promise.all([ Promise.all( incompleteEvents.map(async (e) => { const { sourceId, tr } = structureByEventId.get(e.id) ?? { sourceId: e.id, tr: IDENTITY_TR, }; return [e.id, tr, await findPlayoffMatchesByEventId(sourceId)] as const; }) ), Promise.all( incompleteEvents.map(async (e) => { const { sourceId, tr } = structureByEventId.get(e.id) ?? { sourceId: e.id, tr: IDENTITY_TR, }; return [e.id, tr, await findSeasonMatchesByScoringEventId(sourceId)] as const; }) ), ]); const eventBracket = new Map( bracketEntries.map(([id, tr, matches]) => [ id, matches.map((m) => ({ round: m.round, matchNumber: m.matchNumber, participant1Id: tr(m.participant1Id), participant2Id: tr(m.participant2Id), winnerId: tr(m.winnerId), isComplete: m.isComplete, })), ]) ); const eventSwiss = new Map( swissEntries.map(([id, tr, matches]) => [ id, matches.map((m) => ({ matchStage: m.matchStage, participant1Id: tr(m.participant1Id), participant2Id: tr(m.participant2Id), winnerId: tr(m.winnerId), })), ]) ); // Per-stage W–L records depend only on the (loop-invariant) Swiss matches, // so reconstruct them once per event here rather than on every iteration. const eventSwissRecords = new Map< string, Map> >( [...eventSwiss].map(([id, matches]) => [ id, new Map([1, 2, 3].map((stageNum) => [stageNum, reconstructStageRecords(matches, stageNum)])), ]) ); const eventRecordedQP = new Map>(); if (incompleteEventIds.length > 0) { const provisionalResults = await db .select({ eventId: schema.eventResults.scoringEventId, participantId: schema.eventResults.seasonParticipantId, qualifyingPointsAwarded: schema.eventResults.qualifyingPointsAwarded, }) .from(schema.eventResults) .where(inArray(schema.eventResults.scoringEventId, incompleteEventIds)); for (const r of provisionalResults) { if (r.qualifyingPointsAwarded === null) continue; let perEvent = eventRecordedQP.get(r.eventId); if (!perEvent) { perEvent = new Map(); eventRecordedQP.set(r.eventId, perEvent); } perEvent.set(r.participantId, parseFloat(r.qualifyingPointsAwarded)); } } // 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])); for (let sim = 0; sim < NUM_SIMULATIONS; sim++) { const simQP = new Map(actualQPMap); for (const event of incompleteEvents) { const excluded = excludedByEvent.get(event.id) ?? new Set(); const eventPool = excluded.size > 0 ? pool.filter((t) => !excluded.has(t.id)) : pool; const stageResultsForEvent = eventStageResults.get(event.id); const eventQP = simulateOneMajor(eventPool, stageResultsForEvent, qpConfig, { bracketMatches: eventBracket.get(event.id), swissMatches: eventSwiss.get(event.id), swissRecords: eventSwissRecords.get(event.id), recordedResults: eventRecordedQP.get(event.id), }); 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]++; } } // 9. 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 ─────────────────────────────────────────────────── /** 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; } /** * Reconstruct each team's current (wins, losses) within a stage from real Swiss * match results. Only completed matches (those with a recorded winner) count. */ function reconstructStageRecords( swissMatches: SwissMatchInput[] | undefined, stageNum: number ): Map { const records = new Map(); if (!swissMatches) return records; const bump = (id: string, key: "wins" | "losses") => { const r = records.get(id) ?? { wins: 0, losses: 0 }; r[key] += 1; records.set(id, r); }; for (const m of swissMatches) { if (m.matchStage !== stageNum) continue; if (!m.winnerId || !m.participant1Id || !m.participant2Id) continue; const loserId = m.winnerId === m.participant1Id ? m.participant2Id : m.participant1Id; bump(m.winnerId, "wins"); bump(loserId, "losses"); } return records; } /** * Build the seeded starting records for a stage's Swiss simulation, locking in * everything already known from real data so only the undecided remainder is * simulated: * - Teams recorded as eliminated at this stage (cs2MajorStageResults) or with * 3 losses in the Swiss match data are locked in as eliminated (losses = 3). * - Teams with 3 recorded wins are locked in as advanced. * - When the stage is complete (8 eliminations known) every other team is a * locked advancer. * - Remaining teams carry their partial real record (0–0 if none). * Returns the seed map plus the sets of teams locked in as eliminated or * advanced from real data. Locked-eliminated teams have settled QP (recorded * provisional QP may be applied verbatim); both sets are protected from being * flipped by reconcileAdvancers. * * `recon` may be supplied precomputed (it is invariant across Monte Carlo * iterations); otherwise it is reconstructed from `swissMatches`. */ function buildStageInitialRecords( stageTeams: TeamWithElo[], stageNum: number, swissMatches: SwissMatchInput[] | undefined, stageResults: StageResultsMap | undefined, recon: Map = reconstructStageRecords(swissMatches, stageNum) ): { initialRecords: Map; lockedEliminated: Set; lockedAdvanced: Set; } { const initialRecords = new Map(); const lockedEliminated = new Set(); const lockedAdvanced = new Set(); const cs2ElimIds = new Set(); if (stageResults) { for (const [id, r] of stageResults) { if (r.stageEliminated === stageNum) cs2ElimIds.add(id); } } const reconElimCount = [...recon.values()].filter((r) => r.losses >= 3).length; const stageComplete = cs2ElimIds.size >= 8 || reconElimCount >= 8; for (const t of stageTeams) { const r = recon.get(t.id); const cs2 = stageResults?.get(t.id); if (cs2ElimIds.has(t.id)) { initialRecords.set(t.id, { wins: cs2?.stageEliminatedWins ?? r?.wins ?? 0, losses: 3 }); lockedEliminated.add(t.id); } else if (r && r.losses >= 3) { initialRecords.set(t.id, { wins: r.wins, losses: 3 }); lockedEliminated.add(t.id); } else if (r && r.wins >= 3) { initialRecords.set(t.id, { wins: 3, losses: r.losses }); lockedAdvanced.add(t.id); } else if (stageComplete) { // Stage done and this team wasn't eliminated → it advanced. Exact loss // count is unknown without match data; use 2 as a conservative fallback. initialRecords.set(t.id, { wins: 3, losses: r?.losses ?? 2 }); lockedAdvanced.add(t.id); } else if (r) { initialRecords.set(t.id, { wins: r.wins, losses: r.losses }); } } return { initialRecords, lockedEliminated, lockedAdvanced }; } /** * Each CS2 Swiss stage advances exactly 8 of its 16 teams. A consistent Swiss * state always yields that split, but a ragged mid-stage snapshot (locked * results that don't form a valid Swiss position) can over- or under-fill the * advancer pool. Reconcile to exactly `target` advancers — demoting the weakest * advancers (most losses, then worst rank) or promoting the strongest * eliminated teams (most wins, then best rank) — so downstream stages stay * well-formed. A no-op for the common, consistent case. * * Teams in `locked` come from real match data and must not be flipped: locked * advancers are kept out of the demotion pool and locked-eliminated teams out * of the promotion pool. They are only touched as a last resort, when there * aren't enough non-locked teams to reach `target` (a genuinely inconsistent * snapshot), in which case the weakest/strongest locked team is used. */ export function reconcileAdvancers( result: SwissResult, target: number, locked: Set = new Set() ): SwissResult { if (result.advanced.length === target) return result; const advanced = [...result.advanced]; const eliminated = [...result.eliminated]; if (advanced.length > target) { // Demote the weakest non-locked advancers first; sort locked teams to the // front (kept) so they are demoted only if non-locked teams run out. advanced.sort((a, b) => { const la = locked.has(a.id) ? 1 : 0; const lb = locked.has(b.id) ? 1 : 0; if (la !== lb) return lb - la; // locked first → kept return a.losses !== b.losses ? a.losses - b.losses : a.rank - b.rank; }); for (const t of advanced.splice(target)) { eliminated.push({ id: t.id, elo: t.elo, rank: t.rank, wins: 2 }); } } else { // Promote the strongest non-locked eliminated teams first; sort locked // teams to the back so they are promoted only if non-locked teams run out. eliminated.sort((a, b) => { const la = locked.has(a.id) ? 1 : 0; const lb = locked.has(b.id) ? 1 : 0; if (la !== lb) return la - lb; // non-locked first → promoted return b.wins !== a.wins ? b.wins - a.wins : a.rank - b.rank; }); for (const t of eliminated.splice(0, target - advanced.length)) { advanced.push({ id: t.id, elo: t.elo, rank: t.rank, losses: 2 }); } } return { advanced, eliminated }; } /** * Simulate one CS2 Major and return QP earned per participant. * * Conditions the simulation on whatever has already happened, only randomizing * the undecided remainder: * - `stageResults` determines field composition and recorded eliminations. * - `options.swissMatches` reconstructs mid-stage W–L records so partly-played * stages are locked in (not re-simulated from scratch). * - `options.bracketMatches` makes the Champions Stage honor real bracket * seeding and already-played QF/SF/Final results. * - `options.recordedResults` supplies provisional QP already written for * settled (eliminated) teams, used verbatim instead of re-derived. * * Returns a Map from participantId → QP earned in this major. * Exported for unit testing. */ export function simulateOneMajor( pool: TeamWithElo[], stageResults: StageResultsMap | undefined, qpConfig: Map, options: SimulateOneMajorOptions = {} ): Map { const { bracketMatches, swissMatches, swissRecords, recordedResults } = options; const qpMap = new Map(); const poolById = new Map(pool.map((t) => [t.id, t])); // Per-stage reconstructed records are invariant across iterations; reuse the // caller's precomputed maps when available, otherwise derive on demand. const reconFor = (stageNum: number) => swissRecords?.get(stageNum) ?? reconstructStageRecords(swissMatches, stageNum); // ── Determine field and stage assignments ───────────────────────────────── // stage2Direct / stage3Direct are AdvancedTeam[] (losses = 0: no prior Swiss stage) let stage1Teams: TeamWithElo[]; let stage2Direct: AdvancedTeam[]; let stage3Direct: AdvancedTeam[]; if (stageResults && stageResults.size > 0) { const s1: 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, losses: 0 }); else if (result.stageEntry === 3) s3.push({ ...team, losses: 0 }); } 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).map((t) => ({ ...t, losses: 0 })); stage2Direct = sorted.slice(8, 16).map((t) => ({ ...t, losses: 0 })); stage1Teams = sorted.slice(16, 32); } // ── Stage 1 (Opening) — all Bo1 ─────────────────────────────────────────── // Each stage advances exactly 8 teams; reconcile guards against ragged inputs // while protecting teams whose result is locked in from real data. const s1Seed = buildStageInitialRecords(stage1Teams, 1, swissMatches, stageResults, reconFor(1)); const s1Locked = new Set([...s1Seed.lockedEliminated, ...s1Seed.lockedAdvanced]); const stage1Result = reconcileAdvancers(simulateSwiss(stage1Teams, false, false, s1Seed.initialRecords), 8, s1Locked); const stage1Advanced = stage1Result.advanced; const stage1EliminatedFinal = stage1Result.eliminated; // ── Stage 2 (Challengers) — Bo1, Bo3 for decisive matches ──────────────── const stage2Teams: AdvancedTeam[] = [...stage2Direct, ...stage1Advanced]; const s2Seed = buildStageInitialRecords(stage2Teams, 2, swissMatches, stageResults, reconFor(2)); const s2Locked = new Set([...s2Seed.lockedEliminated, ...s2Seed.lockedAdvanced]); const stage2Result = reconcileAdvancers(simulateSwiss(stage2Teams, false, true, s2Seed.initialRecords), 8, s2Locked); const stage2Advanced = stage2Result.advanced; const stage2EliminatedFinal = stage2Result.eliminated; // ── Stage 3 (Legends) — all Bo3 ────────────────────────────────────────── const stage3Teams: AdvancedTeam[] = [...stage3Direct, ...stage2Advanced]; const s3Seed = buildStageInitialRecords(stage3Teams, 3, swissMatches, stageResults, reconFor(3)); const s3Locked = new Set([...s3Seed.lockedEliminated, ...s3Seed.lockedAdvanced]); const stage3Result = reconcileAdvancers(simulateSwiss(stage3Teams, true, false, s3Seed.initialRecords), 8, s3Locked); const stage3EliminatedFinal = stage3Result.eliminated; // Stage 3 reconciles to exactly 8 advancers — the Champions Stage field. const champTeams = stage3Result.advanced; // ── Champions Stage ─────────────────────────────────────────────────────── const champResult = simulateChampionsStage(champTeams, bracketMatches); // ── 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) { 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); 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); // For teams whose elimination is locked in from real data, prefer the QP // already recorded in event_results so the simulation matches what fans see. if (recordedResults) { const lockedEliminated = new Set([ ...s1Seed.lockedEliminated, ...s2Seed.lockedEliminated, ...s3Seed.lockedEliminated, ]); for (const id of lockedEliminated) { const recorded = recordedResults.get(id); if (recorded !== undefined) qpMap.set(id, recorded); } } return qpMap; }