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:
parent
dedd2f3d86
commit
8990bf4281
2 changed files with 576 additions and 112 deletions
|
|
@ -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-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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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: 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<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 0–0 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 5–8 by caller).
|
* 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).
|
* SF losers are both assigned placement 3 (tie-split across slots 3–4 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 5–8 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 5–8 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 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)
|
// 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 (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<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 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.
|
* 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 (5–8) and SF losers (3–4) ────────────
|
// ── Assign QP — tie-split QF losers (5–8) and SF losers (3–4) ────────────
|
||||||
// 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue