Fix swiss pairing algorithm (#2446)

This commit is contained in:
Kalle 2025-07-10 20:17:58 +03:00 committed by GitHub
parent 621d484363
commit d7d10fbd78
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1317 additions and 361 deletions

View File

@ -2,6 +2,6 @@
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit"
"source.fixAll.biome": "never"
}
}

View File

@ -89,6 +89,8 @@ export function PlacementsTable({
bracket.tournament.ctx.settings.bracketProgression,
).map((idx) => bracket.tournament.bracketByIdx(idx)!);
const canEditDestination = (() => {
if (possibleDestinationBrackets.length === 0) return false;
const allDestinationsPreview = possibleDestinationBrackets.every(
(b) => b.preview,
);

View File

@ -1,4 +1,7 @@
import { describe, expect, it } from "vitest";
import { Tournament } from "~/features/tournament-bracket/core/Tournament";
import { ZONES_WEEKLY_38 } from "~/features/tournament-bracket/core/tests/mocks-zones-weekly";
import invariant from "~/utils/invariant";
import * as Swiss from "./Swiss";
describe("Swiss", () => {
@ -98,6 +101,65 @@ describe("Swiss", () => {
});
});
// TODO:
// describe("generateMatchUps()", () => {});
describe("generateMatchUps()", () => {
describe("Zones Weekly 38", () => {
const tournament = new Tournament({
...ZONES_WEEKLY_38(),
simulateBrackets: false,
});
const bracket = tournament.bracketByIdx(0)!;
const matches = Swiss.generateMatchUps({
bracket,
groupId: 4443,
});
it("finds new opponents for each team in the last round", () => {
for (const match of matches) {
if (match.opponentTwo === "null") continue;
const opponent1 = JSON.parse(match.opponentOne).id as number;
const opponent2 = JSON.parse(match.opponentTwo).id as number;
const existingMatch = bracket.data.match.find(
(m) =>
(m.opponent1?.id === opponent1 &&
m.opponent2?.id === opponent2) ||
(m.opponent1?.id === opponent2 && m.opponent2?.id === opponent1),
);
expect(existingMatch).toBeUndefined();
}
});
it("generates a bye", () => {
const byes = matches.filter((match) => match.opponentTwo === "null");
expect(byes).toHaveLength(1);
});
it("every pair is max one set win from each other", () => {
for (const match of matches) {
if (match.opponentTwo === "null") continue;
const opponent1 = JSON.parse(match.opponentOne).id as number;
const opponent2 = JSON.parse(match.opponentTwo).id as number;
const opponent1Stats = bracket.standings.find(
(s) => s.team.id === opponent1,
)?.stats;
const opponent2Stats = bracket.standings.find(
(s) => s.team.id === opponent2,
)?.stats;
invariant(opponent1Stats, "Opponent 1 not found in standings");
invariant(opponent2Stats, "Opponent 2 not found in standings");
expect(
Math.abs(opponent1Stats.setWins - opponent2Stats.setWins),
).toBeLessThanOrEqual(1);
}
});
});
});
});

View File

@ -1,12 +1,14 @@
// separate from brackets-manager as this wasn't part of the original brackets-manager library
import blossom from "edmonds-blossom-fixed";
import * as R from "remeda";
import type { TournamentRepositoryInsertableMatch } from "~/features/tournament/TournamentRepository.server";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import type { InputStage, Match } from "~/modules/brackets-model";
import { nullFilledArray } from "~/utils/arrays";
import invariant from "~/utils/invariant";
import type { Bracket, Standing } from "./Bracket";
import type { Bracket } from "./Bracket";
/**
* Creates a Swiss tournament data set (initial matches) based on the provided arguments. Mimics bracket-manager module's interfaces.
@ -145,11 +147,24 @@ function firstRoundMatches({
}
}
function everyMatchOver(matches: Match[]) {
for (const match of matches) {
// bye
if (!match.opponent1 || !match.opponent2) continue;
if (match.opponent1.result !== "win" && match.opponent2.result !== "win") {
return false;
}
}
return true;
}
/**
* Generates the next round of matchups for a Swiss tournament bracket within a specific group.
*
* Considers only the matches and teams within the specified group. Teams that have dropped out are excluded from the pairing process.
* If the group has an uneven number of teams, the lowest standing team that has not already received a bye will receive one.
* If the group has an uneven number of teams, the lowest standing team that has not already received a bye is preferred to receive one.
* Matches are generated such that teams do not replay previous opponents if possible.
*/
export function generateMatchUps({
@ -158,7 +173,7 @@ export function generateMatchUps({
}: {
bracket: Bracket;
groupId: number;
}) {
}): Array<TournamentRepositoryInsertableMatch> {
// lets consider only this groups matches
// in the case that there are more than one group
const groupsMatches = bracket.data.match.filter(
@ -184,67 +199,28 @@ export function generateMatchUps({
(s) => !s.team.droppedOut,
);
// if group has uneven number of teams
// the lowest standing team gets a bye
// that did not already receive one
const { bye, play } = splitToByeAndPlay(
standingsWithoutDropouts,
groupsMatches,
const teamsThatHaveHadByes = groupsMatches
.filter((m) => m.opponent2 === null)
.map((m) => m.opponent1?.id);
const pairs = pairUp(
standingsWithoutDropouts.map((standing) => ({
id: standing.team.id,
score: standing.stats?.setWins ?? 0,
receivedBye: teamsThatHaveHadByes.includes(standing.team.id),
avoid: groupsMatches.flatMap((match) => {
if (match.opponent1?.id === standing.team.id) {
return match.opponent2?.id ? [match.opponent2.id] : [];
}
if (match.opponent2?.id === standing.team.id) {
return match.opponent1?.id ? [match.opponent1.id] : [];
}
return [];
}),
})),
);
// split participating teams to sections
// each section resolves matches between teams of that section
// section could look something like this (team counts inaccurate):
// 3-0'ers - 4 members
// 2-1'ers - 6 members
// 1-2'ers - 6 members
// 0-3'ers - 4 members
// ---
// if a section has an uneven number of teams
// the lowest standing team gets dropped to the section below
// or if the lowest section is unevent the highest team of the lowest section
// gets promoted to the section above
let sections = splitPlayingTeamsToSections(play);
let iteration = 0;
let matches: [opponentOneId: number, opponentTwoId: number][] = [];
while (true) {
iteration++;
if (iteration > 100) {
throw new Error("Swiss bracket generation failed (too many iterations)");
}
// lets attempt to create matches for the current sections
// might fail if some section can't be matches so that nobody replays
const maybeMatches = sectionsToMatches(sections, groupsMatches);
// ok good matches found!
if (Array.isArray(maybeMatches)) {
matches = maybeMatches;
break;
}
// for some reason we couldn't find new opponent for everyone
// even with everyone in the same section, so let's just replay
// (should not be possible to happen if running swiss normally)
if (sections.length === 1) {
const maybeMatches = sectionsToMatches(sections, groupsMatches, true);
if (Array.isArray(maybeMatches)) {
matches = maybeMatches;
break;
}
throw new Error(
"Swiss bracket generation failed (failed to generate matches even with fallback behavior)",
);
}
// let's unify sections so that we can try again with a better chance
sections = unifySections(sections, maybeMatches.impossibleSectionIdx);
}
// finally lets just convert the generated pairs to match objects
// for the database
let matchNumber = 1;
const newRoundId = bracket.data.round
.slice()
.sort((a, b) => a.id - b.id)
@ -253,312 +229,123 @@ export function generateMatchUps({
(r) => r.id > Math.max(...groupsMatches.map((match) => match.round_id)),
)?.id;
invariant(newRoundId, "newRoundId not found");
let matchNumber = 1;
const result: TournamentRepositoryInsertableMatch[] = matches.map(
([opponentOneId, opponentTwoId]) => ({
const result: TournamentRepositoryInsertableMatch[] = pairs.map(
({ opponentOne, opponentTwo }) => ({
groupId,
number: matchNumber++,
roundId: newRoundId,
stageId: groupsMatches[0].stage_id,
opponentOne: JSON.stringify({
id: opponentOneId,
}),
opponentTwo: JSON.stringify({
id: opponentTwoId,
id: opponentOne,
}),
opponentTwo:
typeof opponentTwo === "number"
? JSON.stringify({
id: opponentTwo,
})
: JSON.stringify(null),
}),
);
if (bye) {
result.push({
groupId,
stageId: groupsMatches[0].stage_id,
roundId: newRoundId,
number: matchNumber,
opponentOne: JSON.stringify({
id: bye.team.id,
return result;
}
interface SwissPairingTeam {
id: number;
/** How many matches has the team won */
score: number;
/** List of tournament team ids this team already played */
avoid: Array<number>;
receivedBye?: boolean;
}
// adapted from https://github.com/slashinfty/tournament-pairings
function pairUp(players: SwissPairingTeam[]) {
const matches = [];
const playerArray = R.shuffle(players).map((p, i) => ({ ...p, index: i }));
const scoreGroups = [...new Set(playerArray.map((p) => p.score))].sort(
(a, b) => a - b,
);
const scoreSums = [
...new Set(
scoreGroups.flatMap((s, i, a) => {
const sums = [];
for (let j = i; j < a.length; j++) {
sums.push(s + a[j]);
}
return sums;
}),
opponentTwo: JSON.stringify(null),
),
].sort((a, b) => a - b);
const pairs = [];
for (let i = 0; i < playerArray.length; i++) {
const curr = playerArray[i];
const next = playerArray.slice(i + 1);
for (let j = 0; j < next.length; j++) {
const opp = next[j];
if (Object.hasOwn(curr, "avoid") && curr.avoid.includes(opp.id)) {
continue;
}
let wt =
75 -
75 /
(scoreGroups.findIndex((s) => s === Math.min(curr.score, opp.score)) +
2);
wt +=
5 - 5 / (scoreSums.findIndex((s) => s === curr.score + opp.score) + 1);
let scoreGroupDiff = Math.abs(
scoreGroups.findIndex((s) => s === curr.score) -
scoreGroups.findIndex((s) => s === opp.score),
);
scoreGroupDiff += 0.2;
wt += 23 / (2 * (scoreGroupDiff + 2));
if (
(Object.hasOwn(curr, "receivedBye") && curr.receivedBye) ||
(Object.hasOwn(opp, "receivedBye") && opp.receivedBye)
) {
wt += 40;
}
pairs.push([curr.index, opp.index, wt]);
}
}
const blossomPairs = blossom(pairs, true);
const playerCopy = [...playerArray];
let byeArray = [];
do {
const indexA = playerCopy[0].index;
const indexB = blossomPairs[indexA];
if (indexB === -1) {
byeArray.push(playerCopy.splice(0, 1)[0]);
continue;
}
playerCopy.splice(0, 1);
playerCopy.splice(
playerCopy.findIndex((p) => p.index === indexB),
1,
);
const playerA = playerArray.find((p) => p.index === indexA);
const playerB = playerArray.find((p) => p.index === indexB);
invariant(playerA, "Player A not found");
invariant(playerB, "Player B not found");
matches.push({
opponentOne: playerA.id,
opponentTwo: playerB.id,
});
} while (
playerCopy.length >
blossomPairs.reduce(
(sum: number, idx: number) => (idx === -1 ? sum + 1 : sum),
0,
)
);
byeArray = [...byeArray, ...playerCopy];
for (let i = 0; i < byeArray.length; i++) {
matches.push({
opponentOne: byeArray[i].id,
opponentTwo: null,
});
}
return result;
}
function everyMatchOver(matches: Match[]) {
for (const match of matches) {
// bye
if (!match.opponent1 || !match.opponent2) continue;
if (match.opponent1.result !== "win" && match.opponent2.result !== "win") {
return false;
}
}
return true;
}
function splitToByeAndPlay(standings: Standing[], matches: Match[]) {
if (standings.length % 2 === 0) {
return {
bye: null,
play: standings,
};
}
const teamsThatHaveHadByes = matches
.filter((m) => m.opponent2 === null)
.map((m) => m.opponent1?.id);
const play = standings.slice();
const bye = play
.slice()
.reverse()
.find((s) => !teamsThatHaveHadByes.includes(s.team.id));
// should not happen
if (!bye) {
const reBye = play[play.length - 1];
return {
bye: reBye,
play: play.filter((s) => s.team.id !== reBye.team.id),
};
}
return {
bye: bye,
play: play.filter((s) => s.team.id !== bye.team.id),
};
}
type TournamentDataTeamSections = Standing[][];
function splitPlayingTeamsToSections(standings: Standing[]) {
let result: TournamentDataTeamSections = [];
let lastMapWins = -1;
let currentSection: Standing[] = [];
for (const standing of standings) {
const mapWins = standing.stats?.mapWins;
invariant(mapWins !== undefined, "mapWins not found");
if (mapWins !== lastMapWins) {
if (currentSection.length > 0) result.push(currentSection);
currentSection = [];
}
currentSection.push(standing);
lastMapWins = mapWins;
}
result.push(currentSection);
result = evenOutSectionsForward(result);
result = evenOutSectionsBackward(result);
return result;
}
function evenOutSectionsForward(sections: TournamentDataTeamSections) {
if (sections.every((section) => section.length % 2 === 0)) {
return sections;
}
const result: TournamentDataTeamSections = [];
let pushedStanding: Standing | null = null;
for (const [i, section] of sections.entries()) {
const newSection = section.slice();
if (pushedStanding) {
newSection.unshift(pushedStanding);
pushedStanding = null;
}
if (newSection.length % 2 !== 0 && i < sections.length - 1) {
pushedStanding = newSection.pop()!;
}
result.push(newSection);
}
return result;
}
function evenOutSectionsBackward(sections: TournamentDataTeamSections) {
if (sections.every((section) => section.length % 2 === 0)) {
return sections;
}
const result: TournamentDataTeamSections = [];
let pushedTeam: Standing | null = null;
for (const [i, section] of sections.slice().reverse().entries()) {
const newSection = section.slice();
if (pushedTeam) {
newSection.push(pushedTeam);
pushedTeam = null;
}
if (newSection.length % 2 !== 0) {
if (i === sections.length - 1) {
throw new Error("Can't even out sections");
}
pushedTeam = newSection.shift()!;
}
result.unshift(newSection);
}
return result;
}
function sectionsToMatches(
sections: TournamentDataTeamSections,
previousMatches: Match[],
fallbackBehaviorWithReplays = false,
):
| [opponentOneId: number, opponentTwoId: number][]
| { impossibleSectionIdx: number } {
const matches: [opponentOneId: number, opponentTwoId: number][] = [];
for (const [i, section] of sections.entries()) {
const isLossless = section.every(
(standing) => standing.stats!.setLosses === 0,
);
const isWinless = section.every(
(standing) => standing.stats!.setWins === 0,
);
if (isLossless || isWinless || fallbackBehaviorWithReplays) {
// doing it like this to make it so that if everyone plays to their seed
// then seeds 1 & 2 meet in the final round (assuming proper amount of rounds)
// these sections can't have replays no matter how we divide them
matches.push(...matchesBySeed(section));
} else {
const sectionMatches = matchesByNotPlayedBefore(section, previousMatches);
if (sectionMatches === null) {
return { impossibleSectionIdx: i };
}
matches.push(...sectionMatches);
}
}
return matches;
}
function unifySections(
sections: TournamentDataTeamSections,
sectionToUnifyIdx: number,
) {
const result: TournamentDataTeamSections = sections.slice();
if (sectionToUnifyIdx < sections.length - 1) {
// Combine section at sectionToUnifyIdx with the section after it
const currentSection = result[sectionToUnifyIdx];
const nextSection = result[sectionToUnifyIdx + 1];
const combinedSection = [...currentSection, ...nextSection];
result[sectionToUnifyIdx] = combinedSection;
result.splice(sectionToUnifyIdx + 1, 1);
} else {
// Combine last section with the section before it
const lastSection = result.pop()!;
const previousSection = result.pop()!;
const combinedSection = [...previousSection, ...lastSection];
result.push(combinedSection);
}
invariant(
sections.length - 1 === result.length,
"unifySections: length invalid",
);
return result;
}
function matchesBySeed(
teams: Standing[],
): [opponentOneId: number, opponentTwoId: number][] {
// we know that here nobody has played each other
const sortedBySeed = teams.slice().sort((a, b) => {
invariant(a.team.seed, "matchesBySeed: a.seed is falsy");
invariant(b.team.seed, "matchesBySeed: b.seed is falsy");
return a.team.seed - b.team.seed;
});
const matches: [opponentOneId: number, opponentTwoId: number][] = [];
while (sortedBySeed.length > 0) {
const one = sortedBySeed.shift()!;
const two = sortedBySeed.pop()!;
matches.push([one.team.id, two.team.id]);
}
return matches;
}
function matchesByNotPlayedBefore(
teams: Standing[],
previousMatches: Match[],
): [opponentOneId: number, opponentTwoId: number][] | null {
invariant(teams.length % 2 === 0, "matchesByNotPlayedBefore: uneven teams");
const alreadyPlayed = previousMatches.reduce((acc, cur) => {
if (!cur.opponent1?.id || !cur.opponent2?.id) return acc;
if (!acc.has(cur.opponent1.id)) {
acc.set(cur.opponent1.id, new Set());
}
acc.get(cur.opponent1.id)!.add(cur.opponent2.id);
if (!acc.has(cur.opponent2.id)) {
acc.set(cur.opponent2.id, new Set());
}
acc.get(cur.opponent2.id)!.add(cur.opponent1.id);
return acc;
}, new Map<number, Set<number>>());
const possibleRounds = makeRounds(teams.length);
for (const round of possibleRounds) {
let allNew = true;
for (const pair of round) {
const one = teams[pair[0]];
const two = teams[pair[1]];
if (alreadyPlayed.get(one.team.id)?.has(two.team.id)) {
allNew = false;
break;
}
}
if (!allNew) continue;
const matches: [opponentOneId: number, opponentTwoId: number][] = [];
for (const pair of round) {
const one = teams[pair[0]];
const two = teams[pair[1]];
matches.push([one.team.id, two.team.id]);
}
return matches;
}
return null;
}
// https://stackoverflow.com/a/75330079
function makeRounds(n: number) {
const pairings = [];
const max = n - 1;
for (let i = 0; i < max; i++) {
const pairing = [[max, i]];
for (let k = 1; k < n / 2; k++) {
pairing.push([(i + k) % max, (max + i - k) % max]);
}
pairings.push(pairing);
}
return pairings;
}

File diff suppressed because it is too large Load Diff

View File

@ -45,7 +45,7 @@ export default function TournamentAdminPage() {
setEditingProgression(false);
}, [tournament]);
if (!tournament.isOrganizer(user) || tournament.everyBracketOver) {
if (!tournament.isOrganizer(user) || tournament.ctx.isFinalized) {
return <Redirect to={tournamentPage(tournament.ctx.id)} />;
}

View File

@ -213,7 +213,7 @@ export function TournamentLayout() {
!tournament.isLeagueSignup && (
<SubNavLink to="seeds">{t("tournament:tabs.seeds")}</SubNavLink>
)}
{tournament.isOrganizer(user) && !tournament.everyBracketOver && (
{tournament.isOrganizer(user) && !tournament.ctx.isFinalized && (
<SubNavLink to="admin" data-testid="admin-tab">
{t("tournament:tabs.admin")}
</SubNavLink>

7
package-lock.json generated
View File

@ -26,6 +26,7 @@
"clsx": "^2.1.1",
"compressorjs": "^1.2.1",
"date-fns": "^4.1.0",
"edmonds-blossom-fixed": "^1.0.1",
"gray-matter": "^4.0.3",
"i18next": "^23.16.8",
"i18next-browser-languagedetector": "^8.0.5",
@ -9612,6 +9613,12 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/edmonds-blossom-fixed": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/edmonds-blossom-fixed/-/edmonds-blossom-fixed-1.0.1.tgz",
"integrity": "sha512-wtpraSt4yJeUpNU8RGC4q2JBxsJbHFxI7/htm/mS4FgxSN90WBwmgE7QZwpcY70KJRqmfLCNxU49UIc+DfzLKQ==",
"license": "MIT"
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",

View File

@ -46,6 +46,7 @@
"clsx": "^2.1.1",
"compressorjs": "^1.2.1",
"date-fns": "^4.1.0",
"edmonds-blossom-fixed": "^1.0.1",
"gray-matter": "^4.0.3",
"i18next": "^23.16.8",
"i18next-browser-languagedetector": "^8.0.5",

View File

@ -14,5 +14,8 @@ sql
`delete from "TournamentResult" where "TournamentResult"."tournamentId" = @id`,
)
.run({ id });
sql
.prepare(`update "Tournament" set "isFinalized" = 0 where "id" = @id`)
.run({ id });
logger.info(`Reopened tournament with id ${id}`);

1
types/edmonds-blossom.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module "edmonds-blossom-fixed";