3rd place match Closes #1680

This commit is contained in:
Kalle
2024-03-03 23:56:17 +02:00
parent f75a5dbb96
commit ba0715152d
15 changed files with 146 additions and 36 deletions

View File

@@ -169,6 +169,7 @@ function wipeDB() {
"TournamentTeamMember",
"MapPoolMap",
"TournamentMatchGameResult",
"TournamentTeamCheckIn",
"TournamentTeam",
"TournamentStage",
"TournamentResult",

View File

@@ -385,6 +385,7 @@ export type TournamentBracketProgression = {
export interface TournamentSettings {
bracketProgression: TournamentBracketProgression;
teamsPerGroup?: number;
thirdPlaceMatch?: boolean;
}
export interface CastedMatchesInfo {

View File

@@ -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

View File

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

View File

@@ -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() {
</div>
) : null}
{format === "RR_TO_SE" ? (
<div>
<Label htmlFor="thirdPlaceMatch">Third place match</Label>
<Toggle
checked={thirdPlaceMatch}
setChecked={setThirdPlaceMatch}
name="thirdPlaceMatch"
id="thirdPlaceMatch"
/>
</div>
) : null}
{format === "RR_TO_SE" ? (
<div>
<Label htmlFor="teamsPerGroup">Teams per group</Label>

View File

@@ -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}`

View File

@@ -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 (
<div>

View File

@@ -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;
}

View File

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

View File

@@ -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",

View File

@@ -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,

View File

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

View File

@@ -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),
});
}
}

View File

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

View File

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