diff --git a/app/core/tournament/bracket.test.ts b/app/core/tournament/bracket.test.ts new file mode 100644 index 000000000..c7d0ffe79 --- /dev/null +++ b/app/core/tournament/bracket.test.ts @@ -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(); diff --git a/app/core/tournament/bracket.ts b/app/core/tournament/bracket.ts new file mode 100644 index 000000000..9c2c62eaa --- /dev/null +++ b/app/core/tournament/bracket.ts @@ -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 { + return { + // TODO: crypto.uuid + id: Math.random().toString(), + ...args, + }; +}