* Add notParticipating flag to allow excluding withdrawn participants from qualifying-points simulators Adds a `not_participating` boolean column to `event_results` so admins can mark a participant as not competing in a specific upcoming major (e.g. Alcaraz withdrawing from Wimbledon due to injury). The golf, tennis, and CS2 major simulators now query this flag for incomplete events and exclude those participants from the event's draw/field, redistributing probability weight to the remaining field. Admin UI for qualifying major_tournament events gains a "Not Participating" card to mark/unmark withdrawals before the event runs. https://claude.ai/code/session_01HxNPLEXzr5Km3suWrJe2F9 * Address code review feedback on not-participating flag Security: unmark-not-participating now validates the result exists, belongs to this event, and is actually a DNP row before deleting. mark-not-participating now returns a user-friendly error on duplicate-key constraint violations. Code quality: extract shared getExcludedByEventMap() utility to event-result model, eliminating the duplicated 20-line exclusion-loading block that was copy-pasted into all three simulators. Fix hasParticipantResult() to exclude notParticipating rows so it correctly reflects actual competition participation. Remove optional chaining on the non-optional notParticipatingIds field in the admin UI. Fix misleading empty-state message. Tests: replace the misleading first tennis DNP test (which never used the activeIds variable it created) with a test that explicitly validates the fallback behaviour when too few players remain after exclusion. Add three CS2 DNP tests covering the excluded-team-gets-zero-QP path, the redistribution of wins, and per-event pool independence. https://claude.ai/code/session_01HxNPLEXzr5Km3suWrJe2F9 * Fix lint errors: replace non-null assertions in CS2 DNP test https://claude.ai/code/session_01HxNPLEXzr5Km3suWrJe2F9 --------- Co-authored-by: Claude <noreply@anthropic.com>
306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
americanToImplied,
|
|
getMajorOddsKey,
|
|
resolveSkill,
|
|
simulateMajor,
|
|
} from "../golf-simulator";
|
|
import type { GolfSkillsRecord } from "~/models/golf-skills";
|
|
|
|
// ─── americanToImplied ────────────────────────────────────────────────────────
|
|
|
|
describe("americanToImplied", () => {
|
|
it("converts positive (underdog) American odds correctly", () => {
|
|
// +400 → 100 / (400 + 100) = 0.2
|
|
expect(americanToImplied(400)).toBeCloseTo(0.2, 5);
|
|
// +100 → 100 / 200 = 0.5
|
|
expect(americanToImplied(100)).toBeCloseTo(0.5, 5);
|
|
});
|
|
|
|
it("converts negative (favorite) American odds correctly", () => {
|
|
// -200 → 200 / 300 ≈ 0.6667
|
|
expect(americanToImplied(-200)).toBeCloseTo(0.6667, 3);
|
|
});
|
|
|
|
it("returns null for odds = 0", () => {
|
|
expect(americanToImplied(0)).toBeNull();
|
|
});
|
|
|
|
it("returns probability in (0, 1] for valid odds", () => {
|
|
const p = americanToImplied(200);
|
|
expect(p).not.toBeNull();
|
|
expect(p).toBeGreaterThan(0);
|
|
expect(p).toBeLessThanOrEqual(1);
|
|
});
|
|
});
|
|
|
|
// ─── getMajorOddsKey ──────────────────────────────────────────────────────────
|
|
|
|
describe("getMajorOddsKey", () => {
|
|
it("maps Masters names to mastersOdds", () => {
|
|
expect(getMajorOddsKey("The Masters")).toBe("mastersOdds");
|
|
expect(getMajorOddsKey("masters tournament")).toBe("mastersOdds");
|
|
});
|
|
|
|
it("maps PGA Championship to pgaChampionshipOdds", () => {
|
|
expect(getMajorOddsKey("PGA Championship")).toBe("pgaChampionshipOdds");
|
|
expect(getMajorOddsKey("pga championship 2025")).toBe("pgaChampionshipOdds");
|
|
});
|
|
|
|
it("maps US Open to usOpenOdds", () => {
|
|
expect(getMajorOddsKey("US Open")).toBe("usOpenOdds");
|
|
expect(getMajorOddsKey("U.S. Open Golf")).toBe("usOpenOdds");
|
|
expect(getMajorOddsKey("2025 US Open")).toBe("usOpenOdds");
|
|
});
|
|
|
|
it("maps Open Championship / British Open to openChampionshipOdds", () => {
|
|
expect(getMajorOddsKey("The Open Championship")).toBe("openChampionshipOdds");
|
|
expect(getMajorOddsKey("British Open")).toBe("openChampionshipOdds");
|
|
});
|
|
|
|
it("returns null for unrecognized names", () => {
|
|
expect(getMajorOddsKey("Ryder Cup")).toBeNull();
|
|
expect(getMajorOddsKey("Travelers Championship")).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ─── resolveSkill ─────────────────────────────────────────────────────────────
|
|
|
|
function makeSkills(overrides: Partial<GolfSkillsRecord> = {}): GolfSkillsRecord {
|
|
return {
|
|
id: "skill-1",
|
|
participantId: "p1",
|
|
sportsSeasonId: "s1",
|
|
sgTotal: null,
|
|
datagolfRank: null,
|
|
mastersOdds: null,
|
|
usOpenOdds: null,
|
|
openChampionshipOdds: null,
|
|
pgaChampionshipOdds: null,
|
|
updatedAt: new Date(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("resolveSkill", () => {
|
|
it("returns sgTotal when set, ignoring odds", () => {
|
|
const skills = makeSkills({ sgTotal: 2.5, mastersOdds: 400 });
|
|
expect(resolveSkill(skills, "mastersOdds")).toBe(2.5);
|
|
});
|
|
|
|
it("falls back to odds-derived skill when sgTotal is null", () => {
|
|
const skills = makeSkills({ sgTotal: null, mastersOdds: 400 });
|
|
const skill = resolveSkill(skills, "mastersOdds");
|
|
// +400 = 20% win prob; skill > 0 because 20% > 1/156 baseline
|
|
expect(skill).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("returns 0 when no skills record", () => {
|
|
expect(resolveSkill(undefined, "mastersOdds")).toBe(0);
|
|
expect(resolveSkill(undefined, null)).toBe(0);
|
|
});
|
|
|
|
it("returns 0 when sgTotal is null and no matching odds key", () => {
|
|
const skills = makeSkills({ sgTotal: null, mastersOdds: 400 });
|
|
// oddsKey = null → no odds available for this major
|
|
expect(resolveSkill(skills, null)).toBe(0);
|
|
});
|
|
|
|
it("returns 0 when sgTotal is null and the odds column is null", () => {
|
|
const skills = makeSkills({ sgTotal: null, mastersOdds: null });
|
|
expect(resolveSkill(skills, "mastersOdds")).toBe(0);
|
|
});
|
|
|
|
it("better American odds → higher resolved skill", () => {
|
|
const skillGood = makeSkills({ sgTotal: null, mastersOdds: 200 }); // +200 = 33% win prob
|
|
const skillPoor = makeSkills({ sgTotal: null, mastersOdds: 2000 }); // +2000 = 4.8% win prob
|
|
const sg1 = resolveSkill(skillGood, "mastersOdds");
|
|
const sg2 = resolveSkill(skillPoor, "mastersOdds");
|
|
expect(sg1).toBeGreaterThan(sg2);
|
|
});
|
|
});
|
|
|
|
// ─── simulateMajor ────────────────────────────────────────────────────────────
|
|
|
|
function makeQPConfig(maxPlacement = 16): Map<number, number> {
|
|
// Default QP: 20, 14, 10, 8, 5, 5, 3, 3, 2, 2, 2, 2, 1, 1, 1, 1
|
|
const values = [20, 14, 10, 8, 5, 5, 3, 3, 2, 2, 2, 2, 1, 1, 1, 1];
|
|
const map = new Map<number, number>();
|
|
for (let i = 0; i < maxPlacement; i++) {
|
|
map.set(i + 1, values[i] ?? 0);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
describe("simulateMajor", () => {
|
|
const qpConfig = makeQPConfig();
|
|
|
|
it("returns an entry for every tracked player", () => {
|
|
const players = [
|
|
{ id: "p1", strength: 2.0 },
|
|
{ id: "p2", strength: 1.0 },
|
|
];
|
|
const result = simulateMajor(players, 154, 1.0, qpConfig);
|
|
expect(result.has("p1")).toBe(true);
|
|
expect(result.has("p2")).toBe(true);
|
|
});
|
|
|
|
it("total QP awarded does not exceed sum of top-16 QP config", () => {
|
|
const players = Array.from({ length: 10 }, (_, i) => ({ id: `p${i}`, strength: 1.0 }));
|
|
const result = simulateMajor(players, 146, 1.0, qpConfig);
|
|
const totalQP = [...result.values()].reduce((s, v) => s + v, 0);
|
|
const maxQP = [...qpConfig.values()].reduce((s, v) => s + v, 0);
|
|
expect(totalQP).toBeLessThanOrEqual(maxQP);
|
|
});
|
|
|
|
it("all QP values are valid (>= 0 and in the config set or 0)", () => {
|
|
const players = [{ id: "p1", strength: 8.0 }, { id: "p2", strength: 1.0 }];
|
|
const result = simulateMajor(players, 154, 1.0, qpConfig);
|
|
const validQP = new Set([0, ...qpConfig.values()]);
|
|
for (const qp of result.values()) {
|
|
expect(validQP.has(qp)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("a stronger player wins more often than a weaker one (statistical)", () => {
|
|
const TRIALS = 5_000;
|
|
let eliteWins = 0;
|
|
const players = [
|
|
{ id: "elite", strength: 50.0 }, // very high strength
|
|
{ id: "average", strength: 1.0 },
|
|
];
|
|
const singleQP = new Map([[1, 20], [2, 14]]);
|
|
|
|
for (let i = 0; i < TRIALS; i++) {
|
|
const result = simulateMajor(players, 0, 1.0, singleQP);
|
|
if (result.get("elite") === 20) eliteWins++;
|
|
}
|
|
|
|
// Elite player should win at least 80% of the time with strength 50x average
|
|
expect(eliteWins / TRIALS).toBeGreaterThan(0.8);
|
|
});
|
|
|
|
it("works when tracked players outnumber field size (restCount = 0)", () => {
|
|
const players = Array.from({ length: 200 }, (_, i) => ({ id: `p${i}`, strength: 1.0 }));
|
|
const result = simulateMajor(players, 0, 1.0, qpConfig);
|
|
// All players should have an entry
|
|
expect(result.size).toBe(200);
|
|
// Only 16 can get QP
|
|
const scorers = [...result.values()].filter((v) => v > 0);
|
|
expect(scorers.length).toBeLessThanOrEqual(16);
|
|
});
|
|
|
|
it("when all players have equal strength, each wins approximately equally (statistical)", () => {
|
|
const TRIALS = 10_000;
|
|
const N = 3;
|
|
const wins: Record<string, number> = {};
|
|
const players = Array.from({ length: N }, (_, i) => {
|
|
const id = `p${i}`;
|
|
wins[id] = 0;
|
|
return { id, strength: 1.0 };
|
|
});
|
|
const singleQP = new Map([[1, 20]]);
|
|
|
|
for (let i = 0; i < TRIALS; i++) {
|
|
const result = simulateMajor(players, 0, 1.0, singleQP);
|
|
for (const [id, qp] of result) {
|
|
if (qp === 20) wins[id]++;
|
|
}
|
|
}
|
|
|
|
// Each of 3 equal players should win ~33% ± 5%
|
|
for (const id of Object.keys(wins)) {
|
|
expect(wins[id] / TRIALS).toBeGreaterThan(0.27);
|
|
expect(wins[id] / TRIALS).toBeLessThan(0.39);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Monte Carlo property test ─────────────────────────────────────────────────
|
|
|
|
describe("simulateMajor Monte Carlo properties", () => {
|
|
it("better SG player wins the head-to-head more often (no rest-of-field)", () => {
|
|
// With no rest-of-field, the win rate is purely determined by strength ratio:
|
|
// P(elite wins) = exp(0.7 * 3.0) / (exp(0.7 * 3.0) + exp(0.7 * 1.5))
|
|
// = 8.17 / (8.17 + 2.86) ≈ 74%
|
|
const TRIALS = 3_000;
|
|
const qpConfig = new Map([[1, 20], [2, 14]]);
|
|
let eliteFirst = 0;
|
|
|
|
for (let i = 0; i < TRIALS; i++) {
|
|
const players = [
|
|
{ id: "elite", strength: Math.exp(0.7 * 3.0) }, // SG = 3.0
|
|
{ id: "good", strength: Math.exp(0.7 * 1.5) }, // SG = 1.5
|
|
];
|
|
const result = simulateMajor(players, 0, 1.0, qpConfig);
|
|
if (result.get("elite") === 20) eliteFirst++;
|
|
}
|
|
|
|
const eliteWinRate = eliteFirst / TRIALS;
|
|
// Elite player wins ~74% in a head-to-head; allow generous margin for randomness
|
|
expect(eliteWinRate).toBeGreaterThan(0.65);
|
|
});
|
|
|
|
it("in a full 156-player field, a player with strength 8 wins ~5% of the time", () => {
|
|
// Calibration check: strength 8 vs 155 opponents at strength 1 → 8 / (8 + 155) ≈ 4.9%
|
|
const TRIALS = 5_000;
|
|
const qpConfig = new Map([[1, 20]]);
|
|
let eliteWins = 0;
|
|
|
|
for (let i = 0; i < TRIALS; i++) {
|
|
const players = [{ id: "elite", strength: 8 }];
|
|
const result = simulateMajor(players, 155, 1.0, qpConfig);
|
|
if (result.get("elite") === 20) eliteWins++;
|
|
}
|
|
|
|
const winRate = eliteWins / TRIALS;
|
|
// Should be approximately 4.9%; allow ±3% tolerance
|
|
expect(winRate).toBeGreaterThan(0.02);
|
|
expect(winRate).toBeLessThan(0.09);
|
|
});
|
|
});
|
|
|
|
// ─── Not-participating exclusion ──────────────────────────────────────────────
|
|
|
|
describe("not-participating exclusion (golf)", () => {
|
|
const qpConfig = makeQPConfig();
|
|
|
|
it("a player omitted from the players list gets 0 QP from that major", () => {
|
|
// Simulates the effect of notParticipating=true: the excluded player is simply
|
|
// not passed to simulateMajor, so they are absent from the result map.
|
|
const included = [
|
|
{ id: "p1", strength: 5.0 },
|
|
{ id: "p2", strength: 1.0 },
|
|
];
|
|
const result = simulateMajor(included, 154, 1.0, qpConfig);
|
|
expect(result.has("excluded")).toBe(false);
|
|
});
|
|
|
|
it("excluding an elite player from one major redistributes probability to others", () => {
|
|
// With the elite player in both majors, p2 and p3 win rarely.
|
|
// After exclusion from major2, p2/p3 should win major2 QP more often.
|
|
const TRIALS = 2_000;
|
|
const singleQP = new Map([[1, 20]]);
|
|
|
|
let p2WinsWithElite = 0;
|
|
let p2WinsWithoutElite = 0;
|
|
const allPlayers = [
|
|
{ id: "elite", strength: 50.0 },
|
|
{ id: "p2", strength: 1.0 },
|
|
{ id: "p3", strength: 1.0 },
|
|
];
|
|
const reducedPlayers = allPlayers.filter((p) => p.id !== "elite");
|
|
|
|
for (let i = 0; i < TRIALS; i++) {
|
|
const withElite = simulateMajor(allPlayers, 0, 1.0, singleQP);
|
|
if (withElite.get("p2") === 20) p2WinsWithElite++;
|
|
|
|
const withoutElite = simulateMajor(reducedPlayers, 0, 1.0, singleQP);
|
|
if (withoutElite.get("p2") === 20) p2WinsWithoutElite++;
|
|
}
|
|
|
|
// Without the elite player, p2 should win ~50% (2 equal players); with elite ~2%
|
|
expect(p2WinsWithoutElite / TRIALS).toBeGreaterThan(0.35);
|
|
expect(p2WinsWithElite / TRIALS).toBeLessThan(0.15);
|
|
});
|
|
});
|