From a4dec62b61e3faa5a1d7ac896348e193203136bd Mon Sep 17 00:00:00 2001 From: "Kalle (Sendou)" <38327916+Sendouc@users.noreply.github.com> Date: Mon, 13 Dec 2021 10:22:31 +0200 Subject: [PATCH] Count rounds algorithm --- .../ActionSectionBeforeStartContent.tsx | 2 + app/core/tournament/bracket.test.ts | 20 +++--- app/core/tournament/bracket.ts | 23 +++++-- app/core/tournament/utils.test.ts | 42 +++++++++--- app/core/tournament/utils.ts | 65 +++++++++++++++++-- 5 files changed, 124 insertions(+), 28 deletions(-) diff --git a/app/components/tournament/ActionSectionBeforeStartContent.tsx b/app/components/tournament/ActionSectionBeforeStartContent.tsx index a81ebbcc1..5f7d75a01 100644 --- a/app/components/tournament/ActionSectionBeforeStartContent.tsx +++ b/app/components/tournament/ActionSectionBeforeStartContent.tsx @@ -1,3 +1,5 @@ +// TODO: Warning: Text content did not match. Server: "57" Client: "56" + import * as React from "react"; import { useFetcher, useLoaderData } from "remix"; import { diff --git a/app/core/tournament/bracket.test.ts b/app/core/tournament/bracket.test.ts index c7d0ffe79..dff16ef2c 100644 --- a/app/core/tournament/bracket.test.ts +++ b/app/core/tournament/bracket.test.ts @@ -1,7 +1,7 @@ import { suite } from "uvu"; import * as assert from "uvu/assert"; import { - createEliminationBracket, + eliminationBracket, fillParticipantsWithNullTillPowerOfTwo, Match, TeamIdentifier, @@ -15,25 +15,25 @@ const FillParticipantsWithNull = suite( ); AmountOfTeams("Generates right amount of rounds (16 participants - SE)", () => { - const bracket16 = createEliminationBracket(16, "SE"); + const bracket16 = eliminationBracket(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"); + const bracket16 = eliminationBracket(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"); + const bracket15 = eliminationBracket(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"); + const bracket17 = eliminationBracket(17, "DE"); assert.equal(removeMatchesWithByes(bracket17.winners).length, 17); // not incl reset assert.equal(removeMatchesWithByes(bracket17.losers).length, 30); @@ -42,24 +42,24 @@ AmountOfTeams("Generates right amount of rounds (17 participants - DE)", () => { }); AmountOfTeams("Same amount of rounds as next power of two", () => { - const bracket17 = createEliminationBracket(17, "DE"); - const bracket32 = createEliminationBracket(32, "DE"); + const bracket17 = eliminationBracket(17, "DE"); + const bracket32 = eliminationBracket(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"); + const bracket17 = eliminationBracket(17, "DE"); assert.equal(countOpponentsWithByes(bracket17.winners), 15); }); Byes("Correct team has bye", () => { - const bracket15 = createEliminationBracket(15, "DE"); + const bracket15 = eliminationBracket(15, "DE"); assert.equal(teamWithBye(bracket15.winners), 1); }); Seeds("First and second seed are spread apart", () => { - const bracket16 = createEliminationBracket(16, "DE"); + const bracket16 = eliminationBracket(16, "DE"); assert.ok( [bracket16.winners[0].upperTeam, bracket16.winners[0].lowerTeam].includes( 1 diff --git a/app/core/tournament/bracket.ts b/app/core/tournament/bracket.ts index 9c2c62eaa..04d3498b6 100644 --- a/app/core/tournament/bracket.ts +++ b/app/core/tournament/bracket.ts @@ -7,17 +7,22 @@ export interface Match { upperTeam?: TeamIdentifier; lowerTeam?: TeamIdentifier; winner?: TeamIdentifier; + /** Match that leads to this match */ match1?: Match; + /** Match that leads to this match */ match2?: Match; } -interface Bracket { +export interface Bracket { winners: Match[]; losers: Match[]; + participantCount: number; + participantsWithByesCount: number; } -/** @link https://stackoverflow.com/a/59615574 */ -export function createEliminationBracket( +/** Singe/Double Elimination bracket algorithm that handles byes + * @link https://stackoverflow.com/a/59615574 */ +export function eliminationBracket( participantCount: number, type: "SE" | "DE" ) { @@ -30,9 +35,16 @@ export function createEliminationBracket( const matchesWQueue: Match[] = []; const matchesLQueue: Match[] = []; const backfillQ: Match[] = []; + + invariant( + powerOf2(participants.length), + "Unexpected participants length not power of two" + ); const bracket: Bracket = { winners: [], losers: [], + participantCount, + participantsWithByesCount: participants.length, }; const bracketSize = participants.length; @@ -47,7 +59,6 @@ export function createEliminationBracket( // First round for (let i = 1; i <= bracketSize / 2; i++) { - // TODO: respect seed const upperTeam = participants.pop(); const lowerTeam = participants.pop(); invariant( @@ -58,6 +69,10 @@ export function createEliminationBracket( typeof lowerTeam !== "undefined", "Unexpected team1 is undefined in first round" ); + invariant( + !(upperTeam === "BYE" && lowerTeam === "BYE"), + "Unexpected both teams in the first round are BYEs" + ); const firstRoundMatch = createMatch({ upperTeam, lowerTeam, diff --git a/app/core/tournament/utils.test.ts b/app/core/tournament/utils.test.ts index 57716c5ba..e2a4717b6 100644 --- a/app/core/tournament/utils.test.ts +++ b/app/core/tournament/utils.test.ts @@ -1,8 +1,12 @@ -import { test } from "uvu"; +import { suite } from "uvu"; import * as assert from "uvu/assert"; -import { sortTeamsBySeed } from "./utils"; +import { eliminationBracket } from "./bracket"; +import { countRounds, sortTeamsBySeed } from "./utils"; -test("Sorts teams by seed", () => { +const SortTeams = suite("sortTeamsBySeed()"); +const CountBracketRounds = suite("countRounds()"); + +SortTeams("Sorts teams by seed", () => { const seeds = ["3", "2", "1"]; const teamsToSeed = [ { id: "1", createdAt: "1639036511550" }, @@ -17,7 +21,7 @@ test("Sorts teams by seed", () => { ]); }); -test("Sorts teams by createdAt", () => { +SortTeams("Sorts teams by createdAt", () => { const seeds: string[] = []; const teamsToSeed = [ { id: "1", createdAt: "1639036511550" }, @@ -32,7 +36,7 @@ test("Sorts teams by createdAt", () => { ]); }); -test("Sorts teams by seed and createdAt", () => { +SortTeams("Sorts teams by seed and createdAt", () => { const seeds: string[] = ["3"]; const teamsToSeed = [ { id: "1", createdAt: "1639036511550" }, @@ -47,7 +51,7 @@ test("Sorts teams by seed and createdAt", () => { ]); }); -test("Can handle non-existent id in seeds", () => { +SortTeams("Can handle non-existent id in seeds", () => { const seeds: string[] = ["4", "3", "2"]; const teamsToSeed = [ { id: "1", createdAt: "1639036511550" }, @@ -62,11 +66,33 @@ test("Can handle non-existent id in seeds", () => { ]); }); -test("Sorting works with empty arrays", () => { +SortTeams("Sorting works with empty arrays", () => { const seeds: string[] = []; const teamsToSeed: any = []; assert.equal(teamsToSeed.sort(sortTeamsBySeed(seeds)), []); }); -test.run(); +CountBracketRounds("Counts bracket (DE - 38)", () => { + const bracket = eliminationBracket(38, "DE"); + const count = countRounds(bracket); + + assert.equal(count, { winners: 7, losers: 9 }); +}); + +CountBracketRounds("Counts bracket (DE - 10)", () => { + const bracket = eliminationBracket(10, "DE"); + const count = countRounds(bracket); + + assert.equal(count, { winners: 5, losers: 5 }); +}); + +CountBracketRounds("Counts bracket (DE - 16)", () => { + const bracket = eliminationBracket(16, "DE"); + const count = countRounds(bracket); + + assert.equal(count, { winners: 5, losers: 6 }); +}); + +SortTeams.run(); +CountBracketRounds.run(); diff --git a/app/core/tournament/utils.ts b/app/core/tournament/utils.ts index d9a1fa5ff..53dc21493 100644 --- a/app/core/tournament/utils.ts +++ b/app/core/tournament/utils.ts @@ -1,12 +1,14 @@ -export const checkInHasStarted = (checkInStartTime: string) => - new Date(checkInStartTime) < new Date(); +import { Bracket } from "./bracket"; -export const sortTeamsBySeed = - (seeds: string[]) => - ( +export function checkInHasStarted(checkInStartTime: string) { + return new Date(checkInStartTime) < new Date(); +} + +export function sortTeamsBySeed(seeds: string[]) { + return function ( a: { id: string; createdAt: string | Date }, b: { id: string; createdAt: string | Date } - ) => { + ) { const aSeed = seeds.indexOf(a.id); const bSeed = seeds.indexOf(b.id); @@ -24,3 +26,54 @@ export const sortTeamsBySeed = // finally, consider the seeds return aSeed - bSeed; }; +} + +export function countRounds(bracket: Bracket): { + winners: number; + losers: number; +} { + let winners = 1; + + for (let i = bracket.participantsWithByesCount; i > 1; i /= 2) { + winners++; + } + + const losersMatchIds = new Set(bracket.losers.map((match) => match.id)); + let losers = 0; + let losersMatch = bracket.losers[bracket.losers.length - 1]; + + while (true) { + losers++; + const match1 = losersMatch.match1; + const match2 = losersMatch.match2; + if (match1 && losersMatchIds.has(match1.id)) { + losersMatch = match1; + continue; + } else if (match2 && losersMatchIds.has(match2.id)) { + losersMatch = match2; + continue; + } + + break; + } + + let matchesWithByes = 0; + let matchesWithOpponent = 0; + + for (const match of bracket.winners) { + if (!match.upperTeam) break; + if (match.upperTeam === "BYE" || match.lowerTeam === "BYE") { + matchesWithByes++; + continue; + } + + matchesWithOpponent++; + } + + // First round of losers is not played if certain amount of byes + if (matchesWithByes && matchesWithByes >= matchesWithOpponent) { + losers--; + } + + return { winners, losers }; +}