mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-11 13:46:10 -05:00
Elim bracket algorithm
This commit is contained in:
123
app/core/tournament/bracket.test.ts
Normal file
123
app/core/tournament/bracket.test.ts
Normal 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();
|
||||
202
app/core/tournament/bracket.ts
Normal file
202
app/core/tournament/bracket.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user