diff --git a/app/features/tournament-bracket/core/Swiss.test.ts b/app/features/tournament-bracket/core/Swiss.test.ts index dfc07e236..536a85751 100644 --- a/app/features/tournament-bracket/core/Swiss.test.ts +++ b/app/features/tournament-bracket/core/Swiss.test.ts @@ -3,6 +3,7 @@ import type { TournamentStageSettings } from "~/db/tables-json"; import { Tournament } from "~/features/tournament-bracket/core/Tournament"; import { LOW_INK_AUGUST_2025, + LOW_INK_AUGUST_2026_ROUND_3, RUSH_WEEKEND_3, } from "~/features/tournament-bracket/core/tests/mocks-swiss"; import { ZONES_WEEKLY_38 } from "~/features/tournament-bracket/core/tests/mocks-zones-weekly"; @@ -232,7 +233,11 @@ describe("Swiss", () => { }); }); - const PAIR_UP_TEST_CASES = [RUSH_WEEKEND_3, LOW_INK_AUGUST_2025]; + const PAIR_UP_TEST_CASES = [ + RUSH_WEEKEND_3, + LOW_INK_AUGUST_2025, + LOW_INK_AUGUST_2026_ROUND_3, + ]; describe("pairUp()", () => { test.for(PAIR_UP_TEST_CASES)( @@ -376,6 +381,23 @@ describe("Swiss", () => { expect(bye?.opponentOne).toBe(2); }); + + test("gives the bye to the lowest score group even when byeing a top group team would keep every pairing within its score group", () => { + // real state where the top score group had an odd size (7) while the + // lower groups were even (16 and 6): byeing an undefeated team lets + // every match stay within its score group, which the weights preferred: + // live this gave the free win to the 2-0 top seed instead of a 0-2 team + const result = Swiss.pairUp(LOW_INK_AUGUST_2026_ROUND_3); + + const bye = result.find((match) => match.opponentTwo === null); + invariant(bye, "bye not found"); + + const byeTeam = LOW_INK_AUGUST_2026_ROUND_3.find( + (team) => team.id === bye.opponentOne, + ); + + expect(byeTeam?.score).toBe(0); + }); }); 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 d9e18e268..99c30b708 100644 --- a/app/features/tournament-bracket/core/engine/swiss/pairing.ts +++ b/app/features/tournament-bracket/core/engine/swiss/pairing.ts @@ -16,7 +16,7 @@ import { calculateTeamStatus } from "./team-status"; * to dwarf every other weight of the whole round, meaning rematches only happen when * unavoidable and then as few of them as possible. */ -const REMATCH_PENALTY = 1_000_000; +const REMATCH_PENALTY = 1_000_000_000; /** * Weight added for each team of a pair that has already received a bye, making @@ -24,9 +24,21 @@ const REMATCH_PENALTY = 1_000_000; * 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. + * weight (including HIGH_SCORE_PAIRING_BONUS) while staying far below + * REMATCH_PENALTY. */ -const PRIOR_BYE_PAIRING_BONUS = 10_000; +const PRIOR_BYE_PAIRING_BONUS = 10_000_000; + +/** + * Weight added per score point of both teams of a pair. Summed over a full matching + * this contributes the bonus times the total score of all paired teams, so with an + * odd team count the matching maximizes it by leaving a lowest-score team unpaired — + * that team receives the bye. Without this term the bye can drift to a top score + * group team whenever the score group size parities make all-within-group pairings + * possible only that way. Sized to dwarf the pairing quality weights (< ~100 per + * pair) while a full round's worth stays far below PRIOR_BYE_PAIRING_BONUS. + */ +const HIGH_SCORE_PAIRING_BONUS = 10_000; interface GroupArgs { groupId: number; @@ -295,6 +307,8 @@ function generateWeightedPairs({ wt -= 10; } + wt += (curr.score + opp.score) * HIGH_SCORE_PAIRING_BONUS; + if (curr.receivedBye) { wt += PRIOR_BYE_PAIRING_BONUS; } diff --git a/app/features/tournament-bracket/core/tests/mocks-swiss.ts b/app/features/tournament-bracket/core/tests/mocks-swiss.ts index 47117ae24..2046dae8e 100644 --- a/app/features/tournament-bracket/core/tests/mocks-swiss.ts +++ b/app/features/tournament-bracket/core/tests/mocks-swiss.ts @@ -19,6 +19,39 @@ export const RUSH_WEEKEND_3 = [ { id: 39814, score: 0, receivedBye: false, avoid: [38337, 39850] }, ]; +// tournament 4224 group A entering round 3: 7 teams at score 2, 16 at score 1, 6 at score 0 +export const LOW_INK_AUGUST_2026_ROUND_3 = [ + { id: 83693, score: 2, receivedBye: false, avoid: [85574, 84491] }, + { id: 84224, score: 2, receivedBye: false, avoid: [86019, 85541] }, + { id: 84735, score: 2, receivedBye: false, avoid: [84239, 83733] }, + { id: 85791, score: 2, receivedBye: false, avoid: [85868, 85393] }, + { id: 85832, score: 2, receivedBye: false, avoid: [83873, 84857] }, + { id: 85909, score: 2, receivedBye: false, avoid: [86038, 86046] }, + { id: 85970, score: 2, receivedBye: false, avoid: [85408, 85347] }, + { id: 83619, score: 1, receivedBye: false, avoid: [83733, 85775] }, + { id: 83733, score: 1, receivedBye: false, avoid: [83619, 84735] }, + { id: 84491, score: 1, receivedBye: false, avoid: [84553, 83693] }, + { id: 84553, score: 1, receivedBye: false, avoid: [84491, 86019] }, + { id: 84857, score: 1, receivedBye: false, avoid: [86067, 85832] }, + { id: 85347, score: 1, receivedBye: false, avoid: [85504, 85970] }, + { id: 85393, score: 1, receivedBye: false, avoid: [85860, 85791] }, + { id: 85408, score: 1, receivedBye: false, avoid: [85970, 85697] }, + { id: 85536, score: 1, receivedBye: true, avoid: [86067] }, + { id: 85541, score: 1, receivedBye: false, avoid: [85697, 84224] }, + { id: 85574, score: 1, receivedBye: false, avoid: [83693, 84239] }, + { id: 85860, score: 1, receivedBye: true, avoid: [85393] }, + { id: 85868, score: 1, receivedBye: false, avoid: [85791, 85504] }, + { id: 86038, score: 1, receivedBye: false, avoid: [85909, 83873] }, + { id: 86046, score: 1, receivedBye: false, avoid: [85775, 85909] }, + { id: 86067, score: 1, receivedBye: false, avoid: [84857, 85536] }, + { id: 83873, score: 0, receivedBye: false, avoid: [85832, 86038] }, + { id: 84239, score: 0, receivedBye: false, avoid: [84735, 85574] }, + { id: 85504, score: 0, receivedBye: false, avoid: [85347, 85868] }, + { id: 85697, score: 0, receivedBye: false, avoid: [85541, 85408] }, + { id: 85775, score: 0, receivedBye: false, avoid: [86046, 83619] }, + { id: 86019, score: 0, receivedBye: false, avoid: [84224, 84553] }, +]; + export const LOW_INK_AUGUST_2025 = [ { id: 40878, score: 2, receivedBye: false, avoid: [39949, 41092] }, { id: 40986, score: 2, receivedBye: false, avoid: [41308, 40796] },