Three review follow-ups on the conditioning change: - reconcileAdvancers no longer flips a result that is locked in from real match data. 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 the 8-advancer target (a genuinely inconsistent snapshot). buildStageInitialRecords now also returns the lockedAdvanced set for this. - simulateChampionsStage only honors recorded bracket results when the real bracket pairings are actually in use (realBracket). Previously a recorded winner could be applied to a fictitious Elo-seeded pairing. - Per-stage Swiss W-L reconstruction is loop-invariant, so it is now computed once per event in CSMajorSimulator.simulate and passed in via options.swissRecords instead of being re-derived on every Monte Carlo iteration. Adds direct unit coverage for reconcileAdvancers (protection + last-resort fallback) and for the bracket-gating behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zMG6JFng8bMqZYweffVuE
814 lines
34 KiB
TypeScript
814 lines
34 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
||
import {
|
||
gameWinProb,
|
||
seriesWinProb,
|
||
sampleField,
|
||
simulateSwiss,
|
||
simulateChampionsStage,
|
||
calcStage3ExitQP,
|
||
simulateOneMajor,
|
||
reconcileAdvancers,
|
||
} from "../cs-major-simulator";
|
||
import type { AdvancedTeam, BracketMatchInput, SwissMatchInput, StageResultsMap } from "../cs-major-simulator";
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function makeTeams(n: number, baseElo = 1800, baseRank = 1) {
|
||
return Array.from({ length: n }, (_, i) => ({
|
||
id: `team-${i}`,
|
||
elo: baseElo - i * 10,
|
||
rank: baseRank + i,
|
||
}));
|
||
}
|
||
|
||
function makeAdvancedTeams(n: number, losses = 0, baseElo = 1800, baseRank = 1): AdvancedTeam[] {
|
||
return Array.from({ length: n }, (_, i) => ({
|
||
id: `team-${i}`,
|
||
elo: baseElo - i * 10,
|
||
rank: baseRank + i,
|
||
losses,
|
||
}));
|
||
}
|
||
|
||
function makeQPConfig(): Map<number, number> {
|
||
const config = new Map<number, number>();
|
||
config.set(1, 500);
|
||
config.set(2, 400);
|
||
config.set(3, 325);
|
||
config.set(4, 250);
|
||
config.set(5, 175);
|
||
config.set(6, 125);
|
||
config.set(7, 75);
|
||
config.set(8, 50);
|
||
for (let i = 9; i <= 16; i++) config.set(i, (17 - i) * 10);
|
||
return config;
|
||
}
|
||
|
||
// ─── gameWinProb ──────────────────────────────────────────────────────────────
|
||
|
||
describe("gameWinProb", () => {
|
||
it("returns 0.5 for equal Elo", () => {
|
||
expect(gameWinProb(1800, 1800)).toBeCloseTo(0.5, 5);
|
||
expect(gameWinProb(2000, 2000)).toBeCloseTo(0.5, 5);
|
||
});
|
||
|
||
it("returns > 0.5 for positive advantage, < 0.5 for negative", () => {
|
||
expect(gameWinProb(2000, 1700)).toBeGreaterThan(0.5);
|
||
expect(gameWinProb(1700, 2000)).toBeLessThan(0.5);
|
||
});
|
||
|
||
it("is symmetric: p(a,b) + p(b,a) = 1", () => {
|
||
expect(gameWinProb(2000, 1700) + gameWinProb(1700, 2000)).toBeCloseTo(1.0, 5);
|
||
});
|
||
|
||
it("stays strictly in (0, 1)", () => {
|
||
const p = gameWinProb(3000, 1000);
|
||
expect(p).toBeLessThan(1);
|
||
expect(p).toBeGreaterThan(0);
|
||
});
|
||
});
|
||
|
||
// ─── seriesWinProb ────────────────────────────────────────────────────────────
|
||
|
||
describe("seriesWinProb", () => {
|
||
it("returns 0.5 for equal players (p=0.5) in any format", () => {
|
||
expect(seriesWinProb(0.5, 2)).toBeCloseTo(0.5, 5); // Bo3
|
||
expect(seriesWinProb(0.5, 3)).toBeCloseTo(0.5, 5); // Bo5
|
||
expect(seriesWinProb(0.5, 4)).toBeCloseTo(0.5, 5); // Bo7
|
||
});
|
||
|
||
it("amplifies better team advantage in longer formats", () => {
|
||
const bo3 = seriesWinProb(0.6, 2);
|
||
const bo5 = seriesWinProb(0.6, 3);
|
||
expect(bo5).toBeGreaterThan(bo3);
|
||
});
|
||
|
||
it("approaches 1.0 as p → 1.0", () => {
|
||
expect(seriesWinProb(0.9999, 2)).toBeCloseTo(1.0, 3);
|
||
expect(seriesWinProb(0.9999, 3)).toBeCloseTo(1.0, 3);
|
||
});
|
||
|
||
it("sums to 1 with complement", () => {
|
||
const p = 0.63;
|
||
expect(seriesWinProb(p, 2) + seriesWinProb(1 - p, 2)).toBeCloseTo(1.0, 5);
|
||
expect(seriesWinProb(p, 3) + seriesWinProb(1 - p, 3)).toBeCloseTo(1.0, 5);
|
||
});
|
||
|
||
it("Bo3 win prob with p=0.6 is in expected range", () => {
|
||
// P(2-0) + P(2-1) = 0.36 + 2*0.36*0.4 = 0.36 + 0.288 = 0.648
|
||
expect(seriesWinProb(0.6, 2)).toBeCloseTo(0.648, 3);
|
||
});
|
||
});
|
||
|
||
// ─── sampleField ──────────────────────────────────────────────────────────────
|
||
|
||
describe("sampleField", () => {
|
||
it("returns all teams when pool <= fieldSize", () => {
|
||
const pool = makeTeams(20);
|
||
const field = sampleField(pool, 32);
|
||
expect(field).toHaveLength(20);
|
||
});
|
||
|
||
it("returns fieldSize teams when pool > fieldSize", () => {
|
||
const pool = makeTeams(50);
|
||
const field = sampleField(pool, 32);
|
||
expect(field).toHaveLength(32);
|
||
});
|
||
|
||
it("always includes the top 12 ranked teams", () => {
|
||
const pool = makeTeams(50);
|
||
const top12Ids = new Set(pool.slice(0, 12).map((t) => t.id));
|
||
|
||
for (let i = 0; i < 20; i++) {
|
||
const field = sampleField(pool, 32);
|
||
const fieldIds = new Set(field.map((t) => t.id));
|
||
for (const id of top12Ids) {
|
||
expect(fieldIds.has(id)).toBe(true);
|
||
}
|
||
}
|
||
});
|
||
|
||
it("never includes more teams than fieldSize", () => {
|
||
const pool = makeTeams(100);
|
||
for (let i = 0; i < 10; i++) {
|
||
expect(sampleField(pool, 32)).toHaveLength(32);
|
||
}
|
||
});
|
||
|
||
it("returns unique teams", () => {
|
||
const pool = makeTeams(50);
|
||
const field = sampleField(pool, 32);
|
||
const ids = field.map((t) => t.id);
|
||
expect(new Set(ids).size).toBe(ids.length);
|
||
});
|
||
});
|
||
|
||
// ─── simulateSwiss ────────────────────────────────────────────────────────────
|
||
|
||
describe("simulateSwiss", () => {
|
||
it("returns exactly 8 advanced and 8 eliminated from 16 teams", () => {
|
||
const teams = makeTeams(16);
|
||
const result = simulateSwiss(teams, false);
|
||
expect(result.advanced).toHaveLength(8);
|
||
expect(result.eliminated).toHaveLength(8);
|
||
});
|
||
|
||
it("all advanced teams have 3 wins (implicit by advancement threshold)", () => {
|
||
const teams = makeTeams(16);
|
||
const result = simulateSwiss(teams, false);
|
||
// Advanced teams reached 3 wins — we can't directly check wins here,
|
||
// but we verify each participant appears in exactly one group
|
||
const allIds = new Set([
|
||
...result.advanced.map((t) => t.id),
|
||
...result.eliminated.map((t) => t.id),
|
||
]);
|
||
expect(allIds.size).toBe(16);
|
||
for (const team of teams) {
|
||
expect(allIds.has(team.id)).toBe(true);
|
||
}
|
||
});
|
||
|
||
it("eliminated teams have wins 0, 1, or 2", () => {
|
||
const teams = makeTeams(16);
|
||
const result = simulateSwiss(teams, false);
|
||
for (const t of result.eliminated) {
|
||
expect(t.wins).toBeGreaterThanOrEqual(0);
|
||
expect(t.wins).toBeLessThanOrEqual(2);
|
||
}
|
||
});
|
||
|
||
it("advanced teams have a losses field with value 0, 1, or 2", () => {
|
||
const teams = makeTeams(16);
|
||
const result = simulateSwiss(teams, false);
|
||
for (const t of result.advanced) {
|
||
expect(typeof t.losses).toBe("number");
|
||
expect(t.losses).toBeGreaterThanOrEqual(0);
|
||
expect(t.losses).toBeLessThanOrEqual(2);
|
||
}
|
||
});
|
||
|
||
it("total wins + losses = total matches played (conservation check)", () => {
|
||
const teams = makeTeams(16);
|
||
const result = simulateSwiss(teams, false);
|
||
// Eliminated teams have exactly 3 losses
|
||
// Total losses = 8 * 3 = 24
|
||
// Total wins = sum of wins for all eliminated + 8 * 3 for advanced = ?
|
||
// Each win by advanced team = 1 loss for an eliminated team
|
||
const totalLossesEliminated = result.eliminated.length * 3; // 24
|
||
expect(totalLossesEliminated).toBe(24);
|
||
});
|
||
|
||
it("works with Bo3 format (Swiss all Bo3)", () => {
|
||
const teams = makeTeams(16);
|
||
const result = simulateSwiss(teams, true);
|
||
expect(result.advanced).toHaveLength(8);
|
||
expect(result.eliminated).toHaveLength(8);
|
||
});
|
||
|
||
it("works with decisiveMatchesBo3 enabled", () => {
|
||
const teams = makeTeams(16);
|
||
const result = simulateSwiss(teams, false, true);
|
||
expect(result.advanced).toHaveLength(8);
|
||
expect(result.eliminated).toHaveLength(8);
|
||
});
|
||
|
||
it("returns empty arrays for 0 teams", () => {
|
||
const result = simulateSwiss([], false);
|
||
expect(result.advanced).toHaveLength(0);
|
||
expect(result.eliminated).toHaveLength(0);
|
||
});
|
||
|
||
it("throws for odd team count", () => {
|
||
expect(() => simulateSwiss(makeTeams(15), false)).toThrow(/even number of teams/);
|
||
expect(() => simulateSwiss(makeTeams(1), false)).toThrow(/even number of teams/);
|
||
});
|
||
|
||
it("teams with much higher Elo advance more often (stochastic check)", () => {
|
||
// Top 8 teams have Elo 2000+, bottom 8 have Elo 1000
|
||
const teams = [
|
||
...Array.from({ length: 8 }, (_, i) => ({ id: `top-${i}`, elo: 2000, rank: i + 1 })),
|
||
...Array.from({ length: 8 }, (_, i) => ({ id: `bot-${i}`, elo: 1000, rank: i + 9 })),
|
||
];
|
||
|
||
let topAdvances = 0;
|
||
const runs = 100;
|
||
for (let i = 0; i < runs; i++) {
|
||
const result = simulateSwiss(teams, false);
|
||
for (const t of result.advanced) {
|
||
if (t.id.startsWith("top-")) topAdvances++;
|
||
}
|
||
}
|
||
|
||
// Top teams should advance much more than half the time across runs
|
||
// Expected: ~7-8 top teams advance per run → topAdvances should be >> 400 (50%)
|
||
expect(topAdvances / runs).toBeGreaterThan(6);
|
||
});
|
||
});
|
||
|
||
// ─── simulateChampionsStage ───────────────────────────────────────────────────
|
||
|
||
describe("simulateChampionsStage", () => {
|
||
it("throws if not exactly 8 teams", () => {
|
||
expect(() => simulateChampionsStage(makeAdvancedTeams(7))).toThrow();
|
||
expect(() => simulateChampionsStage(makeAdvancedTeams(9))).toThrow();
|
||
});
|
||
|
||
it("assigns placements to all 8 teams", () => {
|
||
const teams = makeAdvancedTeams(8);
|
||
const result = simulateChampionsStage(teams);
|
||
expect(result.placements.size).toBe(8);
|
||
for (const team of teams) {
|
||
expect(result.placements.has(team.id)).toBe(true);
|
||
}
|
||
});
|
||
|
||
it("has exactly one 1st, one 2nd, two placement-3s, four placement-5s", () => {
|
||
const teams = makeAdvancedTeams(8);
|
||
const result = simulateChampionsStage(teams);
|
||
const placements = [...result.placements.values()];
|
||
expect(placements.filter((p) => p === 1)).toHaveLength(1);
|
||
expect(placements.filter((p) => p === 2)).toHaveLength(1);
|
||
expect(placements.filter((p) => p === 3)).toHaveLength(2); // both SF losers
|
||
expect(placements.filter((p) => p === 5)).toHaveLength(4); // all QF losers
|
||
});
|
||
|
||
it("seeds by losses ascending, then rank as tiebreaker", () => {
|
||
// Team with 0 losses gets seeded 1st regardless of world rank.
|
||
// Make all teams equal Elo so seeding determines matchups purely.
|
||
// team-0 has 0 losses (seed 1) vs team-7 has 2 losses (seed 8).
|
||
const teams: AdvancedTeam[] = [
|
||
{ id: "a", elo: 1800, rank: 1, losses: 0 },
|
||
{ id: "b", elo: 1800, rank: 2, losses: 0 },
|
||
{ id: "c", elo: 1800, rank: 3, losses: 1 },
|
||
{ id: "d", elo: 1800, rank: 4, losses: 1 },
|
||
{ id: "e", elo: 1800, rank: 5, losses: 2 },
|
||
{ id: "f", elo: 1800, rank: 6, losses: 2 },
|
||
{ id: "g", elo: 1800, rank: 7, losses: 2 },
|
||
{ id: "h", elo: 1800, rank: 8, losses: 2 },
|
||
];
|
||
// With equal Elo, seeding by losses produces consistent bracket structure.
|
||
// Just verify it runs correctly and produces valid placements.
|
||
const result = simulateChampionsStage(teams);
|
||
expect(result.placements.size).toBe(8);
|
||
const values = [...result.placements.values()];
|
||
expect(values).toContain(1);
|
||
expect(values).toContain(2);
|
||
});
|
||
|
||
it("rank used as tiebreaker when losses are equal", () => {
|
||
const teams = makeAdvancedTeams(8, 1); // all have losses = 1
|
||
const result = simulateChampionsStage(teams);
|
||
expect(result.placements.size).toBe(8);
|
||
});
|
||
|
||
it("top Elo team wins more often than last (stochastic)", () => {
|
||
const teams = Array.from({ length: 8 }, (_, i): AdvancedTeam => ({
|
||
id: `team-${i}`,
|
||
elo: 1800 - i * 100,
|
||
rank: i + 1,
|
||
losses: 0,
|
||
}));
|
||
let wins = 0;
|
||
for (let i = 0; i < 1000; i++) {
|
||
const result = simulateChampionsStage(teams);
|
||
if (result.placements.get("team-0") === 1) wins++;
|
||
}
|
||
// Should win well above 12.5% (1/8 random baseline)
|
||
expect(wins / 1000).toBeGreaterThan(0.25);
|
||
});
|
||
});
|
||
|
||
// ─── calcStage3ExitQP ─────────────────────────────────────────────────────────
|
||
|
||
// Placements 9–16 with descending QP: 9→80, 10→70, ..., 16→10
|
||
function makeStage3QPConfig(): Map<number, number> {
|
||
const config = new Map<number, number>();
|
||
for (let i = 9; i <= 16; i++) {
|
||
config.set(i, (17 - i) * 10);
|
||
}
|
||
return config;
|
||
}
|
||
|
||
describe("calcStage3ExitQP", () => {
|
||
it("returns empty map for empty input", () => {
|
||
const result = calcStage3ExitQP([], new Map());
|
||
expect(result.size).toBe(0);
|
||
});
|
||
|
||
it("tie-splits QP within each wins group", () => {
|
||
// 2 teams at 2 wins, 2 teams at 1 win, 4 teams at 0 wins
|
||
const elim = [
|
||
{ id: "a", wins: 2 }, { id: "b", wins: 2 }, // slots 9-10: avg (80+70)/2 = 75
|
||
{ id: "c", wins: 1 }, { id: "d", wins: 1 }, // slots 11-12: avg (60+50)/2 = 55
|
||
{ id: "e", wins: 0 }, { id: "f", wins: 0 },
|
||
{ id: "g", wins: 0 }, { id: "h", wins: 0 }, // slots 13-16: avg (40+30+20+10)/4 = 25
|
||
];
|
||
const config = makeStage3QPConfig();
|
||
const result = calcStage3ExitQP(elim, config);
|
||
|
||
expect(result.get("a")).toBeCloseTo(75);
|
||
expect(result.get("b")).toBeCloseTo(75);
|
||
expect(result.get("c")).toBeCloseTo(55);
|
||
expect(result.get("d")).toBeCloseTo(55);
|
||
expect(result.get("e")).toBeCloseTo(25);
|
||
expect(result.get("h")).toBeCloseTo(25);
|
||
});
|
||
|
||
it("all teams with the same wins get identical averaged QP", () => {
|
||
const elim = Array.from({ length: 8 }, (_, i) => ({ id: `t-${i}`, wins: 1 }));
|
||
const config = makeStage3QPConfig();
|
||
const result = calcStage3ExitQP(elim, config);
|
||
|
||
// Average of slots 9–16: (80+70+60+50+40+30+20+10) / 8 = 45
|
||
const expected = (80 + 70 + 60 + 50 + 40 + 30 + 20 + 10) / 8;
|
||
for (const [, qp] of result) {
|
||
expect(qp).toBeCloseTo(expected);
|
||
}
|
||
});
|
||
|
||
it("higher wins groups get higher QP", () => {
|
||
const elim = [
|
||
{ id: "high", wins: 2 },
|
||
{ id: "mid", wins: 1 },
|
||
{ id: "low", wins: 0 },
|
||
];
|
||
// Pad to 8 to use real slots: add 5 more at wins=0
|
||
for (let i = 0; i < 5; i++) elim.push({ id: `pad-${i}`, wins: 0 });
|
||
const config = makeStage3QPConfig();
|
||
const result = calcStage3ExitQP(elim, config);
|
||
|
||
const highQP = result.get("high") ?? 0;
|
||
const midQP = result.get("mid") ?? 0;
|
||
const lowQP = result.get("low") ?? 0;
|
||
expect(highQP).toBeGreaterThan(midQP);
|
||
expect(midQP).toBeGreaterThan(lowQP);
|
||
});
|
||
});
|
||
|
||
// ─── simulateOneMajor ─────────────────────────────────────────────────────────
|
||
|
||
describe("simulateOneMajor", () => {
|
||
it("returns a QP entry for all pool teams (no stage data)", () => {
|
||
const pool = makeTeams(32);
|
||
const result = simulateOneMajor(pool, undefined, makeQPConfig());
|
||
expect(result.size).toBe(32);
|
||
for (const [, qp] of result) {
|
||
expect(qp).toBeGreaterThanOrEqual(0);
|
||
}
|
||
});
|
||
|
||
it("at least one team earns positive QP", () => {
|
||
const pool = makeTeams(32);
|
||
const result = simulateOneMajor(pool, undefined, makeQPConfig());
|
||
const nonZero = [...result.values()].filter((q) => q > 0);
|
||
expect(nonZero.length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it("exactly 16 teams earn 0 QP (stage 1 and 2 exits)", () => {
|
||
// Run multiple times to get a stable count — should always be 16 with 32-team pool
|
||
const pool = makeTeams(32);
|
||
const qpConfig = makeQPConfig();
|
||
for (let run = 0; run < 5; run++) {
|
||
const result = simulateOneMajor(pool, undefined, qpConfig);
|
||
const zeroCount = [...result.values()].filter((q) => q === 0).length;
|
||
expect(zeroCount).toBe(16);
|
||
}
|
||
});
|
||
|
||
it("stage 1 eliminated teams earn 0 QP when stage is complete", () => {
|
||
const pool = makeTeams(32);
|
||
const qpConfig = makeQPConfig();
|
||
|
||
// Build stage results: 16 teams in stage 1, 8 in stage 2, 8 in stage 3
|
||
// Stage 1 eliminations: first 8 pool teams
|
||
const stageResults = new Map<string, {
|
||
stageEntry: number;
|
||
stageEliminated: number | null;
|
||
stageEliminatedWins: number | null;
|
||
}>();
|
||
for (let i = 0; i < 16; i++) {
|
||
stageResults.set(`team-${i}`, {
|
||
stageEntry: 1,
|
||
stageEliminated: i < 8 ? 1 : null, // first 8 eliminated at stage 1
|
||
stageEliminatedWins: i < 8 ? (i % 3) : 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 });
|
||
}
|
||
|
||
const result = simulateOneMajor(pool, stageResults, qpConfig);
|
||
|
||
// Stage 1 eliminated teams (team-0 through team-7) must get 0 QP
|
||
for (let i = 0; i < 8; i++) {
|
||
expect(result.get(`team-${i}`)).toBe(0);
|
||
}
|
||
});
|
||
|
||
it("works with a pool exactly equal to FIELD_SIZE (32 teams)", () => {
|
||
// sampleField returns all teams when pool.length <= fieldSize
|
||
const pool = makeTeams(32);
|
||
expect(() => simulateOneMajor(pool, undefined, makeQPConfig())).not.toThrow();
|
||
});
|
||
|
||
it("QF and SF losers receive averaged QP (not individual slot QP)", () => {
|
||
// With a uniform qpConfig we can verify tie-splitting: if slots 5-8 all have
|
||
// distinct values, QF losers should all get the average, not random individual values.
|
||
const qpConfig = new Map([
|
||
[1, 1000], [2, 800],
|
||
[3, 600], [4, 400],
|
||
[5, 300], [6, 200], [7, 100], [8, 50],
|
||
...Array.from({ length: 8 }, (_, i): [number, number] => [i + 9, 0]),
|
||
]);
|
||
const expectedQFAvg = (300 + 200 + 100 + 50) / 4; // 162.5
|
||
const expectedSFAvg = (600 + 400) / 2; // 500
|
||
|
||
const pool = makeTeams(32);
|
||
|
||
// Run a few times; every QF and SF loser should get the averaged values
|
||
for (let run = 0; run < 5; run++) {
|
||
const result = simulateOneMajor(pool, undefined, qpConfig);
|
||
const qpValues = [...result.values()];
|
||
// The averaged values should appear in the results
|
||
const hasQFAvg = qpValues.some((q) => Math.abs(q - expectedQFAvg) < 0.01);
|
||
const hasSFAvg = qpValues.some((q) => Math.abs(q - expectedSFAvg) < 0.01);
|
||
expect(hasQFAvg).toBe(true);
|
||
expect(hasSFAvg).toBe(true);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ─── Not-participating exclusion ──────────────────────────────────────────────
|
||
//
|
||
// simulateOneMajor uses sampleField to draw a 32-team field from the pool.
|
||
// sampleField returns all teams when pool.length <= 32, so pools must be > 32
|
||
// for exclusion tests — otherwise the field shrinks below 32 and the Swiss
|
||
// bracket gets an odd count. The actual simulator filters the pool before
|
||
// calling simulateOneMajor, so the real-world scenario (pool of 20–30 teams,
|
||
// 1 withdrawn) is safe: sampleField just uses all remaining teams up to 32.
|
||
|
||
describe("not-participating exclusion (CS2)", () => {
|
||
const qpConfig = makeQPConfig();
|
||
|
||
it("a team not in the pool gets 0 QP from that major", () => {
|
||
// Use 40 teams so sampleField still draws 32 after the exclusion.
|
||
const allTeams = makeTeams(40);
|
||
const excluded = allTeams[0];
|
||
const reducedPool = allTeams.slice(1); // 39 teams — sampleField draws 32 from these
|
||
|
||
const result = simulateOneMajor(reducedPool, undefined, qpConfig);
|
||
expect(result.has(excluded.id)).toBe(false);
|
||
});
|
||
|
||
it("excluding a dominant team from the pool means they never win", () => {
|
||
const TRIALS = 200;
|
||
// 40 teams: one dominant + 39 average. sampleField always draws 32.
|
||
const dominantTeam = { id: "dominant", elo: 5000, rank: 1 };
|
||
const otherTeams = makeTeams(39, 1500, 2);
|
||
const allPool = [dominantTeam, ...otherTeams];
|
||
const reducedPool = otherTeams; // dominant excluded
|
||
|
||
let dominantWinsWithFull = 0;
|
||
let dominantWinsWithReduced = 0;
|
||
const firstPlaceQP = qpConfig.get(1) ?? 0;
|
||
|
||
for (let i = 0; i < TRIALS; i++) {
|
||
const fullResult = simulateOneMajor(allPool, undefined, qpConfig);
|
||
if ((fullResult.get(dominantTeam.id) ?? 0) === firstPlaceQP) dominantWinsWithFull++;
|
||
|
||
const reducedResult = simulateOneMajor(reducedPool, undefined, qpConfig);
|
||
if ((reducedResult.get(dominantTeam.id) ?? 0) === firstPlaceQP) dominantWinsWithReduced++;
|
||
}
|
||
|
||
// Dominant team (Elo 5000 vs 1500) wins nearly every sampled major
|
||
expect(dominantWinsWithFull).toBeGreaterThan(TRIALS * 0.8);
|
||
// Excluded team never appears → 0 wins
|
||
expect(dominantWinsWithReduced).toBe(0);
|
||
});
|
||
|
||
it("per-event pool filtering is independent — exclusion in one event does not affect another", () => {
|
||
// Verify that two separate filtered pools produce independent results.
|
||
const allTeams = makeTeams(40);
|
||
const excluded = allTeams[0];
|
||
const fullPool = allTeams;
|
||
const reducedPool = allTeams.slice(1); // exclude team[0] from event 2
|
||
|
||
const resultEvent1 = simulateOneMajor(fullPool, undefined, qpConfig);
|
||
const resultEvent2 = simulateOneMajor(reducedPool, undefined, qpConfig);
|
||
|
||
expect(resultEvent1.has(excluded.id)).toBe(true); // present in event 1
|
||
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) ─────
|
||
|
||
describe("reconcileAdvancers", () => {
|
||
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 });
|
||
|
||
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).sort()).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).sort()).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-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);
|
||
}
|
||
});
|
||
});
|