tournamentRoundsForDB() function implementation

This commit is contained in:
Kalle (Sendou)
2021-12-22 09:39:45 +02:00
parent a9afdb4bd3
commit ba4ee2c00b
4 changed files with 167 additions and 25 deletions

View File

@@ -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<string>();
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();

View File

@@ -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<Stage[][]>;
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<Match[][]> {
const { winners, losers } = countRounds(bracket);
const result: EliminationBracket<Match[][]> = {
winners: new Array(winners).fill(null).map(() => []),
losers: new Array(losers).fill(null).map(() => []),
};
const matchesIncluded = new Set<string>();
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<T> = {
winners: T;
losers: T;

View File

@@ -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 (
<Form method="post" className="width-100">
<input type="hidden" name="map-pool" value={JSON.stringify(bracket)} />
<input type="hidden" name="map-list" value={JSON.stringify(bracket)} />
<input
type="hidden"
name="tournament-id"
@@ -200,6 +206,7 @@ function RoundsCollection({
{([3, 5, 7, 9] as const).map((bestOf) => (
<button
key={bestOf}
type="button"
className={classNames("tournament__start__best-of", {
active: round.bestOf === bestOf,
})}

View File

@@ -5,12 +5,12 @@ import {
} from "~/constants";
import { isTournamentAdmin } from "~/core/tournament/permissions";
import { sortTeamsBySeed } from "~/core/tournament/utils";
import { Serialized, Unpacked } from "~/utils";
import type { UseTournamentRoundsState } from "~/hooks/useTournamentRounds/types";
import * as Tournament from "~/models/Tournament";
import * as TournamentTeam from "~/models/TournamentTeam";
import * as TournamentTeamMember from "~/models/TournamentTeamMember";
import * as TrustRelationship from "~/models/TrustRelationship";
import type { UseTournamentRoundsState } from "~/hooks/useTournamentRounds/types";
import { Serialized, Unpacked } from "~/utils";
export type FindTournamentByNameForUrlI = Serialized<
Prisma.PromiseReturnType<typeof findTournamentByNameForUrl>
@@ -124,11 +124,13 @@ export const createTournamentTeam = TournamentTeam.create;
export async function createTournamentRounds({
organizationNameForUrl,
tournamentNameForUrl,
mapPool,
mapList,
userId,
}: {
organizationNameForUrl: string;
tournamentNameForUrl: string;
mapPool: UseTournamentRoundsState["bracket"];
mapList: UseTournamentRoundsState["bracket"];
userId: string;
}) {
const tournament = await Tournament.findByNameForUrl({
organizationNameForUrl,
@@ -136,6 +138,9 @@ export async function createTournamentRounds({
});
if (!tournament) throw new Response("No tournament found", { status: 404 });
if (!isTournamentAdmin({ organization: tournament.organizer, userId })) {
throw new Response("Not tournament admin", { status: 401 });
}
}
export async function joinTeamViaInviteCode({