mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-27 21:55:15 -05:00
3rd place match Closes #1680
This commit is contained in:
@@ -169,6 +169,7 @@ function wipeDB() {
|
||||
"TournamentTeamMember",
|
||||
"MapPoolMap",
|
||||
"TournamentMatchGameResult",
|
||||
"TournamentTeamCheckIn",
|
||||
"TournamentTeam",
|
||||
"TournamentStage",
|
||||
"TournamentResult",
|
||||
|
||||
@@ -385,6 +385,7 @@ export type TournamentBracketProgression = {
|
||||
export interface TournamentSettings {
|
||||
bracketProgression: TournamentBracketProgression;
|
||||
teamsPerGroup?: number;
|
||||
thirdPlaceMatch?: boolean;
|
||||
}
|
||||
|
||||
export interface CastedMatchesInfo {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user