From 8990bf4281d471956386b193df3dcce846124e60 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 02:14:49 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_018zMG6JFng8bMqZYweffVuE --- .../__tests__/cs-major-simulator.test.ts | 195 ++++++- .../simulations/cs-major-simulator.ts | 493 ++++++++++++++---- 2 files changed, 576 insertions(+), 112 deletions(-) diff --git a/app/services/simulations/__tests__/cs-major-simulator.test.ts b/app/services/simulations/__tests__/cs-major-simulator.test.ts index 8320bdd..b92287d 100644 --- a/app/services/simulations/__tests__/cs-major-simulator.test.ts +++ b/app/services/simulations/__tests__/cs-major-simulator.test.ts @@ -8,7 +8,7 @@ import { calcStage3ExitQP, simulateOneMajor, } from "../cs-major-simulator"; -import type { AdvancedTeam } from "../cs-major-simulator"; +import type { AdvancedTeam, BracketMatchInput, SwissMatchInput, StageResultsMap } from "../cs-major-simulator"; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -542,3 +542,196 @@ describe("not-participating exclusion (CS2)", () => { 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(); + 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-24…team-31): team-0–7 out at stage 1, team-8–15 out at stage 2, + * team-16–23 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); + } + }); +}); diff --git a/app/services/simulations/cs-major-simulator.ts b/app/services/simulations/cs-major-simulator.ts index 7e0c23d..c01852e 100644 --- a/app/services/simulations/cs-major-simulator.ts +++ b/app/services/simulations/cs-major-simulator.ts @@ -49,6 +49,8 @@ import { getQPConfig } from "~/models/qualifying-points"; import { getCs2StageResultsMapForEvent, computeStage3ExitQP as calcStage3ExitQP } from "~/models/cs2-major-stage"; export { calcStage3ExitQP }; import { getExcludedByEventMap } from "~/models/event-result"; +import { findPlayoffMatchesByEventId } from "~/models/playoff-match"; +import { findSeasonMatchesByScoringEventId } from "~/models/season-match"; import type { Simulator, SimulationResult } from "./types"; // ─── Simulation parameters ──────────────────────────────────────────────────── @@ -132,6 +134,48 @@ export interface AdvancedTeam extends TeamWithElo { 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[]; + /** participantId → QP already recorded in event_results (provisional/locked). */ + recordedResults?: Map; +} + /** * Sample a 32-team field from the pool. * - 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 * 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. @@ -200,15 +249,16 @@ interface SwissResult { export function simulateSwiss( teams: TeamWithElo[], bo3: boolean, - decisiveMatchesBo3 = false + decisiveMatchesBo3 = false, + initialRecords?: Map ): SwissResult { if (teams.length === 0) return { advanced: [], eliminated: [] }; if (teams.length % 2 !== 0) { throw new Error(`simulateSwiss requires an even number of teams, got ${teams.length}`); } - const wins = new Map(teams.map((t) => [t.id, 0])); - const losses = new Map(teams.map((t) => [t.id, 0])); + const wins = new Map(teams.map((t) => [t.id, initialRecords?.get(t.id)?.wins ?? 0])); + const losses = new Map(teams.map((t) => [t.id, initialRecords?.get(t.id)?.losses ?? 0])); const eloMap = new Map(teams.map((t) => [t.id, t.elo])); const advanced: AdvancedTeam[] = []; @@ -218,6 +268,21 @@ export function simulateSwiss( const teamById = new Map(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 @@ -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 }; } @@ -326,31 +407,31 @@ interface ChampionsResult { /** * Simulate the Champions Stage 8-team single-elimination bracket. - * Seeds are assigned by stage performance (losses ascending), with world rank - * as tiebreaker. Rounds: QF (Bo3), SF (Bo3), GF (Bo5). + * 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[]): ChampionsResult { +export function simulateChampionsStage( + teams: AdvancedTeam[], + bracketMatches?: BracketMatchInput[] +): ChampionsResult { if (teams.length !== 8) { throw new Error(`simulateChampionsStage expects exactly 8 teams, got ${teams.length}`); } - // Seed by stage performance: fewer losses = higher seed; rank as tiebreaker - const seeded = [...teams].toSorted((a, b) => - a.losses !== b.losses ? a.losses - b.losses : a.rank - b.rank - ); - - // Standard 8-team seeding: 1v8, 4v5, 3v6, 2v7 - const bracket: [AdvancedTeam, AdvancedTeam][] = [ - [seeded[0], seeded[7]], - [seeded[3], seeded[4]], - [seeded[2], seeded[5]], - [seeded[1], seeded[6]], - ]; - + const byId = new Map(teams.map((t) => [t.id, t])); const placements = new Map(); 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; }; - // Quarterfinals (Bo3 = first to 2) - const sfTeams: AdvancedTeam[] = []; - for (const [t1, t2] of bracket) { - const winner = simMatch(t1, t2, 2); - const loser = winner.id === t1.id ? t2 : t1; - sfTeams.push(winner); - placements.set(loser.id, 5); // QF losers: all get placement 5 (tie-split 5–8 by caller) + const recordedMatch = (round: string, matchNumber: number): BracketMatchInput | undefined => + bracketMatches?.find((m) => m.round === round && m.matchNumber === matchNumber); + + // 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); + }; + + // 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) - const finalTeams: AdvancedTeam[] = []; - const sfLosers: AdvancedTeam[] = []; - for (let i = 0; i < sfTeams.length; i += 2) { - const winner = simMatch(sfTeams[i], sfTeams[i + 1], 2); - const loser = winner.id === sfTeams[i].id ? sfTeams[i + 1] : sfTeams[i]; - finalTeams.push(winner); - sfLosers.push(loser); + // 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; } - sfLosers.forEach((t) => placements.set(t.id, 3)); // SF losers: both get placement 3 (tie-split 3–4) // Grand Final (Bo5 = first to 3) - const champion = simMatch(finalTeams[0], finalTeams[1], 3); - const finalist = champion.id === finalTeams[0].id ? finalTeams[1] : finalTeams[0]; + 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); @@ -498,9 +628,67 @@ export class CSMajorSimulator implements Simulator { ); 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(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( + 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( + swissEntries.map(([id, matches]) => [ + id, + matches.map((m) => ({ + matchStage: m.matchStage, + participant1Id: m.participant1Id, + participant2Id: m.participant2Id, + winnerId: m.winnerId, + })), + ]) + ); + + const eventRecordedQP = new Map>(); + 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(); + 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. @@ -536,7 +724,11 @@ export class CSMajorSimulator implements Simulator { const excluded = excludedByEvent.get(event.id) ?? new Set(); 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); + 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) { simQP.set(pid, (simQP.get(pid) ?? 0) + qp); } @@ -576,22 +768,138 @@ function avgSlotQP(slots: number[], qpConfig: Map): 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 { + const records = new Map(); + 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 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; lockedEliminated: Set } { + const recon = reconstructStageRecords(swissMatches, stageNum); + const initialRecords = new Map(); + const lockedEliminated = new Set(); + + const cs2ElimIds = new Set(); + 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. * - * If stage results are provided, uses them to determine field composition - * and to lock in results for any completed stages, only simulating the - * remaining stages. A stage is considered complete when at least 8 - * eliminations for that stage have been recorded. + * 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: Map | undefined, - qpConfig: Map + stageResults: StageResultsMap | undefined, + qpConfig: Map, + options: SimulateOneMajorOptions = {} ): Map { + const { bracketMatches, swissMatches, recordedResults } = options; const qpMap = new Map(); const poolById = new Map(pool.map((t) => [t.id, t])); @@ -623,82 +931,31 @@ export function simulateOneMajor( stage1Teams = sorted.slice(16, 32); } - // ── Lock in known stage results ─────────────────────────────────────────── - // A stage is "complete" when at least 8 teams have been eliminated at that stage. - const eliminatedAtStage = (stageNum: number) => - stageResults - ? [...stageResults.entries()] - .filter(([, r]) => r.stageEliminated === stageNum) - .map(([id, r]) => ({ - id, - elo: poolById.get(id)?.elo ?? FALLBACK_ELO, - rank: poolById.get(id)?.rank ?? 9999, - wins: r.stageEliminatedWins ?? 0, - })) - : []; - - const stage1Elim = eliminatedAtStage(1); - const stage2Elim = eliminatedAtStage(2); - const stage3Elim = eliminatedAtStage(3); - - const stage1Complete = stage1Elim.length >= 8; - const stage2Complete = stage2Elim.length >= 8; - const stage3Complete = stage3Elim.length >= 8; - // ── Stage 1 (Opening) — all Bo1 ─────────────────────────────────────────── - let stage1Advanced: AdvancedTeam[]; - let stage1EliminatedFinal: Array<{ id: string; elo: number; rank: number; wins: number }>; - - if (stage1Complete) { - const stage1EliminatedIds = new Set(stage1Elim.map((t) => t.id)); - // Loss count from stage data is unknown; use 2 as conservative fallback - stage1Advanced = stage1Teams - .filter((t) => !stage1EliminatedIds.has(t.id)) - .map((t): AdvancedTeam => ({ ...t, losses: 2 })); - stage1EliminatedFinal = stage1Elim; - } else { - const result = simulateSwiss(stage1Teams, false); - stage1Advanced = result.advanced; - stage1EliminatedFinal = result.eliminated; - } + // Each stage advances exactly 8 teams; reconcile guards against ragged inputs. + const s1Seed = buildStageInitialRecords(stage1Teams, 1, swissMatches, stageResults); + const stage1Result = reconcileAdvancers(simulateSwiss(stage1Teams, false, false, s1Seed.initialRecords), 8); + const stage1Advanced = stage1Result.advanced; + const stage1EliminatedFinal = stage1Result.eliminated; // ── Stage 2 (Challengers) — Bo1, Bo3 for decisive matches ──────────────── const stage2Teams: AdvancedTeam[] = [...stage2Direct, ...stage1Advanced]; - let stage2Advanced: AdvancedTeam[]; - let stage2EliminatedFinal: Array<{ id: string; elo: number; rank: number; wins: number }>; - - if (stage2Complete) { - const stage2EliminatedIds = new Set(stage2Elim.map((t) => t.id)); - stage2Advanced = stage2Teams - .filter((t) => !stage2EliminatedIds.has(t.id)) - .map((t): AdvancedTeam => ({ ...t, losses: 2 })); - stage2EliminatedFinal = stage2Elim; - } else { - const result = simulateSwiss(stage2Teams, false, true); // decisive matches → Bo3 - stage2Advanced = result.advanced; - stage2EliminatedFinal = result.eliminated; - } + const s2Seed = buildStageInitialRecords(stage2Teams, 2, swissMatches, stageResults); + const stage2Result = reconcileAdvancers(simulateSwiss(stage2Teams, false, true, s2Seed.initialRecords), 8); + const stage2Advanced = stage2Result.advanced; + const stage2EliminatedFinal = stage2Result.eliminated; // ── Stage 3 (Legends) — all Bo3 ────────────────────────────────────────── const stage3Teams: AdvancedTeam[] = [...stage3Direct, ...stage2Advanced]; - let champTeams: AdvancedTeam[]; - let stage3EliminatedFinal: Array<{ id: string; elo: number; rank: number; wins: number }>; + const s3Seed = buildStageInitialRecords(stage3Teams, 3, swissMatches, stageResults); + const stage3Result = reconcileAdvancers(simulateSwiss(stage3Teams, true, false, s3Seed.initialRecords), 8); + const stage3EliminatedFinal = stage3Result.eliminated; - if (stage3Complete) { - const stage3EliminatedIds = new Set(stage3Elim.map((t) => t.id)); - // Stage 3 loss counts are not available from stage data; fall back to rank-based seeding - champTeams = stage3Teams - .filter((t) => !stage3EliminatedIds.has(t.id)) - .map((t): AdvancedTeam => ({ ...t, losses: 2 })); - stage3EliminatedFinal = stage3Elim; - } else { - const result = simulateSwiss(stage3Teams, true); // all Bo3 - champTeams = result.advanced; - stage3EliminatedFinal = result.eliminated; - } + // Stage 3 reconciles to exactly 8 advancers — the Champions Stage field. + const champTeams = stage3Result.advanced; // ── Champions Stage ─────────────────────────────────────────────────────── - const champResult = simulateChampionsStage(champTeams); + 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 @@ -724,5 +981,19 @@ export function simulateOneMajor( 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([ + ...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; }