From ba0715152d601daea17e6bb41e73436596cb5129 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 3 Mar 2024 23:56:17 +0200 Subject: [PATCH] 3rd place match Closes #1680 --- app/db/seed/index.ts | 1 + app/db/tables.ts | 1 + .../calendar/CalendarRepository.server.ts | 3 ++ .../calendar/calendar-schemas.server.ts | 4 ++ app/features/calendar/routes/calendar.new.tsx | 16 ++++++ .../components/Bracket/Elimination.tsx | 18 ++++++- .../components/Bracket/RoundHeader.tsx | 9 ++-- .../components/Bracket/useDeadline.ts | 5 +- .../tournament-bracket/core/Bracket.ts | 49 +++++++++++++++++-- .../tournament-bracket/core/Tournament.ts | 6 ++- .../core/brackets-manager/crud-db.server.ts | 12 +++++ .../core/brackets-manager/crud.server.ts | 4 +- .../routes/to.$id.brackets.tsx | 4 +- .../tournament/routes/to.$id.admin.tsx | 4 ++ e2e/tournament-bracket.spec.ts | 46 +++++++++-------- 15 files changed, 146 insertions(+), 36 deletions(-) diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 853f05644..53d92fe1d 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -169,6 +169,7 @@ function wipeDB() { "TournamentTeamMember", "MapPoolMap", "TournamentMatchGameResult", + "TournamentTeamCheckIn", "TournamentTeam", "TournamentStage", "TournamentResult", diff --git a/app/db/tables.ts b/app/db/tables.ts index f2f9ec989..55f01a95d 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -385,6 +385,7 @@ export type TournamentBracketProgression = { export interface TournamentSettings { bracketProgression: TournamentBracketProgression; teamsPerGroup?: number; + thirdPlaceMatch?: boolean; } export interface CastedMatchesInfo { diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index af2f13b2c..24c7ef861 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -385,6 +385,7 @@ type CreateArgs = Pick< mapPickingStyle: Tables["Tournament"]["mapPickingStyle"]; bracketProgression: TournamentSettings["bracketProgression"] | null; teamsPerGroup?: number; + thirdPlaceMatch?: boolean; }; export async function create(args: CreateArgs) { return db.transaction().execute(async (trx) => { @@ -394,6 +395,7 @@ export async function create(args: CreateArgs) { const settings: Tables["Tournament"]["settings"] = { bracketProgression: args.bracketProgression, teamsPerGroup: args.teamsPerGroup, + thirdPlaceMatch: args.thirdPlaceMatch, }; tournamentId = ( @@ -462,6 +464,7 @@ export async function update(args: UpdateArgs) { const settings: Tables["Tournament"]["settings"] = { bracketProgression: args.bracketProgression, teamsPerGroup: args.teamsPerGroup, + thirdPlaceMatch: args.thirdPlaceMatch, }; await trx diff --git a/app/features/calendar/calendar-schemas.server.ts b/app/features/calendar/calendar-schemas.server.ts index 46154fcb5..44178106b 100644 --- a/app/features/calendar/calendar-schemas.server.ts +++ b/app/features/calendar/calendar-schemas.server.ts @@ -75,6 +75,10 @@ export const newCalendarEventActionSchema = z // format: z.enum(FORMATS_SHORT).nullish(), withUndergroundBracket: z.preprocess(checkboxValueToBoolean, z.boolean()), + thirdPlaceMatch: z.preprocess( + checkboxValueToBoolean, + z.boolean().nullish(), + ), teamsPerGroup: z.coerce .number() .min(TOURNAMENT.MIN_GROUP_SIZE) diff --git a/app/features/calendar/routes/calendar.new.tsx b/app/features/calendar/routes/calendar.new.tsx index 8f09a6e78..6c443a56a 100644 --- a/app/features/calendar/routes/calendar.new.tsx +++ b/app/features/calendar/routes/calendar.new.tsx @@ -116,6 +116,7 @@ export const action: ActionFunction = async ({ request }) => { rankedModesShort.find((mode) => mode === data.toToolsMode) ?? null, bracketProgression: formValuesToBracketProgression(data), teamsPerGroup: data.teamsPerGroup ?? undefined, + thirdPlaceMatch: data.thirdPlaceMatch ?? undefined, }; validate( !commonArgs.toToolsEnabled || commonArgs.bracketProgression, @@ -813,6 +814,9 @@ function TournamentFormatSelector() { ) : true, ); + const [thirdPlaceMatch, setThirdPlaceMatch] = React.useState( + data.tournamentCtx?.settings.thirdPlaceMatch ?? true, + ); const [teamsPerGroup, setTeamsPerGroup] = React.useState( data.tournamentCtx?.settings.teamsPerGroup ?? 4, ); @@ -854,6 +858,18 @@ function TournamentFormatSelector() { ) : null} + {format === "RR_TO_SE" ? ( +
+ + +
+ ) : null} + {format === "RR_TO_SE" ? (
diff --git a/app/features/tournament-bracket/components/Bracket/Elimination.tsx b/app/features/tournament-bracket/components/Bracket/Elimination.tsx index 54df8c1f3..bec267f98 100644 --- a/app/features/tournament-bracket/components/Bracket/Elimination.tsx +++ b/app/features/tournament-bracket/components/Bracket/Elimination.tsx @@ -3,6 +3,7 @@ import type { Bracket as BracketType } from "../../core/Bracket"; import { Match } from "./Match"; import { RoundHeader } from "./RoundHeader"; import clsx from "clsx"; +import { removeDuplicates } from "~/utils/arrays"; interface EliminationBracketSideProps { bracket: BracketType; @@ -142,6 +143,10 @@ function getRounds(props: EliminationBracketSideProps) { return atLeastOneNonByeMatch; }); + const hasThirdPlaceMatch = + props.type === "single" && + removeDuplicates(props.bracket.data.match.map((m) => m.group_id)).length > + 1; return rounds.map((round, i) => { const name = () => { if ( @@ -151,6 +156,14 @@ function getRounds(props: EliminationBracketSideProps) { ) { return "Grand Finals"; } + + if (hasThirdPlaceMatch && i === rounds.length - 2) { + return "Finals"; + } + if (hasThirdPlaceMatch && i === rounds.length - 1) { + return "3rd place match"; + } + if (props.type === "winners" && i === rounds.length - 1) { return showingBracketReset ? "Bracket Reset" : "Grand Finals"; } @@ -159,7 +172,10 @@ function getRounds(props: EliminationBracketSideProps) { props.type === "winners" ? "WB " : props.type === "losers" ? "LB " : ""; const isFinals = i === rounds.length - (props.type === "winners" ? 3 : 1); - const isSemis = i === rounds.length - (props.type === "winners" ? 4 : 2); + + const semisOffSet = + props.type === "winners" ? 4 : hasThirdPlaceMatch ? 3 : 2; + const isSemis = i === rounds.length - semisOffSet; return `${namePrefix}${ isFinals ? "Finals" : isSemis ? "Semis" : `Round ${i + 1}` diff --git a/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx b/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx index 60e86d846..76aace861 100644 --- a/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx +++ b/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx @@ -14,9 +14,12 @@ export function RoundHeader({ bestOf?: 3 | 5 | 7; showInfos?: boolean; }) { - const hasDeadline = !["WB Finals", "Grand Finals", "Bracket Reset"].includes( - name, - ); + const hasDeadline = ![ + "WB Finals", + "Grand Finals", + "Bracket Reset", + "Finals", + ].includes(name); return (
diff --git a/app/features/tournament-bracket/components/Bracket/useDeadline.ts b/app/features/tournament-bracket/components/Bracket/useDeadline.ts index 08c596a19..6b11ff55e 100644 --- a/app/features/tournament-bracket/components/Bracket/useDeadline.ts +++ b/app/features/tournament-bracket/components/Bracket/useDeadline.ts @@ -74,7 +74,10 @@ function dateByPreviousRound(bracket: Bracket, round: Round) { (r) => r.number === round.number - 1 && round.group_id === r.group_id, ); if (!previousRound) { - logger.warn("Previous round not found", { bracket, round }); + // single elimination 3rd place match -> no deadline + if (bracket.type !== "single_elimination") { + logger.warn("Previous round not found", { bracket, round }); + } return null; } diff --git a/app/features/tournament-bracket/core/Bracket.ts b/app/features/tournament-bracket/core/Bracket.ts index e7e93dfa4..29ff27de8 100644 --- a/app/features/tournament-bracket/core/Bracket.ts +++ b/app/features/tournament-bracket/core/Bracket.ts @@ -331,12 +331,28 @@ class SingleEliminationBracket extends Bracket { return "single_elimination"; } + private hasThirdPlaceMatch() { + return removeDuplicates(this.data.match.map((m) => m.group_id)).length > 1; + } + get standings(): Standing[] { const teams: { id: number; lostAt: number }[] = []; - for (const match of this.data.match - .slice() - .sort((a, b) => a.round_id - b.round_id)) { + const matches = (() => { + if (!this.hasThirdPlaceMatch()) { + return this.data.match.slice(); + } + + const thirdPlaceMatch = this.data.match.find( + (m) => m.group_id === Math.max(...this.data.group.map((g) => g.id)), + ); + + return this.data.match.filter( + (m) => m.group_id !== thirdPlaceMatch?.group_id, + ); + })(); + + for (const match of matches.sort((a, b) => a.round_id - b.round_id)) { if ( match.opponent1?.result !== "win" && match.opponent2?.result !== "win" @@ -389,9 +405,32 @@ class SingleEliminationBracket extends Bracket { }); } - // TODO: 3rd place match + const thirdPlaceMatch = this.hasThirdPlaceMatch() + ? this.data.match.find((m) => m.group_id !== matches[0].group_id) + : undefined; + const thirdPlaceMatchWinner = + thirdPlaceMatch?.opponent1?.result === "win" + ? thirdPlaceMatch.opponent1 + : thirdPlaceMatch?.opponent2?.result === "win" + ? thirdPlaceMatch.opponent2 + : undefined; - return this.standingsWithoutNonParticipants(result.reverse()); + const resultWithThirdPlaceTiebroken = result + .map((standing) => { + if ( + standing.placement === 3 && + thirdPlaceMatchWinner?.id !== standing.team.id + ) { + return { + ...standing, + placement: 4, + }; + } + return standing; + }) + .sort((a, b) => a.placement - b.placement); + + return this.standingsWithoutNonParticipants(resultWithThirdPlaceTiebroken); } } diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index ced2c8e74..1e38c67ba 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -243,7 +243,11 @@ export class Tournament { ): Stage["settings"] { switch (type) { case "single_elimination": - return { consolationFinal: false }; + if (participantsCount < 4) { + return { consolationFinal: false }; + } + + return { consolationFinal: this.ctx.settings.thirdPlaceMatch ?? true }; case "double_elimination": return { grandFinal: "double", diff --git a/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts b/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts index 977a8a52d..124fe6720 100644 --- a/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts +++ b/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts @@ -343,6 +343,12 @@ const match_getByIdStm = sql.prepare(/*sql*/ ` where "TournamentMatch"."id" = @id `); +const match_getByRoundIdStm = sql.prepare(/*sql*/ ` + select * + from "TournamentMatch" + where "TournamentMatch"."roundId" = @roundId +`); + const match_getByStageIdStm = sql.prepare(/*sql*/ ` select "TournamentMatch".*, @@ -456,6 +462,12 @@ export class Match { return this.#convertMatch(match); } + static getByRoundId(roundId: TournamentRound["id"]): MatchType[] { + return (match_getByRoundIdStm.all({ roundId }) as any[]).map( + this.#convertMatch, + ); + } + static getByStageId(stageId: TournamentStage["id"]): MatchType[] { return (match_getByStageIdStm.all({ stageId }) as any[]).map( this.#convertMatch, diff --git a/app/features/tournament-bracket/core/brackets-manager/crud.server.ts b/app/features/tournament-bracket/core/brackets-manager/crud.server.ts index d875fd24a..e98f070dd 100644 --- a/app/features/tournament-bracket/core/brackets-manager/crud.server.ts +++ b/app/features/tournament-bracket/core/brackets-manager/crud.server.ts @@ -176,9 +176,7 @@ export class SqlDatabase { } if (arg.round_id) { - throw new Error("not implemented"); - const matches = Match.getByRoundId(arg.round_id); - return matches && matches.map(convertMatch); + return Match.getByRoundId(arg.round_id); } break; diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index faf021dde..74f816860 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -115,7 +115,9 @@ export const action: ActionFunction = async ({ params, request }) => { if (finalStageIdx !== -1) { await TournamentRepository.checkInMany({ bracketIdx: finalStageIdx, - tournamentTeamIds: tournament.ctx.teams.map((t) => t.id), + tournamentTeamIds: tournament.ctx.teams + .filter((t) => t.checkIns.length > 0) + .map((t) => t.id), }); } } diff --git a/app/features/tournament/routes/to.$id.admin.tsx b/app/features/tournament/routes/to.$id.admin.tsx index cdc4f92e9..3b8a6dc81 100644 --- a/app/features/tournament/routes/to.$id.admin.tsx +++ b/app/features/tournament/routes/to.$id.admin.tsx @@ -111,6 +111,10 @@ export const action: ActionFunction = async ({ request, params }) => { }), "Can't check-in", ); + validate( + team.checkIns.length > 0 || data.bracketIdx === 0, + "Can't check-in to follow up bracket if not checked in for the event itself", + ); const bracket = tournament.bracketByIdx(data.bracketIdx); invariant(bracket, "Invalid bracket idx"); diff --git a/e2e/tournament-bracket.spec.ts b/e2e/tournament-bracket.spec.ts index ceb4ad648..662eec5f0 100644 --- a/e2e/tournament-bracket.spec.ts +++ b/e2e/tournament-bracket.spec.ts @@ -288,21 +288,24 @@ test.describe("Tournament bracket", () => { await submit(page); } - await page.getByTestId("edit-event-info-button").click(); + // TODO: test for a different format + // and include await isNotVisible(page.getByTestId("standing-3")); + // + // await page.getByTestId("edit-event-info-button").click(); - await page.getByTestId("add-bracket").click(); - await page.getByLabel("2. Name").fill("Underground bracket"); + // await page.getByTestId("add-bracket").click(); + // await page.getByLabel("2. Name").fill("Underground bracket"); - for (const testId of [ - "placement-1-2", - "placement-2-2", - "placement-2-3", - "placement-2-4", - ]) { - await page.getByTestId(testId).click(); - } + // for (const testId of [ + // "placement-1-2", + // "placement-2-2", + // "placement-2-3", + // "placement-2-4", + // ]) { + // await page.getByTestId(testId).click(); + // } - await submit(page); + // await submit(page); await page.getByTestId("brackets-tab").click(); await page.getByTestId("finalize-bracket-button").click(); @@ -319,7 +322,7 @@ test.describe("Tournament bracket", () => { } // captain of one of the underground bracket teams - await impersonate(page, 52); + await impersonate(page, 57); await navigate({ page, url: tournamentBracketsPage({ tournamentId }), @@ -357,19 +360,20 @@ test.describe("Tournament bracket", () => { url: tournamentBracketsPage({ tournamentId, bracketIdx: 1 }), }); await page.getByTestId("finalize-bracket-button").click(); - await navigateToMatch(page, 14); - await reportResult({ - page, - amountOfMapsToReport: 3, - sidesWithMoreThanFourPlayers: ["first", "last"], - }); + for (const matchId of [14, 15, 16, 17]) { + await navigateToMatch(page, matchId); + await reportResult({ + page, + amountOfMapsToReport: 3, + sidesWithMoreThanFourPlayers: ["first", "last"], + }); - await backToBracket(page); + await backToBracket(page); + } await page.getByTestId("finalize-tournament-button").click(); await page.getByTestId("confirm-button").click(); await expect(page.getByTestId("standing-1")).toBeVisible(); - await isNotVisible(page.getByTestId("standing-3")); // not possible to reopen finals match anymore await navigateToMatch(page, 14);