diff --git a/app/features/tournament-bracket/core/Swiss.test.ts b/app/features/tournament-bracket/core/Swiss.test.ts index e7efb5e3c..4b2984a4f 100644 --- a/app/features/tournament-bracket/core/Swiss.test.ts +++ b/app/features/tournament-bracket/core/Swiss.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; import { Tournament } from "~/features/tournament-bracket/core/Tournament"; +import { + LOW_INK_AUGUST_2025, + RUSH_WEEKEND_3, +} from "~/features/tournament-bracket/core/tests/mocks-swiss"; import { ZONES_WEEKLY_38 } from "~/features/tournament-bracket/core/tests/mocks-zones-weekly"; import invariant from "~/utils/invariant"; import * as Swiss from "./Swiss"; @@ -162,4 +166,100 @@ describe("Swiss", () => { }); }); }); + + const PAIR_UP_TEST_CASES = [RUSH_WEEKEND_3, LOW_INK_AUGUST_2025]; + + describe("pairUp()", () => { + it.for(PAIR_UP_TEST_CASES)( + "all teams have matches (pair up test cases idx %#)", + (testCase) => { + const result = Swiss.pairUp(testCase); + + const inputTeams = testCase + .map((team) => team.id) + .sort((a, b) => a - b); + const resultTeams = result + .flatMap((match) => [match.opponentOne, match.opponentTwo]) + .filter((val) => val !== null) + .sort((a, b) => a - b); + + expect(inputTeams).toEqual(resultTeams); + }, + ); + + it.for(PAIR_UP_TEST_CASES)( + "every pair is max one set win from each other (pair up test cases idx %#)", + (testCase) => { + const result = Swiss.pairUp(testCase); + + for (const match of result) { + if (match.opponentOne === null || match.opponentTwo === null) + continue; + + const opponentOneScore = testCase.find( + (t) => t.id === match.opponentOne, + )!.score; + const opponentTwoScore = testCase.find( + (t) => t.id === match.opponentTwo, + )!.score; + + expect( + Math.abs(opponentOneScore - opponentTwoScore), + `Teams ${match.opponentOne} and ${match.opponentTwo} have too large score difference (${opponentOneScore} vs ${opponentTwoScore})`, + ).toBeLessThanOrEqual(1); + } + }, + ); + + it.for(PAIR_UP_TEST_CASES)( + "should match perfect records against each other as much as possible (pair up test cases idx %#)", + (testCase) => { + const result = Swiss.pairUp(testCase); + + const maxScore = testCase.reduce( + (max, team) => Math.max(max, team.score), + 0, + ); + const perfectRecordsCount = testCase.filter( + (team) => team.score === maxScore, + ).length; + + let perfectRecordsPlayingEachOtherCount = 0; + + for (const match of result) { + if (match.opponentOne === null || match.opponentTwo === null) + continue; + + const oneIsPerfectScore = testCase.some( + (team) => team.id === match.opponentOne && team.score === maxScore, + ); + const twoIsPerfectScore = testCase.some( + (team) => team.id === match.opponentTwo && team.score === maxScore, + ); + + if (oneIsPerfectScore && twoIsPerfectScore) { + perfectRecordsPlayingEachOtherCount++; + } + } + + expect(perfectRecordsPlayingEachOtherCount).toBe( + Math.floor(perfectRecordsCount / 2), + ); + }, + ); + + it.for(PAIR_UP_TEST_CASES)( + "generates max one bye (pair up test cases idx %#)", + (testCase) => { + const result = Swiss.pairUp(testCase); + + let byes = 0; + for (const match of result) { + if (match.opponentOne === null || match.opponentTwo === null) byes++; + } + + expect(byes).toBeLessThanOrEqual(1); + }, + ); + }); }); diff --git a/app/features/tournament-bracket/core/Swiss.ts b/app/features/tournament-bracket/core/Swiss.ts index 785014464..aef0b6381 100644 --- a/app/features/tournament-bracket/core/Swiss.ts +++ b/app/features/tournament-bracket/core/Swiss.ts @@ -262,7 +262,10 @@ interface SwissPairingTeam { } // adapted from https://github.com/slashinfty/tournament-pairings -function pairUp(players: SwissPairingTeam[]) { +export function pairUp(players: SwissPairingTeam[]) { + // uncomment to add a new test case to PAIR_UP_TEST_CASES + // console.log(players); + const matches = []; const playerArray = R.shuffle(players).map((p, i) => ({ ...p, index: i })); const scoreGroups = [...new Set(playerArray.map((p) => p.score))].sort( @@ -295,12 +298,35 @@ function pairUp(players: SwissPairingTeam[]) { 2); wt += 5 - 5 / (scoreSums.findIndex((s) => s === curr.score + opp.score) + 1); - let scoreGroupDiff = Math.abs( + const scoreGroupDiff = Math.abs( scoreGroups.findIndex((s) => s === curr.score) - scoreGroups.findIndex((s) => s === opp.score), ); - scoreGroupDiff += 0.2; + + // TODO: consider "pairedUpDown" + // if ( + // scoreGroupDiff === 1 && + // curr.hasOwnProperty("pairedUpDown") && + // curr.pairedUpDown === false && + // opp.hasOwnProperty("pairedUpDown") && + // opp.pairedUpDown === false + // ) { + // scoreGroupDiff -= 0.65; + // } else if ( + // scoreGroupDiff > 0 && + // ((curr.hasOwnProperty("pairedUpDown") && curr.pairedUpDown === true) || + // (opp.hasOwnProperty("pairedUpDown") && opp.pairedUpDown === true)) + // ) { + // scoreGroupDiff += 0.2; + // } + wt += 23 / (2 * (scoreGroupDiff + 2)); + + // Lower weight for larger score differences, we really want to avoid 2-0 playing 0-2 etc. + if (scoreGroupDiff >= 2) { + wt -= 10; + } + if ( (Object.hasOwn(curr, "receivedBye") && curr.receivedBye) || (Object.hasOwn(opp, "receivedBye") && opp.receivedBye) diff --git a/app/features/tournament-bracket/core/tests/mocks-swiss.ts b/app/features/tournament-bracket/core/tests/mocks-swiss.ts new file mode 100644 index 000000000..47117ae24 --- /dev/null +++ b/app/features/tournament-bracket/core/tests/mocks-swiss.ts @@ -0,0 +1,69 @@ +export const RUSH_WEEKEND_3 = [ + { id: 39795, score: 2, receivedBye: false, avoid: [39950, 39745] }, + { id: 39066, score: 2, receivedBye: false, avoid: [40168, 38337] }, + { id: 38951, score: 2, receivedBye: false, avoid: [39454, 39451] }, + { id: 37953, score: 2, receivedBye: false, avoid: [38435, 39861] }, + { id: 38193, score: 2, receivedBye: false, avoid: [39451, 39659] }, + { id: 39659, score: 1, receivedBye: false, avoid: [39850, 38193] }, + { id: 39861, score: 1, receivedBye: false, avoid: [39322, 37953] }, + { id: 39745, score: 1, receivedBye: false, avoid: [39951, 39795] }, + { id: 38337, score: 1, receivedBye: false, avoid: [39814, 39066] }, + { id: 39454, score: 1, receivedBye: false, avoid: [38951, 38435] }, + { id: 39950, score: 1, receivedBye: false, avoid: [39795, 40168] }, + { id: 39850, score: 1, receivedBye: false, avoid: [39659, 39814] }, + { id: 39322, score: 1, receivedBye: false, avoid: [39861, 39951] }, + { id: 39451, score: 0, receivedBye: false, avoid: [38193, 38951] }, + { id: 40168, score: 0, receivedBye: false, avoid: [39066, 39950] }, + { id: 38435, score: 0, receivedBye: false, avoid: [37953, 39454] }, + { id: 39951, score: 0, receivedBye: false, avoid: [39745, 39322] }, + { id: 39814, score: 0, receivedBye: false, avoid: [38337, 39850] }, +]; + +export const LOW_INK_AUGUST_2025 = [ + { id: 40878, score: 2, receivedBye: false, avoid: [39949, 41092] }, + { id: 40986, score: 2, receivedBye: false, avoid: [41308, 40796] }, + { id: 39811, score: 2, receivedBye: false, avoid: [41157, 41026] }, + { id: 41280, score: 2, receivedBye: false, avoid: [41035, 41117] }, + { id: 40981, score: 2, receivedBye: false, avoid: [40769, 40582] }, + { id: 40236, score: 2, receivedBye: false, avoid: [40709, 41290] }, + { id: 40318, score: 2, receivedBye: false, avoid: [39790, 40646] }, + { id: 39812, score: 2, receivedBye: false, avoid: [41089, 41085] }, + { id: 41218, score: 2, receivedBye: false, avoid: [40494, 40900] }, + { id: 40772, score: 2, receivedBye: false, avoid: [41041, 41014] }, + { id: 40881, score: 2, receivedBye: false, avoid: [41161, 40767] }, + { id: 40651, score: 2, receivedBye: false, avoid: [41262, 40842] }, + { id: 40693, score: 2, receivedBye: false, avoid: [39834, 41210] }, + { id: 41308, score: 1, receivedBye: true, avoid: [40986] }, + { id: 41117, score: 1, receivedBye: false, avoid: [40993, 41280] }, + { id: 40767, score: 1, receivedBye: false, avoid: [40850, 40881] }, + { id: 40842, score: 1, receivedBye: false, avoid: [41068, 40651] }, + { id: 40796, score: 1, receivedBye: false, avoid: [41184, 40986] }, + { id: 41014, score: 1, receivedBye: false, avoid: [41263, 40772] }, + { id: 40646, score: 1, receivedBye: false, avoid: [41207, 40318] }, + { id: 41085, score: 1, receivedBye: false, avoid: [40490, 39812] }, + { id: 41026, score: 1, receivedBye: false, avoid: [39806, 39811] }, + { id: 40582, score: 1, receivedBye: false, avoid: [41186, 40981] }, + { id: 41035, score: 1, receivedBye: false, avoid: [41280, 41257] }, + { id: 40769, score: 1, receivedBye: false, avoid: [40981, 41262] }, + { id: 41290, score: 1, receivedBye: false, avoid: [39827, 40236] }, + { id: 40900, score: 1, receivedBye: false, avoid: [41257, 41218] }, + { id: 39949, score: 1, receivedBye: false, avoid: [40878, 39806] }, + { id: 41092, score: 1, receivedBye: false, avoid: [41210, 40878] }, + { id: 40490, score: 1, receivedBye: false, avoid: [41085, 41161] }, + { id: 40993, score: 1, receivedBye: false, avoid: [41117, 39834] }, + { id: 41263, score: 1, receivedBye: false, avoid: [41014, 40494] }, + { id: 40850, score: 1, receivedBye: false, avoid: [40767, 39827] }, + { id: 41207, score: 1, receivedBye: false, avoid: [40646, 41041] }, + { id: 41184, score: 1, receivedBye: false, avoid: [40796, 41089] }, + { id: 41041, score: 0, receivedBye: false, avoid: [40772, 41207] }, + { id: 39790, score: 0, receivedBye: false, avoid: [40318, 41157] }, + { id: 41089, score: 0, receivedBye: false, avoid: [39812, 41184] }, + { id: 41161, score: 0, receivedBye: false, avoid: [40881, 40490] }, + { id: 39834, score: 0, receivedBye: false, avoid: [40693, 40993] }, + { id: 40494, score: 0, receivedBye: false, avoid: [41218, 41263] }, + { id: 41262, score: 0, receivedBye: false, avoid: [40651, 40769] }, + { id: 39827, score: 0, receivedBye: false, avoid: [41290, 40850] }, + { id: 41186, score: 0, receivedBye: false, avoid: [40582, 41068] }, + { id: 39806, score: 0, receivedBye: false, avoid: [41026, 39949] }, + { id: 41257, score: 0, receivedBye: false, avoid: [40900, 41035] }, +];