1130 lines
46 KiB
TypeScript
1130 lines
46 KiB
TypeScript
/**
|
||
* 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";
|
||
import { positiveConfigNumber } from "./config-access";
|
||
|
||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||
|
||
const DEFAULT_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)
|
||
}
|
||
|
||
/**
|
||
* 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<number, Map<string, { wins: number; losses: number }>>;
|
||
/** participantId → QP already recorded in event_results (provisional/locked). */
|
||
recordedResults?: Map<string, 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<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] }));
|
||
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<string, { wins: number; losses: number }>
|
||
): SwissResult {
|
||
if (teams.length === 0) return { advanced: [], eliminated: [] };
|
||
if (teams.length % 2 !== 0) {
|
||
throw new Error(`simulateSwiss requires an even number of teams, got ${teams.length}`);
|
||
}
|
||
|
||
const wins = new Map<string, number>(teams.map((t) => [t.id, initialRecords?.get(t.id)?.wins ?? 0]));
|
||
const losses = new Map<string, number>(teams.map((t) => [t.id, initialRecords?.get(t.id)?.losses ?? 0]));
|
||
const eloMap = new Map<string, number>(teams.map((t) => [t.id, t.elo]));
|
||
|
||
const advanced: AdvancedTeam[] = [];
|
||
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]));
|
||
|
||
// 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<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 {
|
||
/**
|
||
* 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<string, number>;
|
||
}
|
||
|
||
/**
|
||
* 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<string, AdvancedTeam>(teams.map((t) => [t.id, t]));
|
||
const placements = new Map<string, number>();
|
||
|
||
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, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
||
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<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, 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<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.`
|
||
);
|
||
}
|
||
|
||
// 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<string, IdTranslator>();
|
||
const structureByEventId = new Map<string, StructureSource>(
|
||
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<string, number>(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<string, Cs2StageResult>();
|
||
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<string, BracketMatchInput[]>(
|
||
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<string, SwissMatchInput[]>(
|
||
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<number, Map<string, { wins: number; losses: number }>>
|
||
>(
|
||
[...eventSwiss].map(([id, matches]) => [
|
||
id,
|
||
new Map([1, 2, 3].map((stageNum) => [stageNum, reconstructStageRecords(matches, stageNum)])),
|
||
])
|
||
);
|
||
|
||
const eventRecordedQP = new Map<string, Map<string, number>>();
|
||
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<string, number>();
|
||
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<string, number>(participantIds.map((id, i) => [id, i]));
|
||
|
||
for (let sim = 0; sim < numSimulations; sim++) {
|
||
const simQP = new Map<string, number>(actualQPMap);
|
||
|
||
for (const event of incompleteEvents) {
|
||
const excluded = excludedByEvent.get(event.id) ?? new Set<string>();
|
||
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] / numSimulations,
|
||
probSecond: counts[i][1] / numSimulations,
|
||
probThird: counts[i][2] / numSimulations,
|
||
probFourth: counts[i][3] / numSimulations,
|
||
probFifth: counts[i][4] / numSimulations,
|
||
probSixth: counts[i][5] / numSimulations,
|
||
probSeventh: counts[i][6] / numSimulations,
|
||
probEighth: counts[i][7] / numSimulations,
|
||
},
|
||
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, number>): 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<string, { wins: number; losses: number }> {
|
||
const records = new Map<string, { wins: number; losses: number }>();
|
||
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<string, { wins: number; losses: number }> = reconstructStageRecords(swissMatches, stageNum)
|
||
): {
|
||
initialRecords: Map<string, { wins: number; losses: number }>;
|
||
lockedEliminated: Set<string>;
|
||
lockedAdvanced: Set<string>;
|
||
} {
|
||
const initialRecords = new Map<string, { wins: number; losses: number }>();
|
||
const lockedEliminated = new Set<string>();
|
||
const lockedAdvanced = new Set<string>();
|
||
|
||
const cs2ElimIds = new Set<string>();
|
||
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<string> = 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<number, number>,
|
||
options: SimulateOneMajorOptions = {}
|
||
): Map<string, number> {
|
||
const { bracketMatches, swissMatches, swissRecords, recordedResults } = options;
|
||
const qpMap = new Map<string, number>();
|
||
const poolById = new Map<string, TeamWithElo>(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<number, string[]>();
|
||
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<string>([
|
||
...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;
|
||
}
|