brackt/app/services/simulations/cs-major-simulator.ts

659 lines
26 KiB
TypeScript
Raw Normal View History

Add CS2 Major Qualifying Points simulator and stage management (#260) * Add CS2 Major qualifying points simulator Implements a full CS2 Major tournament simulator with: - 3-stage Swiss format (Opening Bo1, Elimination Bo1/Bo3, Decider all Bo3) + Champions Stage 8-team single-elimination (QF Bo3, SF Bo3, GF Bo5) - Monte Carlo simulation (10,000 iterations) accumulating QP across 2 majors/season - Sampled 24-team field per iteration: top 12 guaranteed, remaining weighted by 1/rank - Stage 3 exits (placements 9-16) sub-ranked by W-L record (2-3 > 1-3 > 0-3) - Stage assignments stored per-event so actual field composition drives simulation - Admin CS Elo form for entering team Elo + HLTV world rankings - Admin CS2 stage setup page for assigning teams to stages and tracking advancement - Database migration: cs2_major_qualifying_points enum value + cs2_major_stage_results table - 24 unit tests covering all exported pure functions https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR * Consolidate Elo + ranking input into generic elo-ratings page The darts-elo and cs-elo pages were unreachable from the admin nav, which always links to the generic elo-ratings page. Extended elo-ratings to conditionally show world ranking fields for simulator types that need it (darts_bracket, cs2_major_qualifying_points), then deleted the redundant sport-specific pages. https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR * Consolidate server postgres connections into one shared pool Four separate postgres() clients were open simultaneously (app, timer, snapshots, socket), each defaulting to 10 connections, exhausting the database's max_connections limit. Replaced with a single shared lazy- initialized client in server/db.ts using a Proxy to defer the DATABASE_URL check until first use (preserving test compatibility). Also bumps the CS2 Champions Stage stochastic test from 200 → 1000 iterations to eliminate flakiness. https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR * 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 * Fix all remaining code review issues - cs2-major-stage.ts: use schema column reference for stageEliminated in markCs2StageEliminations instead of raw SQL string - cs-major-simulator.ts: simulateOneMajor now locks in known stage results when a stage is complete (8 recorded eliminations), only simulating the remaining stages during live events - admin event page: add CS2 Stage Setup button for cs2_major_qualifying_points simulator types; expose simulatorType in server loader type cast - cs2-setup.tsx: replace document.getElementById DOM manipulation with React state (eliminatedChecked map) for checkbox show/hide logic; remove unused stageMap and unassignedParticipants variables https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR * Fix oxlint errors: non-null assertions, sort→toSorted, unused vars - cs-major-simulator.ts: replace 5 non-null assertions (!) with safe optional chaining / if-guards; replace 6 .sort() with .toSorted() - cs2-major-stage.ts: remove unused `inArray` import - cs2-setup.tsx: remove unused `assignedIds` variable https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR * Fix flaky Champions Stage stochastic test The makeTeams(8) helper creates only a 70-pt Elo spread (1800→1730). With the Champions Stage bracket math this gives team-0 a ~19.6% win rate — right at the 0.2 threshold, causing the test to fail ~63% of the time in CI despite 1000 iterations. Use 100-pt steps (1800→1100) instead, giving team-0 a ~40% win rate and raising the assertion threshold to 0.25 for a clear safety margin. https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-04-05 13:40:05 -07:00
/**
* 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].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). */
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);
if (t) advanced.push({ id: t.id, elo: t.elo, rank: t.rank });
}
if ((losses.get(loserId) ?? 0) >= 3) {
eliminatedIds.add(loserId);
const t = teamById.get(loserId);
if (t) eliminated.push({ id: t.id, elo: t.elo, rank: t.rank, wins: wins.get(loserId) ?? 0 });
}
}
}
return { advanced, eliminated };
}
/** Group active teams by (wins, losses) record key. */
function groupByRecord(
active: TeamWithElo[],
wins: Map<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].toSorted((a, b) => a.rank - b.rank);
// Standard 8-team seeding: 1v8, 4v5, 3v6, 2v7
const bracket: [TeamWithElo, TeamWithElo][] = [
[seeded[0], seeded[7]],
[seeded[3], seeded[4]],
[seeded[2], seeded[5]],
[seeded[1], seeded[6]],
];
const placements = new Map<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()].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<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 ?? FALLBACK_ELO, rank: e?.rank ?? 9999 };
})
.toSorted((a, b) => a.rank - b.rank);
if (pool.length === 0) {
throw new Error(
`No participants with Elo ratings found for sports season ${sportsSeasonId}. ` +
`Enter Elo ratings via the CS Elo admin page before simulating.`
);
}
// 3. Load QP config (placements 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()].toSorted((a, b) => b[1] - a[1]);
for (let rank = 0; rank < Math.min(8, ranked.length); rank++) {
const [pid] = ranked[rank];
const idx = idToIndex.get(pid);
if (idx !== undefined) counts[idx][rank]++;
}
}
// 8. Convert counts to probabilities.
return participantIds.map((participantId, i) => ({
participantId,
probabilities: {
probFirst: counts[i][0] / NUM_SIMULATIONS,
probSecond: counts[i][1] / NUM_SIMULATIONS,
probThird: counts[i][2] / NUM_SIMULATIONS,
probFourth: counts[i][3] / NUM_SIMULATIONS,
probFifth: counts[i][4] / NUM_SIMULATIONS,
probSixth: counts[i][5] / NUM_SIMULATIONS,
probSeventh: counts[i][6] / NUM_SIMULATIONS,
probEighth: counts[i][7] / NUM_SIMULATIONS,
},
source: "cs2_major_qualifying_points_monte_carlo",
}));
}
}
// ─── Single major simulation ───────────────────────────────────────────────────
/**
* Simulate one CS2 Major and return QP earned per participant.
*
* If stage results are provided, uses them to determine field composition
* and to lock in results for any completed stages, only simulating the
* remaining stages. A stage is considered complete when the expected 8
* eliminations for that stage have been recorded.
*
* Returns a Map from participantId QP earned in this major.
*/
function simulateOneMajor(
pool: TeamWithElo[],
stageResults: Map<string, { stageEntry: number; stageEliminated: number | null; stageEliminatedWins: number | null }> | undefined,
qpConfig: Map<number, number>
): Map<string, number> {
const qpMap = new Map<string, number>();
const poolById = new Map<string, TeamWithElo>(pool.map((t) => [t.id, t]));
// ── Determine field and stage assignments ─────────────────────────────────
let stage1Teams: TeamWithElo[];
let stage2Direct: TeamWithElo[];
let stage3Direct: TeamWithElo[];
if (stageResults && stageResults.size > 0) {
const s1: TeamWithElo[] = [];
const s2: TeamWithElo[] = [];
const s3: TeamWithElo[] = [];
for (const [participantId, result] of stageResults) {
const team = poolById.get(participantId);
if (!team) continue;
if (result.stageEntry === 1) s1.push(team);
else if (result.stageEntry === 2) s2.push(team);
else if (result.stageEntry === 3) s3.push(team);
}
stage1Teams = s1;
stage2Direct = s2;
stage3Direct = s3;
} else {
const field = sampleField(pool, FIELD_SIZE);
const sorted = field.toSorted((a, b) => a.rank - b.rank);
stage3Direct = sorted.slice(0, 8);
stage2Direct = sorted.slice(8, 16);
stage1Teams = sorted.slice(16, 32);
}
// ── Lock in known stage results ───────────────────────────────────────────
// A stage is "complete" when exactly 8 teams have been recorded as
// eliminated at that stage (the expected output of each Swiss stage).
const eliminatedAtStage = (stageNum: number) =>
stageResults
? [...stageResults.entries()]
.filter(([, r]) => r.stageEliminated === stageNum)
.map(([id, r]) => ({
id,
elo: poolById.get(id)?.elo ?? FALLBACK_ELO,
rank: poolById.get(id)?.rank ?? 9999,
wins: r.stageEliminatedWins ?? 0,
}))
: [];
const stage1Elim = eliminatedAtStage(1);
const stage2Elim = eliminatedAtStage(2);
const stage3Elim = eliminatedAtStage(3);
const stage1Complete = stage1Elim.length === 8;
const stage2Complete = stage2Elim.length === 8;
const stage3Complete = stage3Elim.length === 8;
// ── Stage 1 ───────────────────────────────────────────────────────────────
let stage1Advanced: TeamWithElo[];
let stage1EliminatedFinal: Array<{ id: string; elo: number; rank: number; wins: number }>;
if (stage1Complete) {
// Use known results: teams that entered Stage 1 but weren't eliminated there advanced
const stage1EliminatedIds = new Set(stage1Elim.map((t) => t.id));
stage1Advanced = stage1Teams.filter((t) => !stage1EliminatedIds.has(t.id));
stage1EliminatedFinal = stage1Elim;
} else {
const result = simulateSwiss(stage1Teams, false);
stage1Advanced = result.advanced;
stage1EliminatedFinal = result.eliminated;
}
// ── Stage 2 ───────────────────────────────────────────────────────────────
const stage2Teams = [...stage2Direct, ...stage1Advanced];
let stage2Advanced: TeamWithElo[];
let stage2EliminatedFinal: Array<{ id: string; elo: number; rank: number; wins: number }>;
if (stage2Complete) {
const stage2EliminatedIds = new Set(stage2Elim.map((t) => t.id));
stage2Advanced = stage2Teams.filter((t) => !stage2EliminatedIds.has(t.id));
stage2EliminatedFinal = stage2Elim;
} else {
const result = simulateSwiss(stage2Teams, false);
stage2Advanced = result.advanced;
stage2EliminatedFinal = result.eliminated;
}
// ── Stage 3 ───────────────────────────────────────────────────────────────
const stage3Teams = [...stage3Direct, ...stage2Advanced];
let champTeams: TeamWithElo[];
let stage3EliminatedFinal: Array<{ id: string; elo: number; rank: number; wins: number }>;
if (stage3Complete) {
const stage3EliminatedIds = new Set(stage3Elim.map((t) => t.id));
champTeams = stage3Teams.filter((t) => !stage3EliminatedIds.has(t.id));
stage3EliminatedFinal = stage3Elim;
} else {
const result = simulateSwiss(stage3Teams, true);
champTeams = result.advanced;
stage3EliminatedFinal = result.eliminated;
}
// ── Champions Stage ───────────────────────────────────────────────────────
const champResult = simulateChampionsStage(champTeams);
// ── Assign QP ─────────────────────────────────────────────────────────────
for (const [pid, placement] of champResult.placements) {
qpMap.set(pid, qpConfig.get(placement) ?? 0);
}
const stage3ExitQP = calcStage3ExitQP(stage3EliminatedFinal, qpConfig);
for (const [pid, qp] of stage3ExitQP) {
qpMap.set(pid, qp);
}
for (const t of stage1EliminatedFinal) qpMap.set(t.id, 0);
for (const t of stage2EliminatedFinal) qpMap.set(t.id, 0);
return qpMap;
}