mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 09:54:36 -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.codeActionsOnSave": {
|
||||
"source.organizeImports.biome": "explicit",
|
||||
"source.fixAll.biome": "explicit"
|
||||
"source.fixAll.biome": "never"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
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);
|
||||
}, [tournament]);
|
||||
|
||||
if (!tournament.isOrganizer(user) || tournament.everyBracketOver) {
|
||||
if (!tournament.isOrganizer(user) || tournament.ctx.isFinalized) {
|
||||
return <Redirect to={tournamentPage(tournament.ctx.id)} />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
7
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
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