diff --git a/app/core/tournament/bracket.test.ts b/app/core/tournament/bracket.test.ts index 3b9ec52b7..16f3a52f8 100644 --- a/app/core/tournament/bracket.test.ts +++ b/app/core/tournament/bracket.test.ts @@ -1,10 +1,18 @@ +import { Stage } from ".prisma/client"; import { suite } from "uvu"; import * as assert from "uvu/assert"; import { eliminationBracket } from "./algorithms"; -import { countRounds, getRoundNames } from "./bracket"; +import { + countRounds, + getRoundNames, + getRoundsDefaultBestOf, + tournamentRoundsForDB, +} from "./bracket"; +import { generateMapListForRounds } from "./mapList"; const CountBracketRounds = suite("countRounds()"); const RoundNames = suite("getRoundNames()"); +const TournamentRoundsForDB = suite("tournamentRoundsForDB()"); CountBracketRounds("Counts bracket (DE - 38)", () => { const bracket = eliminationBracket(38, "DE"); @@ -51,5 +59,53 @@ RoundNames("No bracket reset round for SE", () => { assert.not.ok(hasBR); }); +const mapPool: Stage[] = JSON.parse( + `[{"id":923,"mode":"TC","name":"The Reef"},{"id":925,"mode":"CB","name":"The Reef"},{"id":927,"mode":"SZ","name":"Musselforge Fitness"},{"id":929,"mode":"RM","name":"Musselforge Fitness"},{"id":934,"mode":"RM","name":"Starfish Mainstage"},{"id":942,"mode":"SZ","name":"Inkblot Art Academy"},{"id":943,"mode":"TC","name":"Inkblot Art Academy"},{"id":947,"mode":"SZ","name":"Sturgeon Shipyard"},{"id":948,"mode":"TC","name":"Sturgeon Shipyard"},{"id":953,"mode":"TC","name":"Moray Towers"},{"id":959,"mode":"RM","name":"Port Mackerel"},{"id":960,"mode":"CB","name":"Port Mackerel"},{"id":972,"mode":"SZ","name":"Snapper Canal"},{"id":978,"mode":"TC","name":"Blackbelly Skatepark"},{"id":980,"mode":"CB","name":"Blackbelly Skatepark"},{"id":985,"mode":"CB","name":"MakoMart"},{"id":987,"mode":"SZ","name":"Walleye Warehouse"},{"id":988,"mode":"TC","name":"Walleye Warehouse"},{"id":994,"mode":"RM","name":"Shellendorf Institute"},{"id":995,"mode":"CB","name":"Shellendorf Institute"},{"id":1007,"mode":"SZ","name":"Piranha Pit"},{"id":1012,"mode":"SZ","name":"Camp Triggerfish"},{"id":1019,"mode":"RM","name":"Wahoo World"},{"id":1020,"mode":"CB","name":"Wahoo World"},{"id":1027,"mode":"SZ","name":"Ancho-V Games"},{"id":1034,"mode":"RM","name":"Skipper Pavilion"}]` +); +const bracket = eliminationBracket(24, "DE"); +const rounds = getRoundsDefaultBestOf(bracket); +const mapList = generateMapListForRounds({ mapPool, rounds }); + +TournamentRoundsForDB("Generates rounds correctly", () => { + const TEAM_COUNT = 24; + const bracketForDb = tournamentRoundsForDB({ + mapList, + bracketType: "DE", + participantsSeeded: new Array(TEAM_COUNT) + .fill(null) + .map((_, i) => i + 1) + .map(String) + .map((id) => ({ id })), + }); + const roundsCounted = countRounds(bracket); + let max = -Infinity; + let min = Infinity; + let uniqueParticipants = new Set(); + + for (const round of bracketForDb) { + max = Math.max(max, round.position); + min = Math.min(min, round.position); + + for (const match of round.matches) { + for (const participant of match.participants) { + if (round.position !== 1) { + throw new Error("Participant found not first round"); + } + if (typeof participant.team === "string") { + uniqueParticipants.add(participant.team); + continue; + } + assert.not.ok(uniqueParticipants.has(participant.team.id)); + uniqueParticipants.add(participant.team.id); + } + } + } + + assert.equal(max, roundsCounted.winners); + assert.equal(min, -roundsCounted.losers); + assert.equal(uniqueParticipants.size, TEAM_COUNT + 1); // + BYE +}); + CountBracketRounds.run(); RoundNames.run(); +TournamentRoundsForDB.run(); diff --git a/app/core/tournament/bracket.ts b/app/core/tournament/bracket.ts index 294e5b89d..065e38a43 100644 --- a/app/core/tournament/bracket.ts +++ b/app/core/tournament/bracket.ts @@ -1,10 +1,9 @@ import type { BracketType, Stage, TeamOrder } from ".prisma/client"; import invariant from "tiny-invariant"; -import { generateMapListForRounds } from "./mapList"; -import { Bracket, eliminationBracket } from "./algorithms"; -import type { UseTournamentRoundsState } from "../../hooks/useTournamentRounds/types"; -import { FindTournamentByNameForUrlI } from "../../services/tournament"; import { TOURNAMENT_TEAM_ROSTER_MIN_SIZE } from "../../constants"; +import { FindTournamentByNameForUrlI } from "../../services/tournament"; +import { Bracket, eliminationBracket, Match } from "./algorithms"; +import { generateMapListForRounds } from "./mapList"; export function participantCountToRoundsInfo({ bracket, @@ -194,40 +193,115 @@ interface TournamentRoundForDB { winnerDestinationMatchId?: string; loserDestinationMatchId?: string; participants: { - teamId: string; + team: { id: string } | "BYE"; order: TeamOrder; - }; + }[]; }[]; } export function tournamentRoundsForDB({ mapList, - participantCount, bracketType, + participantsSeeded, }: { - mapList: UseTournamentRoundsState["bracket"]; - participantCount: number; + mapList: EliminationBracket; bracketType: BracketType; + participantsSeeded: { id: string }[]; }): TournamentRoundForDB[] { - const rounds = eliminationBracket(participantCount, bracketType); + const bracket = eliminationBracket(participantsSeeded.length, bracketType); const result: TournamentRoundForDB[] = []; - for (const [i, side] of [rounds.winners, rounds.losers].entries()) { - const isWinners = i === 0; + const groupedRounds = groupMatchesByRound(bracket); + + for (const [sideI, side] of [ + groupedRounds.winners, + groupedRounds.losers, + ].entries()) { + const isWinners = sideI === 0; for (const [roundI, round] of side.entries()) { const position = isWinners ? roundI + 1 : -(roundI + 1); const stagesRaw = mapList[isWinners ? "winners" : "losers"][roundI]; invariant(stagesRaw, "stagesRaw is undefined"); - const stages = stagesRaw.mapList.map((stage, i) => ({ + const stages = stagesRaw.map((stage, i) => ({ position: i + 1, stageId: stage.id, })); + + const matches = round.map((match) => { + return { + id: match.id, + winnerDestinationMatchId: match.winnerDestinationMatch?.id, + loserDestinationMatchId: match.loserDestinationMatch?.id, + participants: [match.upperTeam, match.lowerTeam].flatMap( + (team, i) => { + if (!team) return []; + const teamOrBye = + team === "BYE" + ? team + : { id: participantsSeeded[team - 1]?.id }; + invariant( + typeof teamOrBye === "string" || teamOrBye?.id, + `teamId is undefined - participantsSeeded: ${participantsSeeded.join( + "," + )}; team: ${team}` + ); + + return { + team: teamOrBye, + order: i === 0 ? "UPPER" : ("LOWER" as TeamOrder), + }; + } + ), + }; + }); + + result.push({ + position, + stages, + matches, + }); } } return result; } +function groupMatchesByRound(bracket: Bracket): EliminationBracket { + const { winners, losers } = countRounds(bracket); + + const result: EliminationBracket = { + winners: new Array(winners).fill(null).map(() => []), + losers: new Array(losers).fill(null).map(() => []), + }; + const matchesIncluded = new Set(); + for (const match of bracket.winners) { + // first round match + if (match.upperTeam && match.lowerTeam) { + search(match, "winners", 1); + search(match.loserDestinationMatch, "losers", 1); + } + } + + invariant( + matchesIncluded.size === bracket.winners.length + bracket.losers.length, + `matchesIncluded: ${matchesIncluded.size}; winners: ${bracket.winners.length}; losers: ${bracket.losers.length}` + ); + return result; + + function search( + match: Match | undefined, + side: EliminationBracketSide, + depth: number + ) { + if (!match) return; + if (matchesIncluded.has(match.id)) return; + + search(match.winnerDestinationMatch, side, depth + 1); + matchesIncluded.add(match.id); + result[side][depth - 1]?.push(match); + } +} + export type EliminationBracket = { winners: T; losers: T; diff --git a/app/routes/to/$organization.$tournament/start.tsx b/app/routes/to/$organization.$tournament/start.tsx index a20d08bbf..96919eb87 100644 --- a/app/routes/to/$organization.$tournament/start.tsx +++ b/app/routes/to/$organization.$tournament/start.tsx @@ -23,6 +23,7 @@ import { findTournamentByNameForUrl, } from "~/services/tournament"; import startBracketTabStylesUrl from "~/styles/tournament-start.css"; +import { requireUser } from "~/utils"; // TODO: error if not admin AND keep the links available @@ -30,11 +31,11 @@ export const links: LinksFunction = () => { return [{ rel: "stylesheet", href: startBracketTabStylesUrl }]; }; -export const action: ActionFunction = async ({ request, params }) => { - const mapPoolString = (await request.formData()).get("map-pool"); - invariant(typeof mapPoolString === "string", "Type of map pool not string"); - const mapPool = JSON.parse( - mapPoolString +export const action: ActionFunction = async ({ request, params, context }) => { + const mapListString = (await request.formData()).get("map-list"); + invariant(typeof mapListString === "string", "Type of map list not string"); + const mapList = JSON.parse( + mapListString ) as UseTournamentRoundsState["bracket"]; const organizationNameForUrl = params.organization; @@ -42,12 +43,17 @@ export const action: ActionFunction = async ({ request, params }) => { invariant(organizationNameForUrl, "organizationNameForUrl is undefined"); invariant(tournamentNameForUrl, "tournamentNameForUrl is undefined"); + const user = requireUser(context); + await createTournamentRounds({ - mapPool, + mapList, organizationNameForUrl, tournamentNameForUrl, + userId: user.id, }); + return null; + return redirect( `/to/${organizationNameForUrl}/${tournamentNameForUrl}/bracket` ); @@ -94,7 +100,7 @@ export default function StartBracketTab() { return (
- + (