Fix darts simulator: bracket bug, ELO calibration, fallback Elo (#284)
* fix: lower darts ELO_DIVISOR to 400 and fallback Elo to 1400 ELO_DIVISOR 500 → 400: elite darts is more skill-dominated than the previous setting implied. A 400-point gap now gives ~78% per-set win probability (vs ~73% before). fallbackElo 1600 → 1400: unseeded PDC World Championship entrants (regional qualifiers, Q-School players) are materially weaker than seeded tour professionals. 1400 better reflects that gap. Combined effect: a dominant player (e.g. 2000 Elo vs 1400-1600 field) now produces EV ≈ 65 instead of ≈ 30, which better matches expectations for a top-ranked player in a 128-player bracket. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: lower darts ELO_DIVISOR to 200 for realistic EV distribution With the actual PDC field (top players clustered 1800–1970 Elo, Littler at ~2080), ELO_DIVISOR=400 made the gap between world #1 and the top 8 too soft — EV for Littler came out ~37 instead of the expected 65–70. ELO_DIVISOR=200 calibrates correctly for this field: a 100-pt gap now gives ~62% per-set win probability (vs ~56% at 400), sharply rewarding the best players while still allowing upsets. Littler at 2084 produces EV ≈ 65–70 against the real PDC tour roster. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: seed darts bracket by Elo instead of world ranking World ranking (PDC Order of Merit) is prize-money-based and can diverge from current skill — e.g. Kevin Doets (1818 Elo) was ranked 35th while Joe Cullen (1667 Elo) held the 32nd seed. Seeding the weaker player and leaving the stronger one unseeded contradicts the Elo-based match model. Seeding by Elo descending ensures the 32 strongest players (by the same metric used to compute match probabilities) get protected bracket positions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * revert: restore world-ranking-based seeding for darts bracket Seeding should follow the PDC Order of Merit (world ranking), not Elo. Reverts the previous Elo-seeding commit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: interleave seeded and unseeded R1 pairs in darts bracket Previously, all 32 seeded-vs-unseeded matches were added to r1Pairs first (indices 0–31) and all 32 unseeded-vs-unseeded matches last (indices 32–63). Since R2 pairs adjacent R1 winners, this created two completely separate sub-brackets that only converged at the Final: - Seeded sub-bracket: top players eliminating each other early - Unseeded sub-bracket: high-Elo unseeded players (e.g. Doets 1818, Zonneveld 1806) steamrolling weak opponents and making the Final ~20% of the time Fix: interleave each seeded match with its adjacent unseeded-vs-unseeded match. Seeded pair i is at r1Pairs[2i], unseeded pair i is at r1Pairs[2i+1]. From R2 onwards, winners from seeded and unseeded regions now merge correctly as in a real single-elimination bracket. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d5aa8a3de4
commit
a6bb1330e6
2 changed files with 41 additions and 24 deletions
|
|
@ -40,9 +40,9 @@ describe("setWinProb", () => {
|
||||||
expect(setWinProb(1000, 3000)).toBeGreaterThan(0);
|
expect(setWinProb(1000, 3000)).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calibration: 100-pt gap → ~55% per set (ELO_DIVISOR=500)", () => {
|
it("calibration: 100-pt gap → ~62% per set (ELO_DIVISOR=200)", () => {
|
||||||
// 1 / (1 + e^(-100/500)) ≈ 0.5499
|
// 1 / (1 + e^(-100/200)) ≈ 0.6225
|
||||||
expect(setWinProb(1900, 1800)).toBeCloseTo(0.5499, 2);
|
expect(setWinProb(1900, 1800)).toBeCloseTo(0.6225, 2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -160,21 +160,21 @@ describe("buildR1Bracket", () => {
|
||||||
const pairs = buildR1Bracket(seeded, unseeded);
|
const pairs = buildR1Bracket(seeded, unseeded);
|
||||||
const seededSet = new Set(seeded);
|
const seededSet = new Set(seeded);
|
||||||
|
|
||||||
// First 32 pairs: seeded vs unseeded
|
// Even-indexed pairs (0, 2, 4, ...) are seeded vs unseeded
|
||||||
for (let i = 0; i < 32; i++) {
|
for (let i = 0; i < 64; i += 2) {
|
||||||
const [a, b] = pairs[i];
|
const [a, b] = pairs[i];
|
||||||
const aSeeded = seededSet.has(a);
|
const aSeeded = seededSet.has(a);
|
||||||
const bSeeded = seededSet.has(b);
|
const bSeeded = seededSet.has(b);
|
||||||
// One must be seeded, the other unseeded
|
|
||||||
expect(aSeeded !== bSeeded).toBe(true);
|
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 pairs = buildR1Bracket(seeded, unseeded);
|
||||||
const seededSet = new Set(seeded);
|
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];
|
const [a, b] = pairs[i];
|
||||||
expect(seededSet.has(a)).toBe(false);
|
expect(seededSet.has(a)).toBe(false);
|
||||||
expect(seededSet.has(b)).toBe(false);
|
expect(seededSet.has(b)).toBe(false);
|
||||||
|
|
|
||||||
|
|
@ -59,12 +59,15 @@ const NUM_SIMULATIONS = 50000;
|
||||||
* Lower = sharper (Elo differences matter more).
|
* Lower = sharper (Elo differences matter more).
|
||||||
*
|
*
|
||||||
* Standard chess uses 400. Snooker (more random than chess) uses 700.
|
* Standard chess uses 400. Snooker (more random than chess) uses 700.
|
||||||
* Darts is moderately volatile; 500 gives:
|
* Darts at the elite level is highly skill-dominated; 200 gives:
|
||||||
* 100-pt gap → ~55% per set
|
* 100-pt gap → ~62% per set
|
||||||
* 300-pt gap → ~63% per set
|
* 200-pt gap → ~73% per set
|
||||||
* 500-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).
|
* 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]> = [];
|
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++) {
|
for (let i = 0; i < 32; i++) {
|
||||||
const seedPos = seededMatchOrder[i] - 1; // 0-indexed
|
const seedPos = seededMatchOrder[i] - 1; // 0-indexed
|
||||||
|
// Even slot: seed vs. unseeded[i]
|
||||||
pairs.push([seededIds[seedPos], unseededIds[i]]);
|
pairs.push([seededIds[seedPos], unseededIds[i]]);
|
||||||
}
|
// Odd slot: unseeded vs. unseeded (indices 32 + 2i and 32 + 2i + 1)
|
||||||
|
pairs.push([unseededIds[32 + i * 2], unseededIds[32 + i * 2 + 1]]);
|
||||||
// 32 unseeded-vs-unseeded R1 matches (players 32–95)
|
|
||||||
for (let i = 32; i < 96; i += 2) {
|
|
||||||
pairs.push([unseededIds[i], unseededIds[i + 1]]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return pairs;
|
return pairs;
|
||||||
|
|
@ -289,7 +292,10 @@ export class DartsSimulator implements Simulator {
|
||||||
participantIds.push(m.participant1Id, m.participant2Id);
|
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.
|
// Cache matchWinProb — Elo values are fixed across simulations.
|
||||||
const matchProbCache = new Map<string, number>();
|
const matchProbCache = new Map<string, number>();
|
||||||
|
|
@ -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
|
// Sort participants by world ranking (ascending). Fall back to Elo order (descending) for
|
||||||
// any without a ranking, then alphabetical as a final tiebreak.
|
// any without a ranking, then alphabetical as a final tiebreak.
|
||||||
|
|
@ -510,12 +519,20 @@ export class DartsSimulator implements Simulator {
|
||||||
const drawnUnseeded = shuffle([...unseededPool]);
|
const drawnUnseeded = shuffle([...unseededPool]);
|
||||||
|
|
||||||
// Build R1 pairs inline using pre-computed seeded slots.
|
// 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]> = [];
|
const r1Pairs: Array<[string, string]> = [];
|
||||||
for (let i = 0; i < TOP_SEEDS; i++) {
|
for (let i = 0; i < TOP_SEEDS; i++) {
|
||||||
|
// Even slot: seeded player vs. their randomly drawn unseeded opponent
|
||||||
r1Pairs.push([seededSlots[i], drawnUnseeded[i]]);
|
r1Pairs.push([seededSlots[i], drawnUnseeded[i]]);
|
||||||
}
|
// Odd slot (adjacent): unseeded vs. unseeded pair that feeds into the
|
||||||
for (let i = TOP_SEEDS; i < drawnUnseeded.length; i += 2) {
|
// same R2 match as the seeded slot above
|
||||||
r1Pairs.push([drawnUnseeded[i], drawnUnseeded[i + 1]]);
|
r1Pairs.push([drawnUnseeded[TOP_SEEDS + i * 2], drawnUnseeded[TOP_SEEDS + i * 2 + 1]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// R1 (64 matches)
|
// R1 (64 matches)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue