brackt/app/services/simulations/cs-major-simulator.ts
Claude a516dedb76
Fix and() bug and add Swiss loop safety guard
- cs2-major-stage.ts: markCs2StageEliminations and setCs2FinalPlacements
  were using JS && instead of Drizzle and(), causing WHERE to filter only
  by participantId (not scoringEventId), which would update rows across
  all events instead of just the target event
- cs-major-simulator.ts: add break guard in simulateSwiss while loop to
  prevent infinite loop if pairGroups returns no pairs

https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR
2026-04-05 20:05:02 +00:00

610 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 (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
* 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 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.
*
* Stages 1 and 2 exits (placements 1732) 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<T>(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].sort((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<T>(
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] }));
indexed.sort((a, b) => a.key - b.key);
return indexed.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<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 eliminated: SwissResult["eliminated"] = [];
const advancedIds = new Set<string>();
const eliminatedIds = new Set<string>();
const teamById = new Map<string, TeamWithElo>(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)!;
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)!;
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<string, number>,
losses: Map<string, number>
): Map<string, TeamWithElo[]> {
const groups = new Map<string, TeamWithElo[]>();
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<string, TeamWithElo[]>): 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<string, number>; // participantId → placement (18)
}
/**
* 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].sort((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<string, number>();
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: 5th8th (averaged to 6.5, use 7 for counting)
}
// 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[] = [];
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 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.
*/
function calcStage3ExitQP(
elimTeams: Array<{ id: string; wins: number }>,
qpConfig: Map<number, number>
): Map<string, number> {
// Group teams by wins count (0, 1, 2)
const byWins = new Map<number, string[]>();
for (const t of elimTeams) {
if (!byWins.has(t.wins)) byWins.set(t.wins, []);
byWins.get(t.wins)!.push(t.id);
}
// Assign placement slots 916 from highest wins first
const result = new Map<string, number>();
const winsGroups = [...byWins.entries()].sort((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<SimulationResult[]> {
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<string, { elo: number; rank: number }>();
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, rank: e.rank };
})
.sort((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 116 earn QP; 17+ earn 0).
const qpConfigArray = await getQPConfig(sportsSeasonId);
const qpConfig = new Map<number, number>(
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<string, number>(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<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);
}
}
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<string, number>(participantIds.map((id, i) => [id, i]));
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
const simQP = new Map<string, number>(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()].sort((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.
* Uses explicit stage assignments if provided; otherwise infers from world ranking.
*
* Returns a Map from participantId → QP earned in this major.
*/
function simulateOneMajor(
pool: TeamWithElo[],
stageResults: Map<string, { stageEntry: number; stageEliminated: number | null; stageEliminatedWins: number | null }> | undefined,
qpConfig: Map<number, number>
): Map<string, number> {
const qpMap = new Map<string, number>();
// Determine the 32-team field and stage assignments.
let stage1Teams: TeamWithElo[];
let stage2Direct: TeamWithElo[];
let stage3Direct: TeamWithElo[];
if (stageResults && stageResults.size > 0) {
// Use explicit stage assignments from the database.
const s1: TeamWithElo[] = [];
const s2: TeamWithElo[] = [];
const s3: TeamWithElo[] = [];
for (const [participantId, result] of stageResults) {
const team = pool.find((t) => t.id === participantId);
if (!team) continue;
// If result is already complete (stageEliminated is set), we can skip simulation
// for that team and use the known outcome. For simplicity in this iteration,
// we still simulate incomplete stages using Elo.
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 {
// Infer stage assignments from world ranking.
const field = sampleField(pool, FIELD_SIZE);
const sorted = [...field].sort((a, b) => a.rank - b.rank);
// Top 8 → Stage 3 (Legends), next 8 → Stage 2 (Challengers), bottom 16 → Stage 1
stage3Direct = sorted.slice(0, 8);
stage2Direct = sorted.slice(8, 16);
stage1Teams = sorted.slice(16, 32);
}
// Stage 1: Swiss Bo1, 16 → 8 advance
const stage1Result = simulateSwiss(stage1Teams, false);
// Stage 2: Swiss Bo1/Bo3, 16 → 8 advance
// Use Bo1 for Stage 2 (matches are Bo1 early rounds, Bo3 for deciders;
// we simplify to Bo1 for Stage 2 and Bo3 for Stage 3)
const stage2Teams = [...stage2Direct, ...stage1Result.advanced];
const stage2Result = simulateSwiss(stage2Teams, false);
// Stage 3: Swiss all Bo3, 16 → 8 advance
const stage3Teams = [...stage3Direct, ...stage2Result.advanced];
const stage3Result = simulateSwiss(stage3Teams, true);
// Champions Stage: 8-team single elimination
const champResult = simulateChampionsStage(stage3Result.advanced);
// Assign QP from Champions Stage (placements 18)
for (const [pid, placement] of champResult.placements) {
qpMap.set(pid, qpConfig.get(placement) ?? 0);
}
// Assign QP for Stage 3 exits (placements 916), sub-ranked by wins
const stage3ExitQP = calcStage3ExitQP(stage3Result.eliminated, qpConfig);
for (const [pid, qp] of stage3ExitQP) {
qpMap.set(pid, qp);
}
// Stage 1 and 2 exits get 0 QP
for (const t of stage1Result.eliminated) qpMap.set(t.id, 0);
for (const t of stage2Result.eliminated) qpMap.set(t.id, 0);
return qpMap;
}