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);
|
||||
});
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
// 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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue