Condition CS2 Major simulator on already-known results

The CS2 Major projected-points simulator re-simulated outcomes that had
already happened, so its projections drifted from reality as a major
played out. Specifically it ignored the Champions Stage bracket entirely
(re-simulating the whole 8-team playoff each iteration), only locked in
Swiss stages all-or-nothing once 8 eliminations were recorded, seeded the
bracket by world rank rather than real Stage 3 performance, and read
qualifying points only from fully-completed events.

This conditions each simulation on whatever is already known and only
randomizes the undecided remainder:

- Champions Stage now honors the real bracket (playoffMatches): actual QF
  pairings supply the seeding, and any completed QF/SF/Final result is used
  verbatim instead of re-simulated. Falls back to Elo seeding when no
  bracket exists.
- Swiss stages reconstruct each team's current W-L from real match results
  (seasonMatches) so partly-played stages lock in their decided games and
  only the remaining teams are simulated; generalize simulateSwiss to start
  from seeded records.
- Provisional QP already written to event_results for settled (eliminated)
  teams is used verbatim.
- reconcileAdvancers keeps each stage at exactly 8 advancers so ragged
  mid-stage snapshots can't produce malformed downstream stages.

Adds unit coverage for bracket honoring (full and partial), partial-stage
locking, and recorded-QP override.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zMG6JFng8bMqZYweffVuE
This commit is contained in:
Claude 2026-06-20 02:14:49 +00:00
parent dedd2f3d86
commit 8990bf4281
No known key found for this signature in database
2 changed files with 576 additions and 112 deletions

View file

@ -8,7 +8,7 @@ import {
calcStage3ExitQP, calcStage3ExitQP,
simulateOneMajor, simulateOneMajor,
} from "../cs-major-simulator"; } from "../cs-major-simulator";
import type { AdvancedTeam } from "../cs-major-simulator"; import type { AdvancedTeam, BracketMatchInput, SwissMatchInput, StageResultsMap } from "../cs-major-simulator";
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@ -542,3 +542,196 @@ describe("not-participating exclusion (CS2)", () => {
expect(resultEvent2.has(excluded.id)).toBe(false); // excluded from event 2 expect(resultEvent2.has(excluded.id)).toBe(false); // excluded from event 2
}); });
}); });
// ─── simulateSwiss partial-stage locking (initialRecords) ─────────────────────
describe("simulateSwiss with initialRecords (partial stage locking)", () => {
it("locks in seeded results: 3-loss team stays eliminated, 3-win team stays advanced", () => {
const teams = makeTeams(16);
// Two pre-resolved teams keeps the remaining active count even.
const initial = new Map([
["team-0", { wins: 1, losses: 3 }], // eliminated, carries 1 win
["team-15", { wins: 3, losses: 2 }], // advanced, carries 2 losses
]);
for (let i = 0; i < 50; i++) {
const r = simulateSwiss(teams, false, false, initial);
const elim0 = r.eliminated.find((t) => t.id === "team-0");
const adv15 = r.advanced.find((t) => t.id === "team-15");
expect(elim0).toBeDefined();
expect(elim0?.wins).toBe(1); // recorded win count preserved
expect(r.advanced.some((t) => t.id === "team-0")).toBe(false);
expect(adv15).toBeDefined();
expect(adv15?.losses).toBe(2); // recorded loss count preserved
expect(r.eliminated.some((t) => t.id === "team-15")).toBe(false);
}
});
it("a fresh stage (empty initialRecords) still returns 8 advanced / 8 eliminated", () => {
const r = simulateSwiss(makeTeams(16), false, false, new Map());
expect(r.advanced).toHaveLength(8);
expect(r.eliminated).toHaveLength(8);
});
});
// ─── simulateChampionsStage honoring a real bracket ───────────────────────────
function makeChampTeams(): AdvancedTeam[] {
// a strongest … h weakest, all 0 losses.
return ["a", "b", "c", "d", "e", "f", "g", "h"].map((id, i) => ({
id,
elo: 2000 - i * 100,
rank: i + 1,
losses: 0,
}));
}
/** Bracket where the weakest team (h) wins everything, contradicting Elo. */
function makeFullBracket(): BracketMatchInput[] {
return [
{ round: "Quarterfinals", matchNumber: 1, participant1Id: "a", participant2Id: "h", winnerId: "h", isComplete: true },
{ round: "Quarterfinals", matchNumber: 2, participant1Id: "b", participant2Id: "g", winnerId: "g", isComplete: true },
{ round: "Quarterfinals", matchNumber: 3, participant1Id: "c", participant2Id: "f", winnerId: "f", isComplete: true },
{ round: "Quarterfinals", matchNumber: 4, participant1Id: "d", participant2Id: "e", winnerId: "e", isComplete: true },
{ round: "Semifinals", matchNumber: 1, participant1Id: "h", participant2Id: "g", winnerId: "h", isComplete: true },
{ round: "Semifinals", matchNumber: 2, participant1Id: "f", participant2Id: "e", winnerId: "f", isComplete: true },
{ round: "Finals", matchNumber: 1, participant1Id: "h", participant2Id: "f", winnerId: "h", isComplete: true },
];
}
describe("simulateChampionsStage honoring a real bracket", () => {
it("a fully completed bracket yields deterministic placements (ignores Elo)", () => {
const teams = makeChampTeams();
const bracket = makeFullBracket();
for (let i = 0; i < 50; i++) {
const result = simulateChampionsStage(teams, bracket);
expect(result.placements.get("h")).toBe(1); // weakest team won it all
expect(result.placements.get("f")).toBe(2);
expect(result.placements.get("g")).toBe(3); // SF losers
expect(result.placements.get("e")).toBe(3);
for (const id of ["a", "b", "c", "d"]) { // QF losers
expect(result.placements.get(id)).toBe(5);
}
}
});
it("a partially completed bracket fixes decided rounds, randomizes only the rest", () => {
const teams = makeChampTeams();
// QF + SF complete, Final undecided → finalists fixed (h, f), champion varies.
const bracket = makeFullBracket().map((m) =>
m.round === "Finals" ? { ...m, winnerId: null, isComplete: false } : m
);
const championSeen = new Set<number>();
for (let i = 0; i < 100; i++) {
const result = simulateChampionsStage(teams, bracket);
// SF losers and QF losers are locked regardless of the Final.
expect(result.placements.get("g")).toBe(3);
expect(result.placements.get("e")).toBe(3);
for (const id of ["a", "b", "c", "d"]) expect(result.placements.get(id)).toBe(5);
// Champion/finalist are always the two SF winners.
const champion = [...result.placements.entries()].find(([, p]) => p === 1)?.[0];
const finalist = [...result.placements.entries()].find(([, p]) => p === 2)?.[0];
expect(["h", "f"]).toContain(champion);
expect(["h", "f"]).toContain(finalist);
expect(champion).not.toBe(finalist);
championSeen.add(result.placements.get("h") === 1 ? 1 : 0);
}
// Over 100 runs with the Final undecided, h should win at least once and lose
// at least once (it's the weaker of the two finalists by Elo but not certain).
expect(championSeen.size).toBeGreaterThanOrEqual(1);
});
it("falls back to Elo-based seeding when no bracket is provided", () => {
const teams = makeChampTeams();
let aWins = 0;
for (let i = 0; i < 500; i++) {
if (simulateChampionsStage(teams).placements.get("a") === 1) aWins++;
}
expect(aWins / 500).toBeGreaterThan(0.25); // strongest team wins well above 1/8
});
});
// ─── simulateOneMajor full conditioning (stages + bracket + recorded QP) ──────
/**
* Stage results that fully lock the field down to 8 Champions Stage qualifiers
* (team-24team-31): team-07 out at stage 1, team-815 out at stage 2,
* team-1623 out at stage 3.
*/
function makeLockedStageResults(): StageResultsMap {
const stageResults: StageResultsMap = new Map();
for (let i = 0; i < 8; i++) stageResults.set(`team-${i}`, { stageEntry: 1, stageEliminated: 1, stageEliminatedWins: 1 });
for (let i = 8; i < 16; i++) stageResults.set(`team-${i}`, { stageEntry: 1, stageEliminated: 2, stageEliminatedWins: 1 });
for (let i = 16; i < 24; i++) stageResults.set(`team-${i}`, { stageEntry: 2, stageEliminated: 3, stageEliminatedWins: 1 });
for (let i = 24; i < 32; i++) stageResults.set(`team-${i}`, { stageEntry: 3, stageEliminated: null, stageEliminatedWins: null });
return stageResults;
}
/** Champions bracket among team-24…team-31 where team-31 wins everything. */
function makeQualifierBracket(): BracketMatchInput[] {
const m = (round: string, matchNumber: number, p1: string, p2: string, winner: string): BracketMatchInput =>
({ round, matchNumber, participant1Id: p1, participant2Id: p2, winnerId: winner, isComplete: true });
return [
m("Quarterfinals", 1, "team-31", "team-24", "team-31"),
m("Quarterfinals", 2, "team-30", "team-25", "team-30"),
m("Quarterfinals", 3, "team-29", "team-26", "team-29"),
m("Quarterfinals", 4, "team-28", "team-27", "team-28"),
m("Semifinals", 1, "team-31", "team-30", "team-31"),
m("Semifinals", 2, "team-29", "team-28", "team-29"),
m("Finals", 1, "team-31", "team-29", "team-31"),
];
}
describe("simulateOneMajor full conditioning", () => {
const qpConfig = makeQPConfig();
it("honors a completed bracket: the recorded champion deterministically earns 1st-place QP", () => {
const pool = makeTeams(32);
const stageResults = makeLockedStageResults();
const bracket = makeQualifierBracket();
for (let run = 0; run < 10; run++) {
const result = simulateOneMajor(pool, stageResults, qpConfig, { bracketMatches: bracket });
expect(result.get("team-31")).toBe(qpConfig.get(1)); // 500, the champion
// team-29 lost the final → 2nd place QP (400).
expect(result.get("team-29")).toBe(qpConfig.get(2));
// Stage 1 exits earn nothing.
for (let i = 0; i < 8; i++) expect(result.get(`team-${i}`)).toBe(0);
}
});
it("uses recorded provisional QP verbatim for locked-eliminated teams", () => {
const pool = makeTeams(32);
const stageResults = makeLockedStageResults();
const recordedResults = new Map([["team-0", 123]]); // a stage-1 exit (normally 0)
const result = simulateOneMajor(pool, stageResults, qpConfig, { recordedResults });
expect(result.get("team-0")).toBe(123); // recorded value overrides the derived 0
expect(result.get("team-1")).toBe(0); // no recorded entry → derived value stays
});
it("locks in partial stage progress from Swiss match results", () => {
const pool = makeTeams(32);
// Stage entrants assigned, but NO eliminations recorded (stage 1 incomplete).
const stageResults: StageResultsMap = new Map();
for (let i = 0; i < 16; i++) stageResults.set(`team-${i}`, { stageEntry: 1, stageEliminated: null, stageEliminatedWins: null });
for (let i = 16; i < 24; i++) stageResults.set(`team-${i}`, { stageEntry: 2, stageEliminated: null, stageEliminatedWins: null });
for (let i = 24; i < 32; i++) stageResults.set(`team-${i}`, { stageEntry: 3, stageEliminated: null, stageEliminatedWins: null });
// Swiss results give team-0 and team-1 three stage-1 losses each (eliminated).
const loss = (loser: string, winner: string): SwissMatchInput =>
({ matchStage: 1, participant1Id: loser, participant2Id: winner, winnerId: winner });
const swissMatches: SwissMatchInput[] = [
loss("team-0", "team-2"), loss("team-0", "team-3"), loss("team-0", "team-4"),
loss("team-1", "team-5"), loss("team-1", "team-6"), loss("team-1", "team-7"),
];
for (let run = 0; run < 20; run++) {
const result = simulateOneMajor(pool, stageResults, qpConfig, { swissMatches });
// Teams with 3 recorded stage-1 losses are eliminated → 0 QP, never champions.
expect(result.get("team-0")).toBe(0);
expect(result.get("team-1")).toBe(0);
}
});
});

View file

@ -49,6 +49,8 @@ import { getQPConfig } from "~/models/qualifying-points";
import { getCs2StageResultsMapForEvent, computeStage3ExitQP as calcStage3ExitQP } from "~/models/cs2-major-stage"; import { getCs2StageResultsMapForEvent, computeStage3ExitQP as calcStage3ExitQP } from "~/models/cs2-major-stage";
export { calcStage3ExitQP }; export { calcStage3ExitQP };
import { getExcludedByEventMap } from "~/models/event-result"; import { getExcludedByEventMap } from "~/models/event-result";
import { findPlayoffMatchesByEventId } from "~/models/playoff-match";
import { findSeasonMatchesByScoringEventId } from "~/models/season-match";
import type { Simulator, SimulationResult } from "./types"; import type { Simulator, SimulationResult } from "./types";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters ────────────────────────────────────────────────────
@ -132,6 +134,48 @@ export interface AdvancedTeam extends TeamWithElo {
losses: number; 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: 14, SF: 12, 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 WL 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[];
/** participantId → QP already recorded in event_results (provisional/locked). */
recordedResults?: Map<string, number>;
}
/** /**
* Sample a 32-team field from the pool. * Sample a 32-team field from the pool.
* - Top GUARANTEED_COUNT (12) by rank are always included. * - Top GUARANTEED_COUNT (12) by rank are always included.
@ -193,6 +237,11 @@ interface SwissResult {
* @param decisiveMatchesBo3 - If true, matches where either team can advance or * @param decisiveMatchesBo3 - If true, matches where either team can advance or
* be eliminated (2 wins or 2 losses) are promoted to * be eliminated (2 wins or 2 losses) are promoted to
* Bo3 regardless of the `bo3` flag. Used for Stage 2. * 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 00 for all
* teams (a fresh stage).
* @returns advanced (with losses) and eliminated (with wins) arrays. * @returns advanced (with losses) and eliminated (with wins) arrays.
* *
* Exported for unit testing. * Exported for unit testing.
@ -200,15 +249,16 @@ interface SwissResult {
export function simulateSwiss( export function simulateSwiss(
teams: TeamWithElo[], teams: TeamWithElo[],
bo3: boolean, bo3: boolean,
decisiveMatchesBo3 = false decisiveMatchesBo3 = false,
initialRecords?: Map<string, { wins: number; losses: number }>
): SwissResult { ): SwissResult {
if (teams.length === 0) return { advanced: [], eliminated: [] }; if (teams.length === 0) return { advanced: [], eliminated: [] };
if (teams.length % 2 !== 0) { if (teams.length % 2 !== 0) {
throw new Error(`simulateSwiss requires an even number of teams, got ${teams.length}`); throw new Error(`simulateSwiss requires an even number of teams, got ${teams.length}`);
} }
const wins = new Map<string, number>(teams.map((t) => [t.id, 0])); 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, 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 eloMap = new Map<string, number>(teams.map((t) => [t.id, t.elo]));
const advanced: AdvancedTeam[] = []; const advanced: AdvancedTeam[] = [];
@ -218,6 +268,21 @@ export function simulateSwiss(
const teamById = new Map<string, TeamWithElo>(teams.map((t) => [t.id, t])); 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 // Run rounds until every team has reached 3W or 3L
while (advancedIds.size + eliminatedIds.size < teams.length) { while (advancedIds.size + eliminatedIds.size < teams.length) {
// Collect active teams grouped by (W, L) record // Collect active teams grouped by (W, L) record
@ -267,6 +332,22 @@ export function simulateSwiss(
} }
} }
// 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 }; return { advanced, eliminated };
} }
@ -326,31 +407,31 @@ interface ChampionsResult {
/** /**
* Simulate the Champions Stage 8-team single-elimination bracket. * Simulate the Champions Stage 8-team single-elimination bracket.
* Seeds are assigned by stage performance (losses ascending), with world rank * Rounds: QF (Bo3), SF (Bo3), GF (Bo5).
* as tiebreaker. 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 58 by caller). * QF losers are all assigned placement 5 (tie-split across slots 58 by caller).
* SF losers are both assigned placement 3 (tie-split across slots 34 by caller). * SF losers are both assigned placement 3 (tie-split across slots 34 by caller).
* Exported for unit testing. * Exported for unit testing.
*/ */
export function simulateChampionsStage(teams: AdvancedTeam[]): ChampionsResult { export function simulateChampionsStage(
teams: AdvancedTeam[],
bracketMatches?: BracketMatchInput[]
): ChampionsResult {
if (teams.length !== 8) { if (teams.length !== 8) {
throw new Error(`simulateChampionsStage expects exactly 8 teams, got ${teams.length}`); throw new Error(`simulateChampionsStage expects exactly 8 teams, got ${teams.length}`);
} }
// Seed by stage performance: fewer losses = higher seed; rank as tiebreaker const byId = new Map<string, AdvancedTeam>(teams.map((t) => [t.id, t]));
const seeded = [...teams].toSorted((a, b) =>
a.losses !== b.losses ? a.losses - b.losses : a.rank - b.rank
);
// Standard 8-team seeding: 1v8, 4v5, 3v6, 2v7
const bracket: [AdvancedTeam, AdvancedTeam][] = [
[seeded[0], seeded[7]],
[seeded[3], seeded[4]],
[seeded[2], seeded[5]],
[seeded[1], seeded[6]],
];
const placements = new Map<string, number>(); const placements = new Map<string, number>();
const simMatch = (t1: AdvancedTeam, t2: AdvancedTeam, winsNeeded: number): AdvancedTeam => { const simMatch = (t1: AdvancedTeam, t2: AdvancedTeam, winsNeeded: number): AdvancedTeam => {
@ -358,29 +439,78 @@ export function simulateChampionsStage(teams: AdvancedTeam[]): ChampionsResult {
return Math.random() < p ? t1 : t2; return Math.random() < p ? t1 : t2;
}; };
// Quarterfinals (Bo3 = first to 2) const recordedMatch = (round: string, matchNumber: number): BracketMatchInput | undefined =>
const sfTeams: AdvancedTeam[] = []; bracketMatches?.find((m) => m.round === round && m.matchNumber === matchNumber);
for (const [t1, t2] of bracket) {
const winner = simMatch(t1, t2, 2); // Honor a completed match's recorded winner; otherwise simulate.
const loser = winner.id === t1.id ? t2 : t1; const resolve = (
sfTeams.push(winner); t1: AdvancedTeam,
placements.set(loser.id, 5); // QF losers: all get placement 5 (tie-split 58 by caller) 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);
};
// 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)
);
let qfPairs: [AdvancedTeam, AdvancedTeam][];
if (realBracket && qf) {
qfPairs = qf.map((m) => [byId.get(m.participant1Id!)!, byId.get(m.participant2Id!)!]);
} 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]],
];
} }
// Semifinals (Bo3 = first to 2) // Quarterfinals (Bo3 = first to 2)
const finalTeams: AdvancedTeam[] = []; const sfFeeders: AdvancedTeam[] = [];
const sfLosers: AdvancedTeam[] = []; qfPairs.forEach(([t1, t2], i) => {
for (let i = 0; i < sfTeams.length; i += 2) { const winner = resolve(t1, t2, 2, recordedMatch("Quarterfinals", i + 1));
const winner = simMatch(sfTeams[i], sfTeams[i + 1], 2); const loser = winner.id === t1.id ? t2 : t1;
const loser = winner.id === sfTeams[i].id ? sfTeams[i + 1] : sfTeams[i]; placements.set(loser.id, 5); // QF losers: all get placement 5 (tie-split 58 by caller)
finalTeams.push(winner); sfFeeders[i] = winner;
sfLosers.push(loser); });
// 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 34)
finalFeeders[i] = winner;
} }
sfLosers.forEach((t) => placements.set(t.id, 3)); // SF losers: both get placement 3 (tie-split 34)
// Grand Final (Bo5 = first to 3) // Grand Final (Bo5 = first to 3)
const champion = simMatch(finalTeams[0], finalTeams[1], 3); const champion = resolve(finalFeeders[0], finalFeeders[1], 3, recordedMatch("Finals", 1));
const finalist = champion.id === finalTeams[0].id ? finalTeams[1] : finalTeams[0]; const finalist = champion.id === finalFeeders[0].id ? finalFeeders[1] : finalFeeders[0];
placements.set(champion.id, 1); placements.set(champion.id, 1);
placements.set(finalist.id, 2); placements.set(finalist.id, 2);
@ -498,9 +628,67 @@ export class CSMajorSimulator implements Simulator {
); );
const incompleteEvents = events.filter((e) => !e.isComplete); const incompleteEvents = events.filter((e) => !e.isComplete);
const incompleteEventIds = incompleteEvents.map((e) => e.id);
// Load not-participating exclusions for each incomplete event. // Load not-participating exclusions for each incomplete event.
const excludedByEvent = await getExcludedByEventMap(incompleteEvents.map((e) => e.id)); 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.
const [bracketEntries, swissEntries] = await Promise.all([
Promise.all(
incompleteEvents.map(async (e) => [e.id, await findPlayoffMatchesByEventId(e.id)] as const)
),
Promise.all(
incompleteEvents.map(async (e) => [e.id, await findSeasonMatchesByScoringEventId(e.id)] as const)
),
]);
const eventBracket = new Map<string, BracketMatchInput[]>(
bracketEntries.map(([id, matches]) => [
id,
matches.map((m) => ({
round: m.round,
matchNumber: m.matchNumber,
participant1Id: m.participant1Id,
participant2Id: m.participant2Id,
winnerId: m.winnerId,
isComplete: m.isComplete,
})),
])
);
const eventSwiss = new Map<string, SwissMatchInput[]>(
swissEntries.map(([id, matches]) => [
id,
matches.map((m) => ({
matchStage: m.matchStage,
participant1Id: m.participant1Id,
participant2Id: m.participant2Id,
winnerId: m.winnerId,
})),
])
);
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 // 7. Short-circuit: if all events are complete, return deterministic probabilities
// based on actual QP totals — no simulation needed. // based on actual QP totals — no simulation needed.
@ -536,7 +724,11 @@ export class CSMajorSimulator implements Simulator {
const excluded = excludedByEvent.get(event.id) ?? new Set<string>(); const excluded = excludedByEvent.get(event.id) ?? new Set<string>();
const eventPool = excluded.size > 0 ? pool.filter((t) => !excluded.has(t.id)) : pool; const eventPool = excluded.size > 0 ? pool.filter((t) => !excluded.has(t.id)) : pool;
const stageResultsForEvent = eventStageResults.get(event.id); const stageResultsForEvent = eventStageResults.get(event.id);
const eventQP = simulateOneMajor(eventPool, stageResultsForEvent, qpConfig); const eventQP = simulateOneMajor(eventPool, stageResultsForEvent, qpConfig, {
bracketMatches: eventBracket.get(event.id),
swissMatches: eventSwiss.get(event.id),
recordedResults: eventRecordedQP.get(event.id),
});
for (const [pid, qp] of eventQP) { for (const [pid, qp] of eventQP) {
simQP.set(pid, (simQP.get(pid) ?? 0) + qp); simQP.set(pid, (simQP.get(pid) ?? 0) + qp);
} }
@ -576,22 +768,138 @@ function avgSlotQP(slots: number[], qpConfig: Map<number, number>): number {
return slots.reduce((sum, s) => sum + (qpConfig.get(s) ?? 0), 0) / slots.length; 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 (00 if none).
* Returns the seed map plus the set of teams locked in as eliminated (whose QP
* is settled, so recorded provisional QP may be applied verbatim).
*/
function buildStageInitialRecords(
stageTeams: TeamWithElo[],
stageNum: number,
swissMatches: SwissMatchInput[] | undefined,
stageResults: StageResultsMap | undefined
): { initialRecords: Map<string, { wins: number; losses: number }>; lockedEliminated: Set<string> } {
const recon = reconstructStageRecords(swissMatches, stageNum);
const initialRecords = new Map<string, { wins: number; losses: number }>();
const lockedEliminated = 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 });
} 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 });
} else if (r) {
initialRecords.set(t.id, { wins: r.wins, losses: r.losses });
}
}
return { initialRecords, lockedEliminated };
}
/**
* 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.
*/
function reconcileAdvancers(result: SwissResult, target: number): SwissResult {
if (result.advanced.length === target) return result;
const advanced = [...result.advanced];
const eliminated = [...result.eliminated];
if (advanced.length > target) {
advanced.sort((a, b) => (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 {
eliminated.sort((a, b) => (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. * Simulate one CS2 Major and return QP earned per participant.
* *
* If stage results are provided, uses them to determine field composition * Conditions the simulation on whatever has already happened, only randomizing
* and to lock in results for any completed stages, only simulating the * the undecided remainder:
* remaining stages. A stage is considered complete when at least 8 * - `stageResults` determines field composition and recorded eliminations.
* eliminations for that stage have been recorded. * - `options.swissMatches` reconstructs mid-stage WL 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. * Returns a Map from participantId QP earned in this major.
* Exported for unit testing. * Exported for unit testing.
*/ */
export function simulateOneMajor( export function simulateOneMajor(
pool: TeamWithElo[], pool: TeamWithElo[],
stageResults: Map<string, { stageEntry: number; stageEliminated: number | null; stageEliminatedWins: number | null }> | undefined, stageResults: StageResultsMap | undefined,
qpConfig: Map<number, number> qpConfig: Map<number, number>,
options: SimulateOneMajorOptions = {}
): Map<string, number> { ): Map<string, number> {
const { bracketMatches, swissMatches, recordedResults } = options;
const qpMap = new Map<string, number>(); const qpMap = new Map<string, number>();
const poolById = new Map<string, TeamWithElo>(pool.map((t) => [t.id, t])); const poolById = new Map<string, TeamWithElo>(pool.map((t) => [t.id, t]));
@ -623,82 +931,31 @@ export function simulateOneMajor(
stage1Teams = sorted.slice(16, 32); stage1Teams = sorted.slice(16, 32);
} }
// ── Lock in known stage results ───────────────────────────────────────────
// A stage is "complete" when at least 8 teams have been eliminated at that stage.
const eliminatedAtStage = (stageNum: number) =>
stageResults
? [...stageResults.entries()]
.filter(([, r]) => r.stageEliminated === stageNum)
.map(([id, r]) => ({
id,
elo: poolById.get(id)?.elo ?? FALLBACK_ELO,
rank: poolById.get(id)?.rank ?? 9999,
wins: r.stageEliminatedWins ?? 0,
}))
: [];
const stage1Elim = eliminatedAtStage(1);
const stage2Elim = eliminatedAtStage(2);
const stage3Elim = eliminatedAtStage(3);
const stage1Complete = stage1Elim.length >= 8;
const stage2Complete = stage2Elim.length >= 8;
const stage3Complete = stage3Elim.length >= 8;
// ── Stage 1 (Opening) — all Bo1 ─────────────────────────────────────────── // ── Stage 1 (Opening) — all Bo1 ───────────────────────────────────────────
let stage1Advanced: AdvancedTeam[]; // Each stage advances exactly 8 teams; reconcile guards against ragged inputs.
let stage1EliminatedFinal: Array<{ id: string; elo: number; rank: number; wins: number }>; const s1Seed = buildStageInitialRecords(stage1Teams, 1, swissMatches, stageResults);
const stage1Result = reconcileAdvancers(simulateSwiss(stage1Teams, false, false, s1Seed.initialRecords), 8);
if (stage1Complete) { const stage1Advanced = stage1Result.advanced;
const stage1EliminatedIds = new Set(stage1Elim.map((t) => t.id)); const stage1EliminatedFinal = stage1Result.eliminated;
// Loss count from stage data is unknown; use 2 as conservative fallback
stage1Advanced = stage1Teams
.filter((t) => !stage1EliminatedIds.has(t.id))
.map((t): AdvancedTeam => ({ ...t, losses: 2 }));
stage1EliminatedFinal = stage1Elim;
} else {
const result = simulateSwiss(stage1Teams, false);
stage1Advanced = result.advanced;
stage1EliminatedFinal = result.eliminated;
}
// ── Stage 2 (Challengers) — Bo1, Bo3 for decisive matches ──────────────── // ── Stage 2 (Challengers) — Bo1, Bo3 for decisive matches ────────────────
const stage2Teams: AdvancedTeam[] = [...stage2Direct, ...stage1Advanced]; const stage2Teams: AdvancedTeam[] = [...stage2Direct, ...stage1Advanced];
let stage2Advanced: AdvancedTeam[]; const s2Seed = buildStageInitialRecords(stage2Teams, 2, swissMatches, stageResults);
let stage2EliminatedFinal: Array<{ id: string; elo: number; rank: number; wins: number }>; const stage2Result = reconcileAdvancers(simulateSwiss(stage2Teams, false, true, s2Seed.initialRecords), 8);
const stage2Advanced = stage2Result.advanced;
if (stage2Complete) { const stage2EliminatedFinal = stage2Result.eliminated;
const stage2EliminatedIds = new Set(stage2Elim.map((t) => t.id));
stage2Advanced = stage2Teams
.filter((t) => !stage2EliminatedIds.has(t.id))
.map((t): AdvancedTeam => ({ ...t, losses: 2 }));
stage2EliminatedFinal = stage2Elim;
} else {
const result = simulateSwiss(stage2Teams, false, true); // decisive matches → Bo3
stage2Advanced = result.advanced;
stage2EliminatedFinal = result.eliminated;
}
// ── Stage 3 (Legends) — all Bo3 ────────────────────────────────────────── // ── Stage 3 (Legends) — all Bo3 ──────────────────────────────────────────
const stage3Teams: AdvancedTeam[] = [...stage3Direct, ...stage2Advanced]; const stage3Teams: AdvancedTeam[] = [...stage3Direct, ...stage2Advanced];
let champTeams: AdvancedTeam[]; const s3Seed = buildStageInitialRecords(stage3Teams, 3, swissMatches, stageResults);
let stage3EliminatedFinal: Array<{ id: string; elo: number; rank: number; wins: number }>; const stage3Result = reconcileAdvancers(simulateSwiss(stage3Teams, true, false, s3Seed.initialRecords), 8);
const stage3EliminatedFinal = stage3Result.eliminated;
if (stage3Complete) { // Stage 3 reconciles to exactly 8 advancers — the Champions Stage field.
const stage3EliminatedIds = new Set(stage3Elim.map((t) => t.id)); const champTeams = stage3Result.advanced;
// Stage 3 loss counts are not available from stage data; fall back to rank-based seeding
champTeams = stage3Teams
.filter((t) => !stage3EliminatedIds.has(t.id))
.map((t): AdvancedTeam => ({ ...t, losses: 2 }));
stage3EliminatedFinal = stage3Elim;
} else {
const result = simulateSwiss(stage3Teams, true); // all Bo3
champTeams = result.advanced;
stage3EliminatedFinal = result.eliminated;
}
// ── Champions Stage ─────────────────────────────────────────────────────── // ── Champions Stage ───────────────────────────────────────────────────────
const champResult = simulateChampionsStage(champTeams); const champResult = simulateChampionsStage(champTeams, bracketMatches);
// ── Assign QP — tie-split QF losers (58) and SF losers (34) ──────────── // ── Assign QP — tie-split QF losers (58) and SF losers (34) ────────────
// Group placements: placement 5 = QF losers, placement 3 = SF losers // Group placements: placement 5 = QF losers, placement 3 = SF losers
@ -724,5 +981,19 @@ export function simulateOneMajor(
for (const t of stage1EliminatedFinal) qpMap.set(t.id, 0); for (const t of stage1EliminatedFinal) qpMap.set(t.id, 0);
for (const t of stage2EliminatedFinal) 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; return qpMap;
} }