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>
This commit is contained in:
Chris Parsons 2026-04-09 04:39:35 +00:00
parent 715f61d1c7
commit e3052748e5
2 changed files with 22 additions and 14 deletions

View file

@ -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);

View file

@ -150,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 3295)
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;
@ -519,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 031) and
// all 32 unseeded matches come last (pairs 3263). 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)