mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-23 11:36:19 -05:00
tournamentRoundsForDB() function implementation
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user