mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Fix swiss pairing algorithm (#2446)
This commit is contained in:
parent
621d484363
commit
d7d10fbd78
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
|
@ -2,6 +2,6 @@
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.organizeImports.biome": "explicit",
|
"source.organizeImports.biome": "explicit",
|
||||||
"source.fixAll.biome": "explicit"
|
"source.fixAll.biome": "never"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,8 @@ export function PlacementsTable({
|
||||||
bracket.tournament.ctx.settings.bracketProgression,
|
bracket.tournament.ctx.settings.bracketProgression,
|
||||||
).map((idx) => bracket.tournament.bracketByIdx(idx)!);
|
).map((idx) => bracket.tournament.bracketByIdx(idx)!);
|
||||||
const canEditDestination = (() => {
|
const canEditDestination = (() => {
|
||||||
|
if (possibleDestinationBrackets.length === 0) return false;
|
||||||
|
|
||||||
const allDestinationsPreview = possibleDestinationBrackets.every(
|
const allDestinationsPreview = possibleDestinationBrackets.every(
|
||||||
(b) => b.preview,
|
(b) => b.preview,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import { describe, expect, it } from "vitest";
|
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";
|
import * as Swiss from "./Swiss";
|
||||||
|
|
||||||
describe("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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
// separate from brackets-manager as this wasn't part of the original brackets-manager library
|
// 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 type { TournamentRepositoryInsertableMatch } from "~/features/tournament/TournamentRepository.server";
|
||||||
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
|
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
|
||||||
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
|
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
|
||||||
import type { InputStage, Match } from "~/modules/brackets-model";
|
import type { InputStage, Match } from "~/modules/brackets-model";
|
||||||
import { nullFilledArray } from "~/utils/arrays";
|
import { nullFilledArray } from "~/utils/arrays";
|
||||||
import invariant from "~/utils/invariant";
|
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.
|
* 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.
|
* 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.
|
* 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.
|
* Matches are generated such that teams do not replay previous opponents if possible.
|
||||||
*/
|
*/
|
||||||
export function generateMatchUps({
|
export function generateMatchUps({
|
||||||
|
|
@ -158,7 +173,7 @@ export function generateMatchUps({
|
||||||
}: {
|
}: {
|
||||||
bracket: Bracket;
|
bracket: Bracket;
|
||||||
groupId: number;
|
groupId: number;
|
||||||
}) {
|
}): Array<TournamentRepositoryInsertableMatch> {
|
||||||
// lets consider only this groups matches
|
// lets consider only this groups matches
|
||||||
// in the case that there are more than one group
|
// in the case that there are more than one group
|
||||||
const groupsMatches = bracket.data.match.filter(
|
const groupsMatches = bracket.data.match.filter(
|
||||||
|
|
@ -184,67 +199,28 @@ export function generateMatchUps({
|
||||||
(s) => !s.team.droppedOut,
|
(s) => !s.team.droppedOut,
|
||||||
);
|
);
|
||||||
|
|
||||||
// if group has uneven number of teams
|
const teamsThatHaveHadByes = groupsMatches
|
||||||
// the lowest standing team gets a bye
|
.filter((m) => m.opponent2 === null)
|
||||||
// that did not already receive one
|
.map((m) => m.opponent1?.id);
|
||||||
const { bye, play } = splitToByeAndPlay(
|
|
||||||
standingsWithoutDropouts,
|
const pairs = pairUp(
|
||||||
groupsMatches,
|
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
|
let matchNumber = 1;
|
||||||
// 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
|
|
||||||
const newRoundId = bracket.data.round
|
const newRoundId = bracket.data.round
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.id - b.id)
|
.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)),
|
(r) => r.id > Math.max(...groupsMatches.map((match) => match.round_id)),
|
||||||
)?.id;
|
)?.id;
|
||||||
invariant(newRoundId, "newRoundId not found");
|
invariant(newRoundId, "newRoundId not found");
|
||||||
let matchNumber = 1;
|
const result: TournamentRepositoryInsertableMatch[] = pairs.map(
|
||||||
const result: TournamentRepositoryInsertableMatch[] = matches.map(
|
({ opponentOne, opponentTwo }) => ({
|
||||||
([opponentOneId, opponentTwoId]) => ({
|
|
||||||
groupId,
|
groupId,
|
||||||
number: matchNumber++,
|
number: matchNumber++,
|
||||||
roundId: newRoundId,
|
roundId: newRoundId,
|
||||||
stageId: groupsMatches[0].stage_id,
|
stageId: groupsMatches[0].stage_id,
|
||||||
opponentOne: JSON.stringify({
|
opponentOne: JSON.stringify({
|
||||||
id: opponentOneId,
|
id: opponentOne,
|
||||||
}),
|
|
||||||
opponentTwo: JSON.stringify({
|
|
||||||
id: opponentTwoId,
|
|
||||||
}),
|
}),
|
||||||
|
opponentTwo:
|
||||||
|
typeof opponentTwo === "number"
|
||||||
|
? JSON.stringify({
|
||||||
|
id: opponentTwo,
|
||||||
|
})
|
||||||
|
: JSON.stringify(null),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (bye) {
|
return result;
|
||||||
result.push({
|
}
|
||||||
groupId,
|
|
||||||
stageId: groupsMatches[0].stage_id,
|
interface SwissPairingTeam {
|
||||||
roundId: newRoundId,
|
id: number;
|
||||||
number: matchNumber,
|
/** How many matches has the team won */
|
||||||
opponentOne: JSON.stringify({
|
score: number;
|
||||||
id: bye.team.id,
|
/** 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;
|
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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
1093
app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts
Normal file
1093
app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -45,7 +45,7 @@ export default function TournamentAdminPage() {
|
||||||
setEditingProgression(false);
|
setEditingProgression(false);
|
||||||
}, [tournament]);
|
}, [tournament]);
|
||||||
|
|
||||||
if (!tournament.isOrganizer(user) || tournament.everyBracketOver) {
|
if (!tournament.isOrganizer(user) || tournament.ctx.isFinalized) {
|
||||||
return <Redirect to={tournamentPage(tournament.ctx.id)} />;
|
return <Redirect to={tournamentPage(tournament.ctx.id)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -213,7 +213,7 @@ export function TournamentLayout() {
|
||||||
!tournament.isLeagueSignup && (
|
!tournament.isLeagueSignup && (
|
||||||
<SubNavLink to="seeds">{t("tournament:tabs.seeds")}</SubNavLink>
|
<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">
|
<SubNavLink to="admin" data-testid="admin-tab">
|
||||||
{t("tournament:tabs.admin")}
|
{t("tournament:tabs.admin")}
|
||||||
</SubNavLink>
|
</SubNavLink>
|
||||||
|
|
|
||||||
7
package-lock.json
generated
7
package-lock.json
generated
|
|
@ -26,6 +26,7 @@
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"compressorjs": "^1.2.1",
|
"compressorjs": "^1.2.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"edmonds-blossom-fixed": "^1.0.1",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"i18next": "^23.16.8",
|
"i18next": "^23.16.8",
|
||||||
"i18next-browser-languagedetector": "^8.0.5",
|
"i18next-browser-languagedetector": "^8.0.5",
|
||||||
|
|
@ -9612,6 +9613,12 @@
|
||||||
"safe-buffer": "^5.0.1"
|
"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": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"compressorjs": "^1.2.1",
|
"compressorjs": "^1.2.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"edmonds-blossom-fixed": "^1.0.1",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"i18next": "^23.16.8",
|
"i18next": "^23.16.8",
|
||||||
"i18next-browser-languagedetector": "^8.0.5",
|
"i18next-browser-languagedetector": "^8.0.5",
|
||||||
|
|
|
||||||
|
|
@ -14,5 +14,8 @@ sql
|
||||||
`delete from "TournamentResult" where "TournamentResult"."tournamentId" = @id`,
|
`delete from "TournamentResult" where "TournamentResult"."tournamentId" = @id`,
|
||||||
)
|
)
|
||||||
.run({ id });
|
.run({ id });
|
||||||
|
sql
|
||||||
|
.prepare(`update "Tournament" set "isFinalized" = 0 where "id" = @id`)
|
||||||
|
.run({ id });
|
||||||
|
|
||||||
logger.info(`Reopened tournament with id ${id}`);
|
logger.info(`Reopened tournament with id ${id}`);
|
||||||
|
|
|
||||||
1
types/edmonds-blossom.d.ts
vendored
Normal file
1
types/edmonds-blossom.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
declare module "edmonds-blossom-fixed";
|
||||||
Loading…
Reference in New Issue
Block a user