claude/exciting-ride-q1jq8k (#101)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m55s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m42s
🚀 Deploy / 🐳 Build (push) Successful in 1m10s
🚀 Deploy / 🚀 Deploy (push) Successful in 15s

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #101
This commit is contained in:
chrisp 2026-06-20 03:56:28 +00:00
parent dedd2f3d86
commit 472875f151
2 changed files with 730 additions and 112 deletions

View file

@ -7,8 +7,9 @@ import {
simulateChampionsStage, simulateChampionsStage,
calcStage3ExitQP, calcStage3ExitQP,
simulateOneMajor, simulateOneMajor,
reconcileAdvancers,
} 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 +543,275 @@ 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);
});
});
// ─── reconcileAdvancers (ragged-snapshot repair, protecting locked teams) ─────
const adv = (id: string, losses: number, rank: number): AdvancedTeam => ({ id, elo: 1500, rank, losses });
const elim = (id: string, wins: number, rank: number) => ({ id, elo: 1500, rank, wins });
describe("reconcileAdvancers", () => {
it("is a no-op when the advancer count already matches the target", () => {
const input = {
advanced: [adv("a", 0, 1), adv("b", 1, 2)],
eliminated: [elim("c", 2, 3)],
};
expect(reconcileAdvancers(input, 2)).toBe(input); // same reference, untouched
});
it("demotes simulated advancers before locked ones when over-filled", () => {
// 3 advancers, target 2. The locked team is the weakest (most losses) so a
// naive demotion would drop it; protection must keep it and demote a
// simulated team instead.
const input = {
advanced: [adv("sim-strong", 0, 1), adv("sim-weak", 2, 3), adv("locked", 2, 2)],
eliminated: [] as Array<{ id: string; elo: number; rank: number; wins: number }>,
};
const result = reconcileAdvancers(input, 2, new Set(["locked"]));
expect(result.advanced.map((t) => t.id).toSorted()).toEqual(["locked", "sim-strong"]);
expect(result.eliminated.map((t) => t.id)).toEqual(["sim-weak"]);
});
it("promotes non-locked eliminated teams before locked ones when under-filled", () => {
// 1 advancer, target 2 → must promote one eliminated team. The locked
// (real) elimination must be preserved; the non-locked team is promoted
// even though it is the weaker (fewer wins) of the two.
const input = {
advanced: [adv("a", 0, 1)],
eliminated: [elim("locked-strong", 2, 2), elim("sim-weak", 0, 3)],
};
const result = reconcileAdvancers(input, 2, new Set(["locked-strong"]));
expect(result.advanced.map((t) => t.id).toSorted()).toEqual(["a", "sim-weak"]);
expect(result.eliminated.map((t) => t.id)).toEqual(["locked-strong"]);
});
it("falls back to locked teams only when non-locked candidates run out", () => {
// 3 advancers, target 1, all locked → forced to demote locked teams,
// keeping the strongest (fewest losses).
const input = {
advanced: [adv("x", 2, 3), adv("y", 0, 1), adv("z", 1, 2)],
eliminated: [] as Array<{ id: string; elo: number; rank: number; wins: number }>,
};
const result = reconcileAdvancers(input, 1, new Set(["x", "y", "z"]));
expect(result.advanced.map((t) => t.id)).toEqual(["y"]); // strongest kept
});
});
// ─── 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
});
it("ignores recorded results when the bracket's teams are not this field", () => {
// Bracket describes a different set of teams (x1…x8) than the actual
// qualifiers (a…h), so it is not a usable real bracket: recorded winners
// must not bleed into the Elo-seeded simulation of the real field.
const teams = makeChampTeams();
const foreignBracket: BracketMatchInput[] = makeFullBracket().map((m) => ({
...m,
participant1Id: m.participant1Id ? `x-${m.participant1Id}` : null,
participant2Id: m.participant2Id ? `x-${m.participant2Id}` : null,
winnerId: m.winnerId ? `x-${m.winnerId}` : null,
}));
let aWins = 0;
for (let i = 0; i < 500; i++) {
const result = simulateChampionsStage(teams, foreignBracket);
// All 8 real teams still get a placement (the bracket was not applied).
expect(result.placements.size).toBe(8);
if (result.placements.get("a") === 1) aWins++;
}
// Strongest real team wins well above 1/8 → Elo seeding drove it, not the
// foreign bracket (which would have forced its own — absent — winner).
expect(aWins / 500).toBeGreaterThan(0.25);
});
});
// ─── 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;
}
const m = (round: string, matchNumber: number, p1: string, p2: string, winner: string): BracketMatchInput =>
({ round, matchNumber, participant1Id: p1, participant2Id: p2, winnerId: winner, isComplete: true });
/** Champions bracket among team-24…team-31 where team-31 wins everything. */
function makeQualifierBracket(): BracketMatchInput[] {
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"),
];
}
const loss = (loser: string, winner: string): SwissMatchInput =>
({ matchStage: 1, participant1Id: loser, participant2Id: winner, winnerId: winner });
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 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,56 @@ 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[];
/**
* Per-stage reconstructed (wins, losses) records, keyed by stage number.
* These depend only on `swissMatches` and are invariant across Monte Carlo
* iterations, so the caller may precompute them once and pass them in to
* avoid re-deriving them on every iteration. When omitted, they are
* reconstructed from `swissMatches` on demand.
*/
swissRecords?: Map<number, Map<string, { wins: number; losses: number }>>;
/** participantId → QP already recorded in event_results (provisional/locked). */
recordedResults?: Map<string, number>;
}
/** /**
* Sample a 32-team field from the pool. * 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 +245,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 +257,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 +276,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 +340,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 +415,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 +447,86 @@ export function simulateChampionsStage(teams: AdvancedTeam[]): ChampionsResult {
return Math.random() < p ? t1 : t2; return Math.random() < p ? t1 : t2;
}; };
// Quarterfinals (Bo3 = first to 2) // Determine QF pairings from the real bracket when it is fully seeded with
const sfTeams: AdvancedTeam[] = []; // teams from this field; otherwise seed by stage performance.
for (const [t1, t2] of bracket) { const qf = bracketMatches
const winner = simMatch(t1, t2, 2); ?.filter((m) => m.round === "Quarterfinals")
const loser = winner.id === t1.id ? t2 : t1; .toSorted((a, b) => a.matchNumber - b.matchNumber);
sfTeams.push(winner); const realBracket =
placements.set(loser.id, 5); // QF losers: all get placement 5 (tie-split 58 by caller) qf?.length === 4 &&
qf.every(
(m) =>
m.participant1Id !== null &&
m.participant2Id !== null &&
byId.has(m.participant1Id) &&
byId.has(m.participant2Id)
);
// Only honor recorded results when the real bracket pairings are in use;
// applying them to fictitious Elo-seeded pairings would mix real outcomes
// into matchups that never existed.
const recordedMatch = (round: string, matchNumber: number): BracketMatchInput | undefined =>
realBracket
? bracketMatches?.find((m) => m.round === round && m.matchNumber === matchNumber)
: undefined;
// Honor a completed match's recorded winner; otherwise simulate.
const resolve = (
t1: AdvancedTeam,
t2: AdvancedTeam,
winsNeeded: number,
rec: BracketMatchInput | undefined
): AdvancedTeam => {
if (rec?.isComplete && rec.winnerId) {
if (rec.winnerId === t1.id) return t1;
if (rec.winnerId === t2.id) return t2;
}
return simMatch(t1, t2, winsNeeded);
};
let qfPairs: [AdvancedTeam, AdvancedTeam][];
if (realBracket && qf) {
qfPairs = qf.map((m) => [
byId.get(m.participant1Id as string) as AdvancedTeam,
byId.get(m.participant2Id as string) as AdvancedTeam,
]);
} else {
// Seed by stage performance: fewer losses = higher seed; rank as tiebreaker.
const seeded = [...teams].toSorted((a, b) =>
a.losses !== b.losses ? a.losses - b.losses : a.rank - b.rank
);
// Standard 8-team seeding: 1v8, 4v5, 3v6, 2v7
qfPairs = [
[seeded[0], seeded[7]],
[seeded[3], seeded[4]],
[seeded[2], seeded[5]],
[seeded[1], seeded[6]],
];
} }
// 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 +644,79 @@ 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,
})),
])
);
// Per-stage WL records depend only on the (loop-invariant) Swiss matches,
// so reconstruct them once per event here rather than on every iteration.
const eventSwissRecords = new Map<
string,
Map<number, Map<string, { wins: number; losses: number }>>
>(
[...eventSwiss].map(([id, matches]) => [
id,
new Map([1, 2, 3].map((stageNum) => [stageNum, reconstructStageRecords(matches, stageNum)])),
])
);
const eventRecordedQP = new Map<string, Map<string, number>>();
if (incompleteEventIds.length > 0) {
const provisionalResults = await db
.select({
eventId: schema.eventResults.scoringEventId,
participantId: schema.eventResults.seasonParticipantId,
qualifyingPointsAwarded: schema.eventResults.qualifyingPointsAwarded,
})
.from(schema.eventResults)
.where(inArray(schema.eventResults.scoringEventId, incompleteEventIds));
for (const r of provisionalResults) {
if (r.qualifyingPointsAwarded === null) continue;
let perEvent = eventRecordedQP.get(r.eventId);
if (!perEvent) {
perEvent = new Map<string, number>();
eventRecordedQP.set(r.eventId, perEvent);
}
perEvent.set(r.participantId, parseFloat(r.qualifyingPointsAwarded));
}
}
// 7. Short-circuit: if all events are complete, return deterministic probabilities // 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 +752,12 @@ 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),
swissRecords: eventSwissRecords.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,25 +797,182 @@ 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 sets of teams locked in as eliminated or
* advanced from real data. Locked-eliminated teams have settled QP (recorded
* provisional QP may be applied verbatim); both sets are protected from being
* flipped by reconcileAdvancers.
*
* `recon` may be supplied precomputed (it is invariant across Monte Carlo
* iterations); otherwise it is reconstructed from `swissMatches`.
*/
function buildStageInitialRecords(
stageTeams: TeamWithElo[],
stageNum: number,
swissMatches: SwissMatchInput[] | undefined,
stageResults: StageResultsMap | undefined,
recon: Map<string, { wins: number; losses: number }> = reconstructStageRecords(swissMatches, stageNum)
): {
initialRecords: Map<string, { wins: number; losses: number }>;
lockedEliminated: Set<string>;
lockedAdvanced: Set<string>;
} {
const initialRecords = new Map<string, { wins: number; losses: number }>();
const lockedEliminated = new Set<string>();
const lockedAdvanced = new Set<string>();
const cs2ElimIds = new Set<string>();
if (stageResults) {
for (const [id, r] of stageResults) {
if (r.stageEliminated === stageNum) cs2ElimIds.add(id);
}
}
const reconElimCount = [...recon.values()].filter((r) => r.losses >= 3).length;
const stageComplete = cs2ElimIds.size >= 8 || reconElimCount >= 8;
for (const t of stageTeams) {
const r = recon.get(t.id);
const cs2 = stageResults?.get(t.id);
if (cs2ElimIds.has(t.id)) {
initialRecords.set(t.id, { wins: cs2?.stageEliminatedWins ?? r?.wins ?? 0, losses: 3 });
lockedEliminated.add(t.id);
} else if (r && r.losses >= 3) {
initialRecords.set(t.id, { wins: r.wins, losses: 3 });
lockedEliminated.add(t.id);
} else if (r && r.wins >= 3) {
initialRecords.set(t.id, { wins: 3, losses: r.losses });
lockedAdvanced.add(t.id);
} else if (stageComplete) {
// Stage done and this team wasn't eliminated → it advanced. Exact loss
// count is unknown without match data; use 2 as a conservative fallback.
initialRecords.set(t.id, { wins: 3, losses: r?.losses ?? 2 });
lockedAdvanced.add(t.id);
} else if (r) {
initialRecords.set(t.id, { wins: r.wins, losses: r.losses });
}
}
return { initialRecords, lockedEliminated, lockedAdvanced };
}
/**
* Each CS2 Swiss stage advances exactly 8 of its 16 teams. A consistent Swiss
* state always yields that split, but a ragged mid-stage snapshot (locked
* results that don't form a valid Swiss position) can over- or under-fill the
* advancer pool. Reconcile to exactly `target` advancers demoting the weakest
* advancers (most losses, then worst rank) or promoting the strongest
* eliminated teams (most wins, then best rank) so downstream stages stay
* well-formed. A no-op for the common, consistent case.
*
* Teams in `locked` come from real match data and must not be flipped: locked
* advancers are kept out of the demotion pool and locked-eliminated teams out
* of the promotion pool. They are only touched as a last resort, when there
* aren't enough non-locked teams to reach `target` (a genuinely inconsistent
* snapshot), in which case the weakest/strongest locked team is used.
*/
export function reconcileAdvancers(
result: SwissResult,
target: number,
locked: Set<string> = new Set()
): SwissResult {
if (result.advanced.length === target) return result;
const advanced = [...result.advanced];
const eliminated = [...result.eliminated];
if (advanced.length > target) {
// Demote the weakest non-locked advancers first; sort locked teams to the
// front (kept) so they are demoted only if non-locked teams run out.
advanced.sort((a, b) => {
const la = locked.has(a.id) ? 1 : 0;
const lb = locked.has(b.id) ? 1 : 0;
if (la !== lb) return lb - la; // locked first → kept
return a.losses !== b.losses ? a.losses - b.losses : a.rank - b.rank;
});
for (const t of advanced.splice(target)) {
eliminated.push({ id: t.id, elo: t.elo, rank: t.rank, wins: 2 });
}
} else {
// Promote the strongest non-locked eliminated teams first; sort locked
// teams to the back so they are promoted only if non-locked teams run out.
eliminated.sort((a, b) => {
const la = locked.has(a.id) ? 1 : 0;
const lb = locked.has(b.id) ? 1 : 0;
if (la !== lb) return la - lb; // non-locked first → promoted
return b.wins !== a.wins ? b.wins - a.wins : a.rank - b.rank;
});
for (const t of eliminated.splice(0, target - advanced.length)) {
advanced.push({ id: t.id, elo: t.elo, rank: t.rank, losses: 2 });
}
}
return { advanced, eliminated };
}
/** /**
* Simulate one CS2 Major and return QP earned per participant. * 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, swissRecords, 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]));
// Per-stage reconstructed records are invariant across iterations; reuse the
// caller's precomputed maps when available, otherwise derive on demand.
const reconFor = (stageNum: number) =>
swissRecords?.get(stageNum) ?? reconstructStageRecords(swissMatches, stageNum);
// ── Determine field and stage assignments ───────────────────────────────── // ── Determine field and stage assignments ─────────────────────────────────
// stage2Direct / stage3Direct are AdvancedTeam[] (losses = 0: no prior Swiss stage) // stage2Direct / stage3Direct are AdvancedTeam[] (losses = 0: no prior Swiss stage)
let stage1Teams: TeamWithElo[]; let stage1Teams: TeamWithElo[];
@ -623,82 +1001,35 @@ 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 }>; // while protecting teams whose result is locked in from real data.
const s1Seed = buildStageInitialRecords(stage1Teams, 1, swissMatches, stageResults, reconFor(1));
if (stage1Complete) { const s1Locked = new Set([...s1Seed.lockedEliminated, ...s1Seed.lockedAdvanced]);
const stage1EliminatedIds = new Set(stage1Elim.map((t) => t.id)); const stage1Result = reconcileAdvancers(simulateSwiss(stage1Teams, false, false, s1Seed.initialRecords), 8, s1Locked);
// Loss count from stage data is unknown; use 2 as conservative fallback const stage1Advanced = stage1Result.advanced;
stage1Advanced = stage1Teams const stage1EliminatedFinal = stage1Result.eliminated;
.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, reconFor(2));
let stage2EliminatedFinal: Array<{ id: string; elo: number; rank: number; wins: number }>; const s2Locked = new Set([...s2Seed.lockedEliminated, ...s2Seed.lockedAdvanced]);
const stage2Result = reconcileAdvancers(simulateSwiss(stage2Teams, false, true, s2Seed.initialRecords), 8, s2Locked);
if (stage2Complete) { const stage2Advanced = stage2Result.advanced;
const stage2EliminatedIds = new Set(stage2Elim.map((t) => t.id)); const stage2EliminatedFinal = stage2Result.eliminated;
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, reconFor(3));
let stage3EliminatedFinal: Array<{ id: string; elo: number; rank: number; wins: number }>; const s3Locked = new Set([...s3Seed.lockedEliminated, ...s3Seed.lockedAdvanced]);
const stage3Result = reconcileAdvancers(simulateSwiss(stage3Teams, true, false, s3Seed.initialRecords), 8, s3Locked);
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 +1055,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;
} }