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);