/** * 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 } 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) } /** * 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. * - 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. * @returns advanced (with losses) and eliminated (with wins) arrays. * * Exported for unit testing. */ export function simulateSwiss( teams: TeamWithElo[], bo3: boolean, decisiveMatchesBo3 = false ): SwissResult { if (teams.length === 0) return { advanced: [], eliminated: [] }; if (teams.length % 2 !== 0) { throw new Error(`simulateSwiss requires an even number of teams, got ${teams.length}`); } const wins = new Map(teams.map((t) => [t.id, 0])); const losses = new Map(teams.map((t) => [t.id, 0])); const eloMap = new Map(teams.map((t) => [t.id, t.elo])); const advanced: AdvancedTeam[] = []; 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; // 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 }); } } } 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. * Seeds are assigned by stage performance (losses ascending), with world rank * as tiebreaker. Rounds: QF (Bo3), SF (Bo3), GF (Bo5). * * QF losers are all assigned placement 5 (tie-split across slots 5–8 by caller). * SF losers are both assigned placement 3 (tie-split across slots 3–4 by caller). * Exported for unit testing. */ export function simulateChampionsStage(teams: AdvancedTeam[]): ChampionsResult { if (teams.length !== 8) { throw new Error(`simulateChampionsStage expects exactly 8 teams, got ${teams.length}`); } // 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: [AdvancedTeam, AdvancedTeam][] = [ [seeded[0], seeded[7]], [seeded[3], seeded[4]], [seeded[2], seeded[5]], [seeded[1], seeded[6]], ]; 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; }; // Quarterfinals (Bo3 = first to 2) 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, 5); // QF losers: all get placement 5 (tie-split 5–8 by caller) } // Semifinals (Bo3 = first to 2) 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) => placements.set(t.id, 3)); // SF losers: both get placement 3 (tie-split 3–4) // Grand Final (Bo5 = first to 3) const champion = simMatch(finalTeams[0], finalTeams[1], 3); 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: map from placement (1-indexed) to QP value. * Placements 9–16 correspond to stage 3 exit slots. * * Exported for unit testing. */ export 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.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.` ); } // 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. 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. 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 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]++; } } // 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; } /** * 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 at least 8 * eliminations for that stage have been recorded. * * Returns a Map from participantId → QP earned in this major. * Exported for unit testing. */ export 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 ───────────────────────────────── // 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); } // ── Lock in known stage results ─────────────────────────────────────────── // A stage is "complete" when at least 8 teams have been eliminated at that 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 (Opening) — all Bo1 ─────────────────────────────────────────── let stage1Advanced: AdvancedTeam[]; let stage1EliminatedFinal: Array<{ id: string; elo: number; rank: number; wins: number }>; if (stage1Complete) { const stage1EliminatedIds = new Set(stage1Elim.map((t) => 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); stage1Advanced = result.advanced; stage1EliminatedFinal = result.eliminated; } // ── 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)) .map((t): AdvancedTeam => ({ ...t, losses: 2 })); stage2EliminatedFinal = stage2Elim; } else { const result = simulateSwiss(stage2Teams, false, true); // decisive matches → Bo3 stage2Advanced = result.advanced; stage2EliminatedFinal = result.eliminated; } // ── 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)); // 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); // all Bo3 champTeams = result.advanced; stage3EliminatedFinal = result.eliminated; } // ── Champions Stage ─────────────────────────────────────────────────────── const champResult = simulateChampionsStage(champTeams); // ── 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); return qpMap; }