Elim bracket algorithm

This commit is contained in:
Kalle (Sendou)
2021-12-12 20:23:10 +02:00
parent 224817f9e8
commit f8437e3975
2 changed files with 325 additions and 0 deletions

View File

@@ -0,0 +1,123 @@
import { suite } from "uvu";
import * as assert from "uvu/assert";
import {
createEliminationBracket,
fillParticipantsWithNullTillPowerOfTwo,
Match,
TeamIdentifier,
} from "./bracket";
const AmountOfTeams = suite("Amount of teams");
const Byes = suite("Byes");
const Seeds = suite("Seeds");
const FillParticipantsWithNull = suite(
"fillParticipantsWithNullTillPowerOfTwo()"
);
AmountOfTeams("Generates right amount of rounds (16 participants - SE)", () => {
const bracket16 = createEliminationBracket(16, "SE");
assert.equal(removeMatchesWithByes(bracket16.winners).length, 15);
assert.equal(removeMatchesWithByes(bracket16.losers).length, 0);
});
AmountOfTeams("Generates right amount of rounds (16 participants - DE)", () => {
const bracket16 = createEliminationBracket(16, "DE");
assert.equal(removeMatchesWithByes(bracket16.winners).length, 16); // not incl reset
assert.equal(removeMatchesWithByes(bracket16.losers).length, 14);
});
AmountOfTeams("Generates right amount of rounds (15 participants - DE)", () => {
const bracket15 = createEliminationBracket(15, "DE");
assert.equal(removeMatchesWithByes(bracket15.winners).length, 15); // not incl reset
assert.equal(removeMatchesWithByes(bracket15.losers).length, 14); // one bye
});
AmountOfTeams("Generates right amount of rounds (17 participants - DE)", () => {
const bracket17 = createEliminationBracket(17, "DE");
assert.equal(removeMatchesWithByes(bracket17.winners).length, 17); // not incl reset
assert.equal(removeMatchesWithByes(bracket17.losers).length, 30);
assert.equal(removeMatchesWithByes(bracket17.winners).length, 17); // not incl reset
assert.equal(removeMatchesWithByes(bracket17.losers).length, 30);
});
AmountOfTeams("Same amount of rounds as next power of two", () => {
const bracket17 = createEliminationBracket(17, "DE");
const bracket32 = createEliminationBracket(32, "DE");
assert.equal(bracket17.winners.length, bracket32.winners.length); // not incl reset
assert.equal(bracket17.losers.length, bracket32.losers.length);
});
Byes("Right amount of byes", () => {
const bracket17 = createEliminationBracket(17, "DE");
assert.equal(countOpponentsWithByes(bracket17.winners), 15);
});
Byes("Correct team has bye", () => {
const bracket15 = createEliminationBracket(15, "DE");
assert.equal(teamWithBye(bracket15.winners), 1);
});
Seeds("First and second seed are spread apart", () => {
const bracket16 = createEliminationBracket(16, "DE");
assert.ok(
[bracket16.winners[0].upperTeam, bracket16.winners[0].lowerTeam].includes(
1
) ||
[bracket16.winners[0].upperTeam, bracket16.winners[0].lowerTeam].includes(
2
)
);
let lastMatchWithATeam = bracket16.winners[0];
for (const match of bracket16.winners) {
if (!match.upperTeam) break;
lastMatchWithATeam = match;
}
assert.ok(
[lastMatchWithATeam.upperTeam, lastMatchWithATeam.lowerTeam].includes(1) ||
[lastMatchWithATeam.upperTeam, lastMatchWithATeam.lowerTeam].includes(2)
);
});
FillParticipantsWithNull("17", () => {
const participants: TeamIdentifier[] = new Array(17)
.fill(null)
.map((_, i) => i + 1);
assert.equal(participants.length, 17);
fillParticipantsWithNullTillPowerOfTwo(participants);
assert.equal(participants.length, 32);
assert.equal(
participants.reduce((acc: number, cur) => acc + (cur === "BYE" ? 1 : 0), 0),
32 - 17
);
});
function countOpponentsWithByes(matches: Match[]) {
const byes = matches.filter(
(match) => match.upperTeam === "BYE" || match.lowerTeam === "BYE"
);
return byes.length;
}
function removeMatchesWithByes(matches: Match[]) {
return matches.filter(
(match) => match.upperTeam !== "BYE" && match.lowerTeam !== "BYE"
);
}
function teamWithBye(matches: Match[]) {
for (const match of matches) {
if (match.lowerTeam === "BYE") return match.upperTeam;
if (match.upperTeam === "BYE") return match.lowerTeam;
}
throw new Error("No team with a BYE");
}
AmountOfTeams.run();
Byes.run();
Seeds.run();
FillParticipantsWithNull.run();

View File

@@ -0,0 +1,202 @@
import invariant from "tiny-invariant";
export type TeamIdentifier = number | "BYE";
export interface Match {
id: string;
upperTeam?: TeamIdentifier;
lowerTeam?: TeamIdentifier;
winner?: TeamIdentifier;
match1?: Match;
match2?: Match;
}
interface Bracket {
winners: Match[];
losers: Match[];
}
/** @link https://stackoverflow.com/a/59615574 */
export function createEliminationBracket(
participantCount: number,
type: "SE" | "DE"
) {
let participants: TeamIdentifier[] = new Array(participantCount)
.fill(null)
.map((_, i) => i + 1);
fillParticipantsWithNullTillPowerOfTwo(participants);
const matchesWQueue: Match[] = [];
const matchesLQueue: Match[] = [];
const backfillQ: Match[] = [];
const bracket: Bracket = {
winners: [],
losers: [],
};
const bracketSize = participants.length;
const seedList = seeds(bracketSize);
const seedTuples: [TeamIdentifier, number][] = participants.map((p, i) => [
p,
i + 1,
]);
participants = seedTuples
.sort(([_a, ai], [_b, bi]) => seedList.indexOf(ai) - seedList.indexOf(bi))
.map(([p]) => p);
// First round
for (let i = 1; i <= bracketSize / 2; i++) {
// TODO: respect seed
const upperTeam = participants.pop();
const lowerTeam = participants.pop();
invariant(
typeof upperTeam !== "undefined",
"Unexpected team1 is undefined in first round"
);
invariant(
typeof lowerTeam !== "undefined",
"Unexpected team1 is undefined in first round"
);
const firstRoundMatch = createMatch({
upperTeam,
lowerTeam,
});
matchesWQueue.push(firstRoundMatch);
matchesLQueue.push(firstRoundMatch);
bracket.winners.push(firstRoundMatch);
}
// Generate winners bracket matches
while (matchesWQueue.length > 1) {
const match1 = matchesWQueue.shift();
const match2 = matchesWQueue.shift();
invariant(match1, "Unexpected no match1 in winners bracket");
invariant(match2, "Unexpected no match2 in winners bracket");
const winnersBracketMatch = createMatch({
match1,
match2,
});
matchesWQueue.push(winnersBracketMatch);
bracket.winners.push(winnersBracketMatch);
// add match to backfill for Lower Queue
backfillQ.push(winnersBracketMatch);
}
if (type === "SE") return bracket;
let roundSwitch = bracketSize / 2;
let switcher = false;
let counter = 0;
let switchedCounter = 0;
// Generate losers bracket matches
while (matchesLQueue.length > 0 && backfillQ.length > 0) {
let match1: Match | undefined;
let match2: Match | undefined;
if (switcher) {
match1 = matchesLQueue.shift();
match2 = backfillQ.shift();
switchedCounter += 2;
if (switchedCounter === roundSwitch) {
// switch back
roundSwitch /= 2;
switcher = false;
// reset counters
switchedCounter = 0;
}
} else {
match1 = matchesLQueue.shift();
match2 = matchesLQueue.shift();
counter += 2;
if (counter === roundSwitch) {
switcher = true;
counter = 0;
}
}
invariant(match1, "Unexpected no match1 in losers bracket");
invariant(match2, "Unexpected no match2 in losers bracket");
const losersMatch = createMatch({
match1,
match2,
});
matchesLQueue.push(losersMatch);
bracket.losers.push(losersMatch);
}
const match1 = matchesWQueue.shift();
const match2 = matchesLQueue.shift();
invariant(match1, "Unexpected no match1 in final match");
invariant(match2, "Unexpected no match2 in final match");
// Add final match
bracket.winners.push(
createMatch({
match1,
match2,
})
);
return bracket;
}
export function fillParticipantsWithNullTillPowerOfTwo(
participants: TeamIdentifier[]
) {
while (!powerOf2(participants.length)) {
participants.push("BYE");
}
}
/** @link https://stackoverflow.com/a/30924333 */
function powerOf2(v: number) {
return v && !(v & (v - 1));
}
function seeds(numberOfTeamsWithByes: number) {
const result: number[] = [];
const limit = getBaseLog(2, numberOfTeamsWithByes) + 1;
invariant(Number.isInteger(limit), "Unexpected limit is not an integer");
branch(1, 1, limit);
/** @link https://stackoverflow.com/a/41647548 */
function branch(seed: number, level: number, limit: number) {
const levelSum = Math.pow(2, level) + 1;
if (limit === level + 1) {
result.push(seed);
result.push(levelSum - seed);
return;
} else if (seed % 2 === 1) {
branch(seed, level + 1, limit);
branch(levelSum - seed, level + 1, limit);
} else {
branch(levelSum - seed, level + 1, limit);
branch(seed, level + 1, limit);
}
}
return result;
}
function getBaseLog(x: number, y: number) {
return Math.log(y) / Math.log(x);
}
function createMatch(args: Omit<Match, "id">): Match {
return {
// TODO: crypto.uuid
id: Math.random().toString(),
...args,
};
}