From 5adf4be0ebe5f740313ebcdc6af2fa7ab3eec746 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:52:31 +0300 Subject: [PATCH] Fix Swiss algorithm not always finding the correct team to give bye to --- .../tournament-bracket/core/Swiss.test.ts | 18 +++++++++++++++++ .../core/engine/swiss/pairing.ts | 20 ++++++++++++++----- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/app/features/tournament-bracket/core/Swiss.test.ts b/app/features/tournament-bracket/core/Swiss.test.ts index 243d1f402..b8b171a17 100644 --- a/app/features/tournament-bracket/core/Swiss.test.ts +++ b/app/features/tournament-bracket/core/Swiss.test.ts @@ -358,6 +358,24 @@ describe("Swiss", () => { expect(includesPair(result, 1, 2)).toBe(false); }); + + it("prefers giving the bye to the lowest standing team without a previous bye", () => { + // five team swiss entering round 4: teams 3, 4 and 5 have already had a + // bye, team 3 in the round right before this one. Teams 1 and 2 have not, + // and a rematch free pairing where team 2 (the lowest standing team + // without a previous bye) gets the bye exists: 1-4, 3-5 + const result = Swiss.pairUp([ + { id: 1, score: 3, avoid: [3, 5, 2] }, + { id: 2, score: 2, avoid: [4, 3, 1] }, + { id: 4, score: 2, avoid: [2, 5], receivedBye: true }, + { id: 3, score: 1, avoid: [1, 2], receivedBye: true }, + { id: 5, score: 1, avoid: [1, 4], receivedBye: true }, + ]); + + const bye = result.find((match) => match.opponentTwo === null); + + expect(bye?.opponentOne).toBe(2); + }); }); describe("calculateTeamStatus()", () => { diff --git a/app/features/tournament-bracket/core/engine/swiss/pairing.ts b/app/features/tournament-bracket/core/engine/swiss/pairing.ts index 0b7619e11..d9e18e268 100644 --- a/app/features/tournament-bracket/core/engine/swiss/pairing.ts +++ b/app/features/tournament-bracket/core/engine/swiss/pairing.ts @@ -18,6 +18,16 @@ import { calculateTeamStatus } from "./team-status"; */ const REMATCH_PENALTY = 1_000_000; +/** + * Weight added for each team of a pair that has already received a bye, making + * previously-byed teams more attractive to pair up. Applied per team (not per pair) + * so that a matching leaving a bye-less team unpaired always beats one giving a team + * a second bye: with an odd team count exactly one team is left unpaired, so the + * matchings differ by exactly one bonus, which is set to dwarf every score-based + * weight while staying far below REMATCH_PENALTY. + */ +const PRIOR_BYE_PAIRING_BONUS = 10_000; + interface GroupArgs { groupId: number; standings: SwissStanding[]; @@ -285,11 +295,11 @@ function generateWeightedPairs({ wt -= 10; } - if ( - (Object.hasOwn(curr, "receivedBye") && curr.receivedBye) || - (Object.hasOwn(opp, "receivedBye") && opp.receivedBye) - ) { - wt += 40; + if (curr.receivedBye) { + wt += PRIOR_BYE_PAIRING_BONUS; + } + if (opp.receivedBye) { + wt += PRIOR_BYE_PAIRING_BONUS; } wt -= timesPlayed(curr, opp) * REMATCH_PENALTY;