diff --git a/app/services/simulations/__tests__/darts-simulator.test.ts b/app/services/simulations/__tests__/darts-simulator.test.ts index 1df59e7..6ad9b5e 100644 --- a/app/services/simulations/__tests__/darts-simulator.test.ts +++ b/app/services/simulations/__tests__/darts-simulator.test.ts @@ -40,9 +40,9 @@ describe("setWinProb", () => { expect(setWinProb(1000, 3000)).toBeGreaterThan(0); }); - it("calibration: 100-pt gap → ~55% per set (ELO_DIVISOR=500)", () => { - // 1 / (1 + e^(-100/500)) ≈ 0.5499 - expect(setWinProb(1900, 1800)).toBeCloseTo(0.5499, 2); + it("calibration: 100-pt gap → ~62% per set (ELO_DIVISOR=200)", () => { + // 1 / (1 + e^(-100/200)) ≈ 0.6225 + expect(setWinProb(1900, 1800)).toBeCloseTo(0.6225, 2); }); }); @@ -160,21 +160,21 @@ describe("buildR1Bracket", () => { const pairs = buildR1Bracket(seeded, unseeded); const seededSet = new Set(seeded); - // First 32 pairs: seeded vs unseeded - for (let i = 0; i < 32; i++) { + // Even-indexed pairs (0, 2, 4, ...) are seeded vs unseeded + for (let i = 0; i < 64; i += 2) { const [a, b] = pairs[i]; const aSeeded = seededSet.has(a); const bSeeded = seededSet.has(b); - // One must be seeded, the other unseeded expect(aSeeded !== bSeeded).toBe(true); } }); - it("last 32 pairs: all unseeded vs unseeded", () => { + it("odd-indexed pairs are all unseeded vs unseeded (interleaved bracket structure)", () => { const pairs = buildR1Bracket(seeded, unseeded); const seededSet = new Set(seeded); - for (let i = 32; i < 64; i++) { + // Odd-indexed pairs (1, 3, 5, ...) are unseeded vs unseeded + for (let i = 1; i < 64; i += 2) { const [a, b] = pairs[i]; expect(seededSet.has(a)).toBe(false); expect(seededSet.has(b)).toBe(false); diff --git a/app/services/simulations/darts-simulator.ts b/app/services/simulations/darts-simulator.ts index 922e6ba..207d4e0 100644 --- a/app/services/simulations/darts-simulator.ts +++ b/app/services/simulations/darts-simulator.ts @@ -59,12 +59,15 @@ const NUM_SIMULATIONS = 50000; * Lower = sharper (Elo differences matter more). * * Standard chess uses 400. Snooker (more random than chess) uses 700. - * Darts is moderately volatile; 500 gives: - * 100-pt gap → ~55% per set - * 300-pt gap → ~63% per set - * 500-pt gap → ~73% per set + * Darts at the elite level is highly skill-dominated; 200 gives: + * 100-pt gap → ~62% per set + * 200-pt gap → ~73% per set + * 300-pt gap → ~83% per set + * + * With 200, a real PDC field (top players 1800–1970 Elo, unseeded at 1400) + * produces EV ≈ 65–70 for the world #1 (≈2080 Elo) in a 128-player bracket. */ -const ELO_DIVISOR = 500; +const ELO_DIVISOR = 200; /** * Sets needed to win per round, in bracket order (R1 first, Final last). @@ -147,15 +150,15 @@ export function buildR1Bracket( const pairs: Array<[string, string]> = []; - // 32 seeded-vs-unseeded R1 matches + // Interleave seeded and unseeded pairs so each seed's R1 match is immediately + // followed by an unseeded-vs-unseeded match. This ensures the two types of + // match converge in R2 rather than running as separate sub-brackets until the Final. for (let i = 0; i < 32; i++) { const seedPos = seededMatchOrder[i] - 1; // 0-indexed + // Even slot: seed vs. unseeded[i] pairs.push([seededIds[seedPos], unseededIds[i]]); - } - - // 32 unseeded-vs-unseeded R1 matches (players 32–95) - for (let i = 32; i < 96; i += 2) { - pairs.push([unseededIds[i], unseededIds[i + 1]]); + // Odd slot: unseeded vs. unseeded (indices 32 + 2i and 32 + 2i + 1) + pairs.push([unseededIds[32 + i * 2], unseededIds[32 + i * 2 + 1]]); } return pairs; @@ -289,7 +292,10 @@ export class DartsSimulator implements Simulator { participantIds.push(m.participant1Id, m.participant2Id); } - const fallbackElo = 1600; + // 1400 reflects the typical strength of unseeded PDC World Championship + // qualifiers (regional/Q-School players), who are significantly weaker than + // the seeded tour players. + const fallbackElo = 1400; // Cache matchWinProb — Elo values are fixed across simulations. const matchProbCache = new Map(); @@ -457,7 +463,10 @@ export class DartsSimulator implements Simulator { ); } - const fallbackElo = 1600; + // 1400 reflects the typical strength of unseeded PDC World Championship + // qualifiers (regional/Q-School players), who are significantly weaker than + // the seeded tour players. + const fallbackElo = 1400; // Sort participants by world ranking (ascending). Fall back to Elo order (descending) for // any without a ranking, then alphabetical as a final tiebreak. @@ -510,12 +519,20 @@ export class DartsSimulator implements Simulator { const drawnUnseeded = shuffle([...unseededPool]); // Build R1 pairs inline using pre-computed seeded slots. + // IMPORTANT: interleave each seeded match with its adjacent unseeded match. + // Without interleaving, all 32 seeded matches come first (pairs 0–31) and + // all 32 unseeded matches come last (pairs 32–63). Because R2 pairs adjacent + // R1 winners, this creates two completely separate sub-brackets (seeded vs. + // unseeded) that only converge at the Final — producing absurd results like + // unseeded 1800-Elo players having a 20% finalist probability. + // Interleaving ensures seeded and unseeded regions mix from R2 onwards. const r1Pairs: Array<[string, string]> = []; for (let i = 0; i < TOP_SEEDS; i++) { + // Even slot: seeded player vs. their randomly drawn unseeded opponent r1Pairs.push([seededSlots[i], drawnUnseeded[i]]); - } - for (let i = TOP_SEEDS; i < drawnUnseeded.length; i += 2) { - r1Pairs.push([drawnUnseeded[i], drawnUnseeded[i + 1]]); + // Odd slot (adjacent): unseeded vs. unseeded pair that feeds into the + // same R2 match as the seeded slot above + r1Pairs.push([drawnUnseeded[TOP_SEEDS + i * 2], drawnUnseeded[TOP_SEEDS + i * 2 + 1]]); } // R1 (64 matches)