mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-26 09:20:24 -05:00
Deadline per tournament match (#2657)
This commit is contained in:
parent
9f3a22b618
commit
42cb33641d
|
|
@ -454,7 +454,6 @@ export interface TournamentSettings {
|
||||||
enableNoScreenToggle?: boolean;
|
enableNoScreenToggle?: boolean;
|
||||||
/** Enable the subs tab, default true */
|
/** Enable the subs tab, default true */
|
||||||
enableSubs?: boolean;
|
enableSubs?: boolean;
|
||||||
deadlines?: "STRICT" | "DEFAULT";
|
|
||||||
requireInGameNames?: boolean;
|
requireInGameNames?: boolean;
|
||||||
isInvitational?: boolean;
|
isInvitational?: boolean;
|
||||||
/** Can teams add subs on their own while tournament is in progress? */
|
/** Can teams add subs on their own while tournament is in progress? */
|
||||||
|
|
@ -551,8 +550,9 @@ export interface TournamentMatch {
|
||||||
roundId: number;
|
roundId: number;
|
||||||
stageId: number;
|
stageId: number;
|
||||||
status: (typeof TournamentMatchStatus)[keyof typeof TournamentMatchStatus];
|
status: (typeof TournamentMatchStatus)[keyof typeof TournamentMatchStatus];
|
||||||
// used only for swiss because it's the only stage type where matches are not created in advance
|
// set when match becomes ongoing (both teams ready and no earlier matches for either team)
|
||||||
createdAt: Generated<number>;
|
// for swiss: set at creation time
|
||||||
|
startedAt: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Represents one decision, pick or ban, during tournaments pick/ban (counterpick, ban 2) phase. */
|
/** Represents one decision, pick or ban, during tournaments pick/ban (counterpick, ban 2) phase. */
|
||||||
|
|
|
||||||
|
|
@ -495,7 +495,6 @@ type CreateArgs = Pick<
|
||||||
isRanked?: boolean;
|
isRanked?: boolean;
|
||||||
isTest?: boolean;
|
isTest?: boolean;
|
||||||
isInvitational?: boolean;
|
isInvitational?: boolean;
|
||||||
deadlines: TournamentSettings["deadlines"];
|
|
||||||
enableNoScreenToggle?: boolean;
|
enableNoScreenToggle?: boolean;
|
||||||
enableSubs?: boolean;
|
enableSubs?: boolean;
|
||||||
autonomousSubs?: boolean;
|
autonomousSubs?: boolean;
|
||||||
|
|
@ -529,7 +528,6 @@ export async function create(args: CreateArgs) {
|
||||||
thirdPlaceMatch: args.thirdPlaceMatch,
|
thirdPlaceMatch: args.thirdPlaceMatch,
|
||||||
isRanked: args.isRanked,
|
isRanked: args.isRanked,
|
||||||
isTest: args.isTest,
|
isTest: args.isTest,
|
||||||
deadlines: args.deadlines,
|
|
||||||
isInvitational: args.isInvitational,
|
isInvitational: args.isInvitational,
|
||||||
enableNoScreenToggle: args.enableNoScreenToggle,
|
enableNoScreenToggle: args.enableNoScreenToggle,
|
||||||
enableSubs: args.enableSubs,
|
enableSubs: args.enableSubs,
|
||||||
|
|
@ -726,7 +724,6 @@ async function updateTournamentTables(
|
||||||
thirdPlaceMatch: args.thirdPlaceMatch,
|
thirdPlaceMatch: args.thirdPlaceMatch,
|
||||||
isRanked: args.isRanked,
|
isRanked: args.isRanked,
|
||||||
isTest: existingSettings.isTest, // this one is not editable after creation
|
isTest: existingSettings.isTest, // this one is not editable after creation
|
||||||
deadlines: args.deadlines,
|
|
||||||
isInvitational: args.isInvitational,
|
isInvitational: args.isInvitational,
|
||||||
enableNoScreenToggle: args.enableNoScreenToggle,
|
enableNoScreenToggle: args.enableNoScreenToggle,
|
||||||
enableSubs: args.enableSubs,
|
enableSubs: args.enableSubs,
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,6 @@ export const action: ActionFunction = async ({ request }) => {
|
||||||
isRanked: data.isRanked ?? undefined,
|
isRanked: data.isRanked ?? undefined,
|
||||||
isTest: data.isTest ?? undefined,
|
isTest: data.isTest ?? undefined,
|
||||||
isInvitational: data.isInvitational ?? false,
|
isInvitational: data.isInvitational ?? false,
|
||||||
deadlines: data.strictDeadline ? ("STRICT" as const) : ("DEFAULT" as const),
|
|
||||||
enableNoScreenToggle: data.enableNoScreenToggle ?? undefined,
|
enableNoScreenToggle: data.enableNoScreenToggle ?? undefined,
|
||||||
enableSubs: data.enableSubs ?? undefined,
|
enableSubs: data.enableSubs ?? undefined,
|
||||||
requireInGameNames: data.requireInGameNames ?? undefined,
|
requireInGameNames: data.requireInGameNames ?? undefined,
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,6 @@ export const newCalendarEventActionSchema = z
|
||||||
),
|
),
|
||||||
enableSubs: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
enableSubs: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
||||||
autonomousSubs: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
autonomousSubs: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
||||||
strictDeadline: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
|
||||||
isInvitational: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
isInvitational: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
||||||
requireInGameNames: z.preprocess(
|
requireInGameNames: z.preprocess(
|
||||||
checkboxValueToBoolean,
|
checkboxValueToBoolean,
|
||||||
|
|
|
||||||
|
|
@ -258,7 +258,6 @@ function EventForm() {
|
||||||
isInvitational={isInvitational}
|
isInvitational={isInvitational}
|
||||||
setIsInvitational={setIsInvitational}
|
setIsInvitational={setIsInvitational}
|
||||||
/>
|
/>
|
||||||
<StrictDeadlinesToggle />
|
|
||||||
{!eventToEdit ? <TestToggle /> : null}
|
{!eventToEdit ? <TestToggle /> : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -972,33 +971,6 @@ function InvitationalToggle({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StrictDeadlinesToggle() {
|
|
||||||
const baseEvent = useBaseEvent();
|
|
||||||
const [strictDeadlines, setStrictDeadlines] = React.useState(
|
|
||||||
baseEvent?.tournament?.ctx.settings.deadlines === "STRICT",
|
|
||||||
);
|
|
||||||
const id = React.useId();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<label htmlFor={id} className="w-max">
|
|
||||||
Strict deadlines
|
|
||||||
</label>
|
|
||||||
<SendouSwitch
|
|
||||||
name="strictDeadline"
|
|
||||||
id={id}
|
|
||||||
size="small"
|
|
||||||
isSelected={strictDeadlines}
|
|
||||||
onChange={setStrictDeadlines}
|
|
||||||
/>
|
|
||||||
<FormMessage type="info">
|
|
||||||
Strict deadlines has 5 minutes less for the target time of each round
|
|
||||||
(25min Bo3, 35min Bo5 compared to 30min Bo3, 40min Bo5 normal).
|
|
||||||
</FormMessage>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TestToggle() {
|
function TestToggle() {
|
||||||
const baseEvent = useBaseEvent();
|
const baseEvent = useBaseEvent();
|
||||||
const [isTest, setIsTest] = React.useState(
|
const [isTest, setIsTest] = React.useState(
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,6 @@ const createCalendarEvent = async (authorId: number, avatarImgId?: number) => {
|
||||||
tags: null,
|
tags: null,
|
||||||
mapPickingStyle: "AUTO_SZ",
|
mapPickingStyle: "AUTO_SZ",
|
||||||
bracketProgression: null,
|
bracketProgression: null,
|
||||||
deadlines: "DEFAULT",
|
|
||||||
rules: null,
|
rules: null,
|
||||||
avatarImgId,
|
avatarImgId,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import {
|
||||||
import { findResultsByMatchId } from "../queries/findResultsByMatchId.server";
|
import { findResultsByMatchId } from "../queries/findResultsByMatchId.server";
|
||||||
import { insertTournamentMatchGameResult } from "../queries/insertTournamentMatchGameResult.server";
|
import { insertTournamentMatchGameResult } from "../queries/insertTournamentMatchGameResult.server";
|
||||||
import { insertTournamentMatchGameResultParticipant } from "../queries/insertTournamentMatchGameResultParticipant.server";
|
import { insertTournamentMatchGameResultParticipant } from "../queries/insertTournamentMatchGameResultParticipant.server";
|
||||||
|
import { resetMatchStatus } from "../queries/resetMatchStatus.server";
|
||||||
import { updateMatchGameResultPoints } from "../queries/updateMatchGameResultPoints.server";
|
import { updateMatchGameResultPoints } from "../queries/updateMatchGameResultPoints.server";
|
||||||
import {
|
import {
|
||||||
matchPageParamsSchema,
|
matchPageParamsSchema,
|
||||||
|
|
@ -39,6 +40,7 @@ import {
|
||||||
} from "../tournament-bracket-schemas.server";
|
} from "../tournament-bracket-schemas.server";
|
||||||
import {
|
import {
|
||||||
isSetOverByScore,
|
isSetOverByScore,
|
||||||
|
matchEndedEarly,
|
||||||
matchIsLocked,
|
matchIsLocked,
|
||||||
tournamentMatchWebsocketRoom,
|
tournamentMatchWebsocketRoom,
|
||||||
tournamentTeamToActiveRosterUserIds,
|
tournamentTeamToActiveRosterUserIds,
|
||||||
|
|
@ -108,6 +110,7 @@ export const action: ActionFunction = async ({ params, request }) => {
|
||||||
|
|
||||||
let emitMatchUpdate = false;
|
let emitMatchUpdate = false;
|
||||||
let emitTournamentUpdate = false;
|
let emitTournamentUpdate = false;
|
||||||
|
|
||||||
switch (data._action) {
|
switch (data._action) {
|
||||||
case "REPORT_SCORE": {
|
case "REPORT_SCORE": {
|
||||||
// they are trying to report score that was already reported
|
// they are trying to report score that was already reported
|
||||||
|
|
@ -464,7 +467,6 @@ export const action: ActionFunction = async ({ params, request }) => {
|
||||||
const scoreTwo = match.opponentTwo?.score ?? 0;
|
const scoreTwo = match.opponentTwo?.score ?? 0;
|
||||||
invariant(typeof scoreOne === "number", "Score one is missing");
|
invariant(typeof scoreOne === "number", "Score one is missing");
|
||||||
invariant(typeof scoreTwo === "number", "Score two is missing");
|
invariant(typeof scoreTwo === "number", "Score two is missing");
|
||||||
invariant(scoreOne !== scoreTwo, "Scores are equal");
|
|
||||||
|
|
||||||
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
|
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
|
||||||
errorToastIfFalsy(
|
errorToastIfFalsy(
|
||||||
|
|
@ -474,6 +476,16 @@ export const action: ActionFunction = async ({ params, request }) => {
|
||||||
|
|
||||||
const results = findResultsByMatchId(matchId);
|
const results = findResultsByMatchId(matchId);
|
||||||
const lastResult = results[results.length - 1];
|
const lastResult = results[results.length - 1];
|
||||||
|
|
||||||
|
const endedEarly = matchEndedEarly({
|
||||||
|
opponentOne: { score: scoreOne, result: match.opponentOne?.result },
|
||||||
|
opponentTwo: { score: scoreTwo, result: match.opponentTwo?.result },
|
||||||
|
count: match.roundMaps.count,
|
||||||
|
countType: match.roundMaps.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!endedEarly) {
|
||||||
|
invariant(scoreOne !== scoreTwo, "Scores are equal");
|
||||||
invariant(lastResult, "Last result is missing");
|
invariant(lastResult, "Last result is missing");
|
||||||
|
|
||||||
if (scoreOne > scoreTwo) {
|
if (scoreOne > scoreTwo) {
|
||||||
|
|
@ -481,25 +493,37 @@ export const action: ActionFunction = async ({ params, request }) => {
|
||||||
} else {
|
} else {
|
||||||
scores[1]--;
|
scores[1]--;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`Reopening match: User ID: ${user.id}; Match ID: ${match.id}`,
|
`Reopening match: User ID: ${user.id}; Match ID: ${match.id}; Ended early: ${endedEarly}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const followingMatches = tournament.followingMatches(match.id);
|
const followingMatches = tournament.followingMatches(match.id);
|
||||||
|
const bracketFormat = tournament.bracketByIdx(
|
||||||
|
tournament.matchIdToBracketIdx(match.id)!,
|
||||||
|
)!.type;
|
||||||
sql.transaction(() => {
|
sql.transaction(() => {
|
||||||
for (const match of followingMatches) {
|
for (const match of followingMatches) {
|
||||||
deleteMatchPickBanEvents({ matchId: match.id });
|
// for other formats the database triggers handle the startedAt clearing. Status reset for those is managed by the brackets-manager
|
||||||
|
if (bracketFormat === "round_robin") {
|
||||||
|
resetMatchStatus(match.id);
|
||||||
|
} else {
|
||||||
|
// edge case but for round robin we can just leave the match as is, lock it then unlock later to continue where they left off (should not really ever happen)
|
||||||
|
deleteMatchPickBanEvents(match.id);
|
||||||
}
|
}
|
||||||
deleteTournamentMatchGameResultById(lastResult.id);
|
}
|
||||||
|
|
||||||
|
if (lastResult) deleteTournamentMatchGameResultById(lastResult.id);
|
||||||
|
|
||||||
manager.update.match({
|
manager.update.match({
|
||||||
id: match.id,
|
id: match.id,
|
||||||
opponent1: {
|
opponent1: {
|
||||||
score: scores[0],
|
score: endedEarly ? scoreOne : scores[0],
|
||||||
result: undefined,
|
result: undefined,
|
||||||
},
|
},
|
||||||
opponent2: {
|
opponent2: {
|
||||||
score: scores[1],
|
score: endedEarly ? scoreTwo : scores[1],
|
||||||
result: undefined,
|
result: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -561,6 +585,56 @@ export const action: ActionFunction = async ({ params, request }) => {
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "END_SET": {
|
||||||
|
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
|
||||||
|
errorToastIfFalsy(
|
||||||
|
match.opponentOne?.id && match.opponentTwo?.id,
|
||||||
|
"Teams are missing",
|
||||||
|
);
|
||||||
|
errorToastIfFalsy(
|
||||||
|
match.opponentOne?.result !== "win" &&
|
||||||
|
match.opponentTwo?.result !== "win",
|
||||||
|
"Match is already over",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determine winner (random if not specified)
|
||||||
|
const winnerTeamId = (() => {
|
||||||
|
if (data.winnerTeamId) {
|
||||||
|
errorToastIfFalsy(
|
||||||
|
data.winnerTeamId === match.opponentOne.id ||
|
||||||
|
data.winnerTeamId === match.opponentTwo.id,
|
||||||
|
"Invalid winner team id",
|
||||||
|
);
|
||||||
|
return data.winnerTeamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random winner: true 50/50 selection
|
||||||
|
return Math.random() < 0.5
|
||||||
|
? match.opponentOne.id
|
||||||
|
: match.opponentTwo.id;
|
||||||
|
})();
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Ending set by organizer: User ID: ${user.id}; Match ID: ${match.id}; Winner: ${winnerTeamId}; Random: ${!data.winnerTeamId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
sql.transaction(() => {
|
||||||
|
manager.update.match({
|
||||||
|
id: match.id,
|
||||||
|
opponent1: {
|
||||||
|
result: winnerTeamId === match.opponentOne!.id ? "win" : "loss",
|
||||||
|
},
|
||||||
|
opponent2: {
|
||||||
|
result: winnerTeamId === match.opponentTwo!.id ? "win" : "loss",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
emitMatchUpdate = true;
|
||||||
|
emitTournamentUpdate = true;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
assertUnreachable(data);
|
assertUnreachable(data);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Link, useFetcher } from "@remix-run/react";
|
import { Link, useFetcher } from "@remix-run/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { differenceInMinutes } from "date-fns";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Avatar } from "~/components/Avatar";
|
import { Avatar } from "~/components/Avatar";
|
||||||
import { SendouButton } from "~/components/elements/Button";
|
import { SendouButton } from "~/components/elements/Button";
|
||||||
|
|
@ -11,10 +12,13 @@ import {
|
||||||
useStreamingParticipants,
|
useStreamingParticipants,
|
||||||
useTournament,
|
useTournament,
|
||||||
} from "~/features/tournament/routes/to.$id";
|
} from "~/features/tournament/routes/to.$id";
|
||||||
|
import { databaseTimestampToDate } from "~/utils/dates";
|
||||||
import type { Unpacked } from "~/utils/types";
|
import type { Unpacked } from "~/utils/types";
|
||||||
import { tournamentMatchPage, tournamentStreamsPage } from "~/utils/urls";
|
import { tournamentMatchPage, tournamentStreamsPage } from "~/utils/urls";
|
||||||
import type { Bracket } from "../../core/Bracket";
|
import type { Bracket } from "../../core/Bracket";
|
||||||
|
import * as Deadline from "../../core/Deadline";
|
||||||
import type { TournamentData } from "../../core/Tournament.server";
|
import type { TournamentData } from "../../core/Tournament.server";
|
||||||
|
import { matchEndedEarly } from "../../tournament-bracket-utils";
|
||||||
|
|
||||||
interface MatchProps {
|
interface MatchProps {
|
||||||
match: Unpacked<TournamentData["data"]["match"]>;
|
match: Unpacked<TournamentData["data"]["match"]>;
|
||||||
|
|
@ -24,6 +28,7 @@ interface MatchProps {
|
||||||
roundNumber: number;
|
roundNumber: number;
|
||||||
showSimulation: boolean;
|
showSimulation: boolean;
|
||||||
bracket: Bracket;
|
bracket: Bracket;
|
||||||
|
hideMatchTimer?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Match(props: MatchProps) {
|
export function Match(props: MatchProps) {
|
||||||
|
|
@ -41,6 +46,9 @@ export function Match(props: MatchProps) {
|
||||||
<div className="bracket__match__separator" />
|
<div className="bracket__match__separator" />
|
||||||
<MatchRow {...props} side={2} />
|
<MatchRow {...props} side={2} />
|
||||||
</MatchWrapper>
|
</MatchWrapper>
|
||||||
|
{!props.hideMatchTimer ? (
|
||||||
|
<MatchTimer match={props.match} bracket={props.bracket} />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -96,7 +104,7 @@ function MatchHeader({ match, type, roundNumber, group }: MatchProps) {
|
||||||
>
|
>
|
||||||
Match is scheduled to be casted
|
Match is scheduled to be casted
|
||||||
</SendouPopover>
|
</SendouPopover>
|
||||||
) : hasStreams() ? (
|
) : hasStreams() && match.startedAt ? (
|
||||||
<SendouPopover
|
<SendouPopover
|
||||||
placement="top"
|
placement="top"
|
||||||
popoverClassName="w-max"
|
popoverClassName="w-max"
|
||||||
|
|
@ -154,7 +162,25 @@ function MatchRow({
|
||||||
const score = () => {
|
const score = () => {
|
||||||
if (!match.opponent1?.id || !match.opponent2?.id || isPreview) return null;
|
if (!match.opponent1?.id || !match.opponent2?.id || isPreview) return null;
|
||||||
|
|
||||||
return opponent!.score ?? 0;
|
const opponentScore = opponent!.score;
|
||||||
|
const opponentResult = opponent!.result;
|
||||||
|
|
||||||
|
// Display W/L as the score might not reflect the winner set in the early ending
|
||||||
|
const round = bracket.data.round.find((r) => r.id === match.round_id);
|
||||||
|
if (
|
||||||
|
round?.maps &&
|
||||||
|
matchEndedEarly({
|
||||||
|
opponentOne: match.opponent1,
|
||||||
|
opponentTwo: match.opponent2,
|
||||||
|
count: round.maps.count,
|
||||||
|
countType: round.maps.type,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
if (opponentResult === "win") return "W";
|
||||||
|
if (opponentResult === "loss") return "L";
|
||||||
|
}
|
||||||
|
|
||||||
|
return opponentScore ?? 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isLoser = opponent?.result === "loss";
|
const isLoser = opponent?.result === "loss";
|
||||||
|
|
@ -267,3 +293,64 @@ function MatchStreams({ match }: Pick<MatchProps, "match">) {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MatchTimer({ match, bracket }: Pick<MatchProps, "match" | "bracket">) {
|
||||||
|
const [now, setNow] = React.useState(new Date());
|
||||||
|
const tournament = useTournament();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setNow(new Date());
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!match.startedAt) return null;
|
||||||
|
|
||||||
|
const isOver =
|
||||||
|
match.opponent1?.result === "win" || match.opponent2?.result === "win";
|
||||||
|
|
||||||
|
if (isOver) return null;
|
||||||
|
|
||||||
|
const isLocked = tournament.ctx.castedMatchesInfo?.lockedMatches?.includes(
|
||||||
|
match.id,
|
||||||
|
);
|
||||||
|
if (isLocked) return null;
|
||||||
|
|
||||||
|
const round = bracket.data.round.find((r) => r.id === match.round_id);
|
||||||
|
const bestOf = round?.maps?.count;
|
||||||
|
|
||||||
|
if (!bestOf) return null;
|
||||||
|
|
||||||
|
const elapsedMinutes = differenceInMinutes(
|
||||||
|
now,
|
||||||
|
databaseTimestampToDate(match.startedAt),
|
||||||
|
);
|
||||||
|
const status = Deadline.matchStatus({
|
||||||
|
elapsedMinutes,
|
||||||
|
gamesCompleted:
|
||||||
|
(match.opponent1?.score ?? 0) + (match.opponent2?.score ?? 0),
|
||||||
|
maxGamesCount: bestOf,
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayText = elapsedMinutes >= 60 ? "1h+" : `${elapsedMinutes}m`;
|
||||||
|
|
||||||
|
const statusColor =
|
||||||
|
status === "error"
|
||||||
|
? "var(--theme-error)"
|
||||||
|
: status === "warning"
|
||||||
|
? "var(--theme-warning)"
|
||||||
|
: "var(--text)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bracket__match__timer">
|
||||||
|
<div
|
||||||
|
className="bracket__match__header__box"
|
||||||
|
style={{ color: statusColor }}
|
||||||
|
>
|
||||||
|
{displayText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import clsx from "clsx";
|
import { differenceInMinutes } from "date-fns";
|
||||||
|
import * as React from "react";
|
||||||
import type { TournamentRoundMaps } from "~/db/tables";
|
import type { TournamentRoundMaps } from "~/db/tables";
|
||||||
import { useTournament } from "~/features/tournament/routes/to.$id";
|
import { useTournament } from "~/features/tournament/routes/to.$id";
|
||||||
import { resolveLeagueRoundStartDate } from "~/features/tournament/tournament-utils";
|
import { resolveLeagueRoundStartDate } from "~/features/tournament/tournament-utils";
|
||||||
import { useAutoRerender } from "~/hooks/useAutoRerender";
|
|
||||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
|
||||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||||
import { TOURNAMENT } from "../../../tournament/tournament-constants";
|
import { databaseTimestampToDate } from "~/utils/dates";
|
||||||
import { useDeadline } from "./useDeadline";
|
import type { Unpacked } from "~/utils/types";
|
||||||
|
import * as Deadline from "../../core/Deadline";
|
||||||
|
import type { TournamentData } from "../../core/Tournament.server";
|
||||||
|
|
||||||
export function RoundHeader({
|
export function RoundHeader({
|
||||||
roundId,
|
roundId,
|
||||||
|
|
@ -14,22 +15,19 @@ export function RoundHeader({
|
||||||
bestOf,
|
bestOf,
|
||||||
showInfos,
|
showInfos,
|
||||||
maps,
|
maps,
|
||||||
|
roundStartedAt = null,
|
||||||
|
matches = [],
|
||||||
}: {
|
}: {
|
||||||
roundId: number;
|
roundId: number;
|
||||||
name: string;
|
name: string;
|
||||||
bestOf?: number;
|
bestOf?: number;
|
||||||
showInfos?: boolean;
|
showInfos?: boolean;
|
||||||
maps?: TournamentRoundMaps | null;
|
maps?: TournamentRoundMaps | null;
|
||||||
|
roundStartedAt?: number | null;
|
||||||
|
matches?: Array<Unpacked<TournamentData["data"]["match"]>>;
|
||||||
}) {
|
}) {
|
||||||
const leagueRoundStartDate = useLeagueWeekStart(roundId);
|
const leagueRoundStartDate = useLeagueWeekStart(roundId);
|
||||||
|
|
||||||
const hasDeadline = ![
|
|
||||||
TOURNAMENT.ROUND_NAMES.WB_FINALS,
|
|
||||||
TOURNAMENT.ROUND_NAMES.GRAND_FINALS,
|
|
||||||
TOURNAMENT.ROUND_NAMES.BRACKET_RESET,
|
|
||||||
TOURNAMENT.ROUND_NAMES.FINALS,
|
|
||||||
].includes(name as any);
|
|
||||||
|
|
||||||
const countPrefix = maps?.type === "PLAY_ALL" ? "Play all " : "Bo";
|
const countPrefix = maps?.type === "PLAY_ALL" ? "Play all " : "Bo";
|
||||||
|
|
||||||
const pickBanSuffix =
|
const pickBanSuffix =
|
||||||
|
|
@ -49,7 +47,13 @@ export function RoundHeader({
|
||||||
{bestOf}
|
{bestOf}
|
||||||
{pickBanSuffix}
|
{pickBanSuffix}
|
||||||
</div>
|
</div>
|
||||||
{hasDeadline ? <Deadline roundId={roundId} bestOf={bestOf} /> : null}
|
{roundStartedAt && matches && matches.length > 0 ? (
|
||||||
|
<RoundTimer
|
||||||
|
startedAt={roundStartedAt}
|
||||||
|
bestOf={bestOf}
|
||||||
|
matches={matches}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : leagueRoundStartDate ? (
|
) : leagueRoundStartDate ? (
|
||||||
<LeagueRoundStartDate date={leagueRoundStartDate} />
|
<LeagueRoundStartDate date={leagueRoundStartDate} />
|
||||||
|
|
@ -78,23 +82,63 @@ function LeagueRoundStartDate({ date }: { date: Date }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Deadline({ roundId, bestOf }: { roundId: number; bestOf: number }) {
|
function RoundTimer({
|
||||||
useAutoRerender("ten seconds");
|
startedAt,
|
||||||
const isMounted = useIsMounted();
|
bestOf,
|
||||||
const deadline = useDeadline(roundId, bestOf);
|
matches,
|
||||||
const { formatTime } = useTimeFormat();
|
}: {
|
||||||
|
startedAt: number;
|
||||||
|
bestOf: number;
|
||||||
|
matches: Array<Unpacked<TournamentData["data"]["match"]>>;
|
||||||
|
}) {
|
||||||
|
const [now, setNow] = React.useState(new Date());
|
||||||
|
|
||||||
if (!deadline) return null;
|
React.useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setNow(new Date());
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
return (
|
return () => clearInterval(interval);
|
||||||
<div
|
}, []);
|
||||||
className={clsx({
|
|
||||||
"text-warning": isMounted && deadline < new Date(),
|
const elapsedMinutes = differenceInMinutes(
|
||||||
})}
|
now,
|
||||||
>
|
databaseTimestampToDate(startedAt),
|
||||||
DL {formatTime(deadline)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const matchStatuses = matches
|
||||||
|
.filter((match) => match.startedAt)
|
||||||
|
.map((match) => {
|
||||||
|
const matchElapsedMinutes = differenceInMinutes(
|
||||||
|
now,
|
||||||
|
databaseTimestampToDate(match.startedAt!),
|
||||||
|
);
|
||||||
|
const gamesCompleted =
|
||||||
|
(match.opponent1?.score ?? 0) + (match.opponent2?.score ?? 0);
|
||||||
|
|
||||||
|
return Deadline.matchStatus({
|
||||||
|
elapsedMinutes: matchElapsedMinutes,
|
||||||
|
gamesCompleted,
|
||||||
|
maxGamesCount: bestOf,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const worstStatus = matchStatuses.includes("error")
|
||||||
|
? "error"
|
||||||
|
: matchStatuses.includes("warning")
|
||||||
|
? "warning"
|
||||||
|
: "normal";
|
||||||
|
|
||||||
|
const displayText = elapsedMinutes >= 60 ? "1h+" : `${elapsedMinutes}m`;
|
||||||
|
|
||||||
|
const statusColor =
|
||||||
|
worstStatus === "error"
|
||||||
|
? "var(--theme-error)"
|
||||||
|
: worstStatus === "warning"
|
||||||
|
? "var(--theme-warning)"
|
||||||
|
: "var(--text)";
|
||||||
|
|
||||||
|
return <div style={{ color: statusColor }}>{displayText}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useLeagueWeekStart(roundId: number) {
|
function useLeagueWeekStart(roundId: number) {
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ export function RoundRobinBracket({ bracket }: { bracket: BracketType }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={groupName} className="stack lg">
|
<div key={groupName} className="stack lg ml-4">
|
||||||
<h2 className="text-lg">{groupName}</h2>
|
<h2 className="text-lg">{groupName}</h2>
|
||||||
<div
|
<div
|
||||||
className="elim-bracket__container"
|
className="elim-bracket__container"
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,19 @@ export function SwissBracket({
|
||||||
|
|
||||||
const bestOf = round.maps?.count;
|
const bestOf = round.maps?.count;
|
||||||
|
|
||||||
|
const ongoingMatches = matches.filter(
|
||||||
|
(m) =>
|
||||||
|
m.opponent1 &&
|
||||||
|
m.opponent2 &&
|
||||||
|
!m.opponent1.result &&
|
||||||
|
!m.opponent2.result,
|
||||||
|
);
|
||||||
|
const startedAtValues = ongoingMatches
|
||||||
|
.map((m) => m.startedAt)
|
||||||
|
.filter((t): t is number => typeof t === "number");
|
||||||
|
const roundStartedAt =
|
||||||
|
startedAtValues.length > 0 ? Math.min(...startedAtValues) : null;
|
||||||
|
|
||||||
const teamWithByeId = matches.find((m) => !m.opponent2)?.opponent1
|
const teamWithByeId = matches.find((m) => !m.opponent2)?.opponent1
|
||||||
?.id;
|
?.id;
|
||||||
const teamWithBye = teamWithByeId
|
const teamWithBye = teamWithByeId
|
||||||
|
|
@ -156,6 +169,8 @@ export function SwissBracket({
|
||||||
bestOf={bestOf}
|
bestOf={bestOf}
|
||||||
showInfos={someMatchOngoing(matches)}
|
showInfos={someMatchOngoing(matches)}
|
||||||
maps={round.maps}
|
maps={round.maps}
|
||||||
|
roundStartedAt={roundStartedAt}
|
||||||
|
matches={ongoingMatches}
|
||||||
/>
|
/>
|
||||||
{roundThatCanBeStartedId() === round.id ? (
|
{roundThatCanBeStartedId() === round.id ? (
|
||||||
<fetcher.Form method="post">
|
<fetcher.Form method="post">
|
||||||
|
|
@ -223,6 +238,7 @@ export function SwissBracket({
|
||||||
bracket={bracket}
|
bracket={bracket}
|
||||||
type="groups"
|
type="groups"
|
||||||
group={selectedGroup.groupName.split(" ")[1]}
|
group={selectedGroup.groupName.split(" ")[1]}
|
||||||
|
hideMatchTimer
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,14 @@
|
||||||
height: 18.86px;
|
height: 18.86px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bracket__match__timer {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
transform: translate(-100%, -50%);
|
||||||
|
margin-inline-start: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.bracket__match {
|
.bracket__match {
|
||||||
width: var(--match-width);
|
width: var(--match-width);
|
||||||
min-height: var(--match-height);
|
min-height: var(--match-height);
|
||||||
|
|
@ -114,6 +122,7 @@ a.bracket__match:hover {
|
||||||
var(--round-count),
|
var(--round-count),
|
||||||
calc(var(--match-width) + var(--line-width))
|
calc(var(--match-width) + var(--line-width))
|
||||||
);
|
);
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.elim-bracket__round-matches-container {
|
.elim-bracket__round-matches-container {
|
||||||
|
|
@ -123,6 +132,7 @@ a.bracket__match:hover {
|
||||||
gap: var(--s-7);
|
gap: var(--s-7);
|
||||||
margin-top: var(--s-6);
|
margin-top: var(--s-6);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.elim-bracket__round-matches-container__top-bye {
|
.elim-bracket__round-matches-container__top-bye {
|
||||||
|
|
|
||||||
|
|
@ -1,203 +0,0 @@
|
||||||
import { useTournament } from "~/features/tournament/routes/to.$id";
|
|
||||||
import type { Round } from "~/modules/brackets-model";
|
|
||||||
import {
|
|
||||||
databaseTimestampToDate,
|
|
||||||
dateToDatabaseTimestamp,
|
|
||||||
} from "~/utils/dates";
|
|
||||||
import { logger } from "~/utils/logger";
|
|
||||||
import type { Bracket } from "../../core/Bracket";
|
|
||||||
|
|
||||||
const MINUTES = {
|
|
||||||
BO1: 20,
|
|
||||||
BO3: 30,
|
|
||||||
BO5: 40,
|
|
||||||
BO7: 50,
|
|
||||||
BO9: 60,
|
|
||||||
};
|
|
||||||
|
|
||||||
const STRICT_MINUTES = {
|
|
||||||
BO1: 15,
|
|
||||||
BO3: 25,
|
|
||||||
BO5: 35,
|
|
||||||
BO7: 45,
|
|
||||||
BO9: 55,
|
|
||||||
};
|
|
||||||
|
|
||||||
const minutesToPlay = (count: number, strict: boolean) => {
|
|
||||||
if (count === 1) return strict ? STRICT_MINUTES.BO1 : MINUTES.BO1;
|
|
||||||
if (count === 3) return strict ? STRICT_MINUTES.BO3 : MINUTES.BO3;
|
|
||||||
if (count === 5) return strict ? STRICT_MINUTES.BO5 : MINUTES.BO5;
|
|
||||||
if (count === 7) return strict ? STRICT_MINUTES.BO7 : MINUTES.BO7;
|
|
||||||
if (count === 9) return strict ? STRICT_MINUTES.BO9 : MINUTES.BO9;
|
|
||||||
|
|
||||||
logger.warn("Unknown best of count", { count });
|
|
||||||
return MINUTES.BO5;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useDeadline(roundId: number, bestOf: number) {
|
|
||||||
const tournament = useTournament();
|
|
||||||
|
|
||||||
if (tournament.isLeagueDivision) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const bracket = tournament.brackets.find((b) =>
|
|
||||||
b.data.round.some((r) => r.id === roundId),
|
|
||||||
);
|
|
||||||
if (!bracket) return null;
|
|
||||||
|
|
||||||
const roundIdx = bracket.data.round.findIndex((r) => r.id === roundId);
|
|
||||||
const round = bracket.data.round[roundIdx];
|
|
||||||
if (!round) return null;
|
|
||||||
|
|
||||||
const isFirstRoundOfBracket =
|
|
||||||
roundIdx === 0 ||
|
|
||||||
((bracket.type === "round_robin" || bracket.type === "swiss") &&
|
|
||||||
round.number === 1);
|
|
||||||
|
|
||||||
const matches = bracket.data.match.filter((m) => m.round_id === roundId);
|
|
||||||
const everyMatchHasStarted = matches.every(
|
|
||||||
(m) =>
|
|
||||||
(!m.opponent1 || m.opponent1.id) && (!m.opponent2 || m.opponent2?.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!everyMatchHasStarted) return null;
|
|
||||||
|
|
||||||
let dl: Date | null;
|
|
||||||
if (isFirstRoundOfBracket) {
|
|
||||||
// should not happen
|
|
||||||
if (!bracket.createdAt) return null;
|
|
||||||
|
|
||||||
dl = databaseTimestampToDate(bracket.createdAt);
|
|
||||||
} else {
|
|
||||||
const losersGroupId = bracket.data.group.find((g) => g.number === 2)?.id;
|
|
||||||
if (
|
|
||||||
bracket.type === "single_elimination" ||
|
|
||||||
(bracket.type === "double_elimination" &&
|
|
||||||
round.group_id !== losersGroupId)
|
|
||||||
) {
|
|
||||||
dl = dateByPreviousRound(bracket, round);
|
|
||||||
} else if (bracket.type === "swiss") {
|
|
||||||
dl = dateByRoundMatch(bracket, round);
|
|
||||||
} else if (bracket.type === "round_robin") {
|
|
||||||
dl = dateByManyPreviousRounds(bracket, round);
|
|
||||||
} else {
|
|
||||||
dl = dateByPreviousRoundAndWinners(bracket, round);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!dl) return null;
|
|
||||||
|
|
||||||
dl.setMinutes(
|
|
||||||
dl.getMinutes() +
|
|
||||||
minutesToPlay(bestOf, tournament.ctx.settings.deadlines === "STRICT"),
|
|
||||||
);
|
|
||||||
|
|
||||||
return dl;
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("useDeadline", { roundId, bestOf }, e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function dateByPreviousRound(bracket: Bracket, round: Round) {
|
|
||||||
const previousRound = bracket.data.round.find(
|
|
||||||
(r) => r.number === round.number - 1 && round.group_id === r.group_id,
|
|
||||||
);
|
|
||||||
if (!previousRound) {
|
|
||||||
// single elimination 3rd place match -> no deadline
|
|
||||||
if (bracket.type !== "single_elimination") {
|
|
||||||
logger.warn("Previous round not found", { bracket, round });
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let maxFinishedAt = 0;
|
|
||||||
for (const match of bracket.data.match.filter(
|
|
||||||
(m) => m.round_id === previousRound.id,
|
|
||||||
)) {
|
|
||||||
if (!match.opponent1 || !match.opponent2) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (match.opponent1.result !== "win" && match.opponent2.result !== "win") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
maxFinishedAt = Math.max(maxFinishedAt, match.lastGameFinishedAt ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (maxFinishedAt === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return databaseTimestampToDate(maxFinishedAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
function dateByRoundMatch(bracket: Bracket, round: Round) {
|
|
||||||
const roundMatch = bracket.data.match.find((m) => m.round_id === round.id);
|
|
||||||
|
|
||||||
if (!roundMatch?.createdAt) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return databaseTimestampToDate(roundMatch.createdAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
function dateByManyPreviousRounds(bracket: Bracket, round: Round) {
|
|
||||||
const relevantRounds = bracket.data.round.filter(
|
|
||||||
(r) => r.number === round.number - 1,
|
|
||||||
);
|
|
||||||
const allMatches = bracket.data.match.filter((match) =>
|
|
||||||
relevantRounds.some((round) => round.id === match.round_id),
|
|
||||||
);
|
|
||||||
|
|
||||||
let maxFinishedAt = 0;
|
|
||||||
for (const match of allMatches) {
|
|
||||||
if (!match.opponent1 || !match.opponent2) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (match.opponent1.result !== "win" && match.opponent2.result !== "win") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
maxFinishedAt = Math.max(maxFinishedAt, match.lastGameFinishedAt ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (maxFinishedAt === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return databaseTimestampToDate(maxFinishedAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
function dateByPreviousRoundAndWinners(bracket: Bracket, round: Round) {
|
|
||||||
const byPreviousRound =
|
|
||||||
round.number > 1 ? dateByPreviousRound(bracket, round) : null;
|
|
||||||
const winnersRound = bracket.winnersSourceRound(round.number);
|
|
||||||
|
|
||||||
if (!winnersRound) return byPreviousRound;
|
|
||||||
|
|
||||||
let maxFinishedAtWB = 0;
|
|
||||||
for (const match of bracket.data.match.filter(
|
|
||||||
(m) => m.round_id === winnersRound.id,
|
|
||||||
)) {
|
|
||||||
if (!match.opponent1 || !match.opponent2) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (match.opponent1.result !== "win" && match.opponent2.result !== "win") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
maxFinishedAtWB = Math.max(maxFinishedAtWB, match.lastGameFinishedAt ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!byPreviousRound && !maxFinishedAtWB) return null;
|
|
||||||
if (!byPreviousRound) return databaseTimestampToDate(maxFinishedAtWB);
|
|
||||||
if (!maxFinishedAtWB) return byPreviousRound;
|
|
||||||
|
|
||||||
return databaseTimestampToDate(
|
|
||||||
Math.max(dateToDatabaseTimestamp(byPreviousRound), maxFinishedAtWB),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { differenceInSeconds } from "date-fns";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { InfoPopover } from "~/components/InfoPopover";
|
||||||
|
import * as Deadline from "../core/Deadline";
|
||||||
|
|
||||||
|
interface DeadlineInfoPopoverProps {
|
||||||
|
startedAt: Date;
|
||||||
|
bestOf: number;
|
||||||
|
gamesCompleted: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeadlineInfoPopover({
|
||||||
|
startedAt,
|
||||||
|
bestOf,
|
||||||
|
gamesCompleted,
|
||||||
|
}: DeadlineInfoPopoverProps) {
|
||||||
|
const { t } = useTranslation(["tournament"]);
|
||||||
|
const [currentTime, setCurrentTime] = React.useState(new Date());
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setCurrentTime(new Date());
|
||||||
|
}, 5_000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const elapsedMinutes = differenceInSeconds(currentTime, startedAt) / 60;
|
||||||
|
|
||||||
|
const status = Deadline.matchStatus({
|
||||||
|
elapsedMinutes,
|
||||||
|
gamesCompleted,
|
||||||
|
maxGamesCount: bestOf,
|
||||||
|
});
|
||||||
|
|
||||||
|
const warningIndicator =
|
||||||
|
status === "warning" ? (
|
||||||
|
<span className="tournament-bracket__deadline-indicator tournament-bracket__deadline-indicator__warning">
|
||||||
|
!
|
||||||
|
</span>
|
||||||
|
) : status === "error" ? (
|
||||||
|
<span className="tournament-bracket__deadline-indicator tournament-bracket__deadline-indicator__error">
|
||||||
|
!
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tournament-bracket__deadline-popover">
|
||||||
|
<InfoPopover
|
||||||
|
tiny
|
||||||
|
className="tournament-bracket__deadline-popover__trigger"
|
||||||
|
>
|
||||||
|
{t("tournament:match.deadline.explanation")}
|
||||||
|
</InfoPopover>
|
||||||
|
{warningIndicator}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
.progressContainer {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 18px;
|
||||||
|
background-color: var(--bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressBar {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--theme-transparent);
|
||||||
|
transition:
|
||||||
|
width 0.5s ease-in-out,
|
||||||
|
background-color 0.3s ease;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gameMarker {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-1);
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gameMarkerLine {
|
||||||
|
width: 2px;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--text-lighter);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maxTimeMarker {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-1);
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
padding-right: var(--s-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gameMarkerText {
|
||||||
|
font-size: var(--fonts-xxxs);
|
||||||
|
font-weight: var(--semi-bold);
|
||||||
|
color: var(--text-lighter);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gameMarkerHidden .gameMarkerText {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
75
app/features/tournament-bracket/components/MatchTimer.tsx
Normal file
75
app/features/tournament-bracket/components/MatchTimer.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
import { differenceInSeconds } from "date-fns";
|
||||||
|
import * as React from "react";
|
||||||
|
import * as Deadline from "../core/Deadline";
|
||||||
|
import styles from "./MatchTimer.module.css";
|
||||||
|
|
||||||
|
interface MatchTimerProps {
|
||||||
|
startedAt: Date;
|
||||||
|
bestOf: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MatchTimer({ startedAt, bestOf }: MatchTimerProps) {
|
||||||
|
const [currentTime, setCurrentTime] = React.useState(new Date());
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setCurrentTime(new Date());
|
||||||
|
}, 5_000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const elapsedMinutes = differenceInSeconds(currentTime, startedAt) / 60;
|
||||||
|
|
||||||
|
const totalMinutes = Deadline.totalMatchTime(bestOf);
|
||||||
|
const progressPercentage = Deadline.progressPercentage(
|
||||||
|
elapsedMinutes,
|
||||||
|
totalMinutes,
|
||||||
|
);
|
||||||
|
const gameMarkers = Deadline.gameMarkers(bestOf);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="match-timer">
|
||||||
|
<div className={styles.progressContainer}>
|
||||||
|
<div
|
||||||
|
className={styles.progressBar}
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(progressPercentage, 100)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{gameMarkers.map((marker) => (
|
||||||
|
<div
|
||||||
|
key={marker.gameNumber}
|
||||||
|
className={clsx(styles.gameMarker, {
|
||||||
|
[styles.gameMarkerHidden]: marker.gameNumber !== 1,
|
||||||
|
})}
|
||||||
|
style={{ left: `${marker.percentage}%` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clsx(styles.gameMarkerText, styles.gameMarkerLabel)}
|
||||||
|
>
|
||||||
|
G{marker.gameNumber}
|
||||||
|
</div>
|
||||||
|
<div className={styles.gameMarkerLine} />
|
||||||
|
<div
|
||||||
|
className={clsx(styles.gameMarkerText, styles.gameMarkerLabel)}
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className={styles.maxTimeMarker}>
|
||||||
|
<div className={clsx(styles.gameMarkerText, styles.gameMarkerTime)}>
|
||||||
|
Max
|
||||||
|
</div>
|
||||||
|
<div className={clsx(styles.gameMarkerText, styles.gameMarkerTime)}>
|
||||||
|
{totalMinutes}min
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { SerializeFrom } from "@remix-run/node";
|
import type { SerializeFrom } from "@remix-run/node";
|
||||||
import { Form, useLoaderData } from "@remix-run/react";
|
import { Form, useLoaderData } from "@remix-run/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { differenceInMinutes } from "date-fns";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
@ -16,6 +17,7 @@ import { Image } from "~/components/Image";
|
||||||
import { CheckmarkIcon } from "~/components/icons/Checkmark";
|
import { CheckmarkIcon } from "~/components/icons/Checkmark";
|
||||||
import { CrossIcon } from "~/components/icons/Cross";
|
import { CrossIcon } from "~/components/icons/Cross";
|
||||||
import { PickIcon } from "~/components/icons/Pick";
|
import { PickIcon } from "~/components/icons/Pick";
|
||||||
|
import { Label } from "~/components/Label";
|
||||||
import { SubmitButton } from "~/components/SubmitButton";
|
import { SubmitButton } from "~/components/SubmitButton";
|
||||||
import { useUser } from "~/features/auth/core/user";
|
import { useUser } from "~/features/auth/core/user";
|
||||||
import { useChat } from "~/features/chat/chat-hooks";
|
import { useChat } from "~/features/chat/chat-hooks";
|
||||||
|
|
@ -36,6 +38,7 @@ import {
|
||||||
stageImageUrl,
|
stageImageUrl,
|
||||||
} from "~/utils/urls";
|
} from "~/utils/urls";
|
||||||
import type { Bracket } from "../core/Bracket";
|
import type { Bracket } from "../core/Bracket";
|
||||||
|
import * as Deadline from "../core/Deadline";
|
||||||
import * as PickBan from "../core/PickBan";
|
import * as PickBan from "../core/PickBan";
|
||||||
import type { TournamentDataTeam } from "../core/Tournament.server";
|
import type { TournamentDataTeam } from "../core/Tournament.server";
|
||||||
import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server";
|
import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server";
|
||||||
|
|
@ -48,8 +51,10 @@ import {
|
||||||
resolveRoomPass,
|
resolveRoomPass,
|
||||||
tournamentTeamToActiveRosterUserIds,
|
tournamentTeamToActiveRosterUserIds,
|
||||||
} from "../tournament-bracket-utils";
|
} from "../tournament-bracket-utils";
|
||||||
|
import { DeadlineInfoPopover } from "./DeadlineInfoPopover";
|
||||||
import { MatchActions } from "./MatchActions";
|
import { MatchActions } from "./MatchActions";
|
||||||
import { MatchRosters } from "./MatchRosters";
|
import { MatchRosters } from "./MatchRosters";
|
||||||
|
import { MatchTimer } from "./MatchTimer";
|
||||||
|
|
||||||
export type Result = Unpacked<
|
export type Result = Unpacked<
|
||||||
SerializeFrom<TournamentMatchLoaderData>["results"]
|
SerializeFrom<TournamentMatchLoaderData>["results"]
|
||||||
|
|
@ -90,6 +95,8 @@ export function StartedMatch({
|
||||||
(p) => p.id === user?.id,
|
(p) => p.id === user?.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const waitingForPreviousMatch = data.match.status === 0;
|
||||||
|
|
||||||
const hostingTeamId = resolveHostingTeam(teams).id;
|
const hostingTeamId = resolveHostingTeam(teams).id;
|
||||||
const poolCode = React.useMemo(() => {
|
const poolCode = React.useMemo(() => {
|
||||||
const match = tournament.brackets
|
const match = tournament.brackets
|
||||||
|
|
@ -171,6 +178,7 @@ export function StartedMatch({
|
||||||
scores: [scoreOne, scoreTwo],
|
scores: [scoreOne, scoreTwo],
|
||||||
tournament,
|
tournament,
|
||||||
})}
|
})}
|
||||||
|
waitingForPreviousMatch={waitingForPreviousMatch}
|
||||||
>
|
>
|
||||||
{currentPosition > 0 &&
|
{currentPosition > 0 &&
|
||||||
!presentational &&
|
!presentational &&
|
||||||
|
|
@ -208,6 +216,19 @@ export function StartedMatch({
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
|
{tournament.isOrganizer(user) &&
|
||||||
|
!data.matchIsOver &&
|
||||||
|
data.match.startedAt &&
|
||||||
|
Deadline.matchStatus({
|
||||||
|
elapsedMinutes: differenceInMinutes(
|
||||||
|
new Date(),
|
||||||
|
databaseTimestampToDate(data.match.startedAt),
|
||||||
|
),
|
||||||
|
gamesCompleted: scoreOne + scoreTwo,
|
||||||
|
maxGamesCount: data.match.bestOf,
|
||||||
|
}) === "error" ? (
|
||||||
|
<EndSetPopover teams={teams} />
|
||||||
|
) : null}
|
||||||
</FancyStageBanner>
|
</FancyStageBanner>
|
||||||
<ModeProgressIndicator
|
<ModeProgressIndicator
|
||||||
scores={[scoreOne, scoreTwo]}
|
scores={[scoreOne, scoreTwo]}
|
||||||
|
|
@ -215,7 +236,7 @@ export function StartedMatch({
|
||||||
selectedResultIndex={selectedResultIndex}
|
selectedResultIndex={selectedResultIndex}
|
||||||
setSelectedResultIndex={setSelectedResultIndex}
|
setSelectedResultIndex={setSelectedResultIndex}
|
||||||
/>
|
/>
|
||||||
{type === "EDIT" || presentational ? (
|
{!waitingForPreviousMatch && (type === "EDIT" || presentational) ? (
|
||||||
<StartedMatchTabs
|
<StartedMatchTabs
|
||||||
presentational={presentational}
|
presentational={presentational}
|
||||||
scores={[scoreOne, scoreTwo]}
|
scores={[scoreOne, scoreTwo]}
|
||||||
|
|
@ -245,17 +266,22 @@ function FancyStageBanner({
|
||||||
children,
|
children,
|
||||||
teams,
|
teams,
|
||||||
matchIsLocked,
|
matchIsLocked,
|
||||||
|
waitingForPreviousMatch,
|
||||||
}: {
|
}: {
|
||||||
stage?: TournamentMapListMap;
|
stage?: TournamentMapListMap;
|
||||||
infos?: (JSX.Element | null)[];
|
infos?: (JSX.Element | null)[];
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
teams: [TournamentDataTeam, TournamentDataTeam];
|
teams: [TournamentDataTeam, TournamentDataTeam];
|
||||||
matchIsLocked: boolean;
|
matchIsLocked: boolean;
|
||||||
|
waitingForPreviousMatch: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const user = useUser();
|
||||||
const data = useLoaderData<TournamentMatchLoaderData>();
|
const data = useLoaderData<TournamentMatchLoaderData>();
|
||||||
const { t } = useTranslation(["game-misc", "tournament"]);
|
const { t } = useTranslation(["game-misc", "tournament"]);
|
||||||
const tournament = useTournament();
|
const tournament = useTournament();
|
||||||
|
|
||||||
|
const gamesCompleted = data.results.length;
|
||||||
|
|
||||||
const stageNameToBannerImageUrl = (stageId: StageId) => {
|
const stageNameToBannerImageUrl = (stageId: StageId) => {
|
||||||
return `${stageImageUrl(stageId)}.png`;
|
return `${stageImageUrl(stageId)}.png`;
|
||||||
};
|
};
|
||||||
|
|
@ -367,6 +393,17 @@ function FancyStageBanner({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : waitingForPreviousMatch ? (
|
||||||
|
<div className="tournament-bracket__locked-banner">
|
||||||
|
<div className="stack sm items-center">
|
||||||
|
<div className="text-lg text-center font-bold">
|
||||||
|
Previous match ongoing
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Match will be reportable when both teams are ready to play
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : waitingForActiveRosterSelectionFor ? (
|
) : waitingForActiveRosterSelectionFor ? (
|
||||||
<div className="tournament-bracket__locked-banner">
|
<div className="tournament-bracket__locked-banner">
|
||||||
<div className="stack sm items-center">
|
<div className="stack sm items-center">
|
||||||
|
|
@ -383,6 +420,15 @@ function FancyStageBanner({
|
||||||
: waitingForActiveRosterSelectionFor}
|
: waitingForActiveRosterSelectionFor}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{data.match.startedAt &&
|
||||||
|
!tournament.isLeagueDivision &&
|
||||||
|
(waitingForActiveRosterSelectionFor || !stage || inBanPhase) ? (
|
||||||
|
<DeadlineInfoPopover
|
||||||
|
startedAt={databaseTimestampToDate(data.match.startedAt)}
|
||||||
|
bestOf={data.match.bestOf}
|
||||||
|
gamesCompleted={gamesCompleted}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
|
|
@ -417,9 +463,27 @@ function FancyStageBanner({
|
||||||
})}
|
})}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
|
{data.match.startedAt && !data.matchIsOver ? (
|
||||||
|
<DeadlineInfoPopover
|
||||||
|
startedAt={databaseTimestampToDate(data.match.startedAt)}
|
||||||
|
bestOf={data.match.bestOf}
|
||||||
|
gamesCompleted={gamesCompleted}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{(tournament.isOrganizer(user) ||
|
||||||
|
teams.some((t) => t.members.some((m) => m.userId === user?.id))) &&
|
||||||
|
!tournament.isLeagueDivision &&
|
||||||
|
!matchIsLocked &&
|
||||||
|
data.match.startedAt &&
|
||||||
|
!data.matchIsOver ? (
|
||||||
|
<MatchTimer
|
||||||
|
startedAt={databaseTimestampToDate(data.match.startedAt)}
|
||||||
|
bestOf={data.match.bestOf}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{infos && (
|
{infos && (
|
||||||
<div className="tournament-bracket__infos">
|
<div className="tournament-bracket__infos">
|
||||||
{infos.filter(Boolean).map((info, i) => (
|
{infos.filter(Boolean).map((info, i) => (
|
||||||
|
|
@ -790,3 +854,85 @@ function ScreenBanIcons({ banned }: { banned: boolean }) {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EndSetPopover({
|
||||||
|
teams,
|
||||||
|
}: {
|
||||||
|
teams: [TournamentDataTeam, TournamentDataTeam];
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation(["tournament"]);
|
||||||
|
const [selectedWinner, setSelectedWinner] = React.useState<
|
||||||
|
number | null | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SendouPopover
|
||||||
|
placement="top"
|
||||||
|
trigger={
|
||||||
|
<SendouButton
|
||||||
|
variant="minimal"
|
||||||
|
className="tournament-bracket__stage-banner__undo-button tournament-bracket__stage-banner__end-set-button"
|
||||||
|
>
|
||||||
|
{t("tournament:match.action.endSet")}
|
||||||
|
</SendouButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form method="post" className="stack md">
|
||||||
|
<div className="stack sm">
|
||||||
|
<Label className="mx-auto">
|
||||||
|
{t("tournament:match.endSet.selectWinner")}
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<label className="stack horizontal sm items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="winnerSelection"
|
||||||
|
value="random"
|
||||||
|
checked={selectedWinner === null}
|
||||||
|
onChange={() => setSelectedWinner(null)}
|
||||||
|
/>
|
||||||
|
<span>{t("tournament:match.endSet.randomWinner")}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="stack horizontal sm items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="winnerSelection"
|
||||||
|
value={teams[0].id}
|
||||||
|
checked={selectedWinner === teams[0].id}
|
||||||
|
onChange={() => setSelectedWinner(teams[0].id)}
|
||||||
|
/>
|
||||||
|
<span>{teams[0].name}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="stack horizontal sm items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="winnerSelection"
|
||||||
|
value={teams[1].id}
|
||||||
|
checked={selectedWinner === teams[1].id}
|
||||||
|
onChange={() => setSelectedWinner(teams[1].id)}
|
||||||
|
/>
|
||||||
|
<span>{teams[1].name}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="winnerTeamId"
|
||||||
|
value={selectedWinner === null ? "null" : (selectedWinner ?? "")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SubmitButton
|
||||||
|
_action="END_SET"
|
||||||
|
testId="end-set-button"
|
||||||
|
size="miniscule"
|
||||||
|
className="mx-auto"
|
||||||
|
isDisabled={selectedWinner === undefined}
|
||||||
|
>
|
||||||
|
{t("tournament:match.action.confirmEndSet")}
|
||||||
|
</SubmitButton>
|
||||||
|
</Form>
|
||||||
|
</SendouPopover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
436
app/features/tournament-bracket/core/Bracket/Bracket.ts
Normal file
436
app/features/tournament-bracket/core/Bracket/Bracket.ts
Normal file
|
|
@ -0,0 +1,436 @@
|
||||||
|
import { sub } from "date-fns";
|
||||||
|
import * as R from "remeda";
|
||||||
|
import type { Tables, TournamentStageSettings } from "~/db/tables";
|
||||||
|
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
|
||||||
|
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
|
||||||
|
import type { Round } from "~/modules/brackets-model";
|
||||||
|
import invariant from "~/utils/invariant";
|
||||||
|
import { logger } from "~/utils/logger";
|
||||||
|
import { fillWithNullTillPowerOfTwo } from "../../tournament-bracket-utils";
|
||||||
|
import { getTournamentManager } from "../brackets-manager";
|
||||||
|
import * as Progression from "../Progression";
|
||||||
|
import type { OptionalIdObject, Tournament } from "../Tournament";
|
||||||
|
import type { TournamentDataTeam } from "../Tournament.server";
|
||||||
|
import type { BracketMapCounts } from "../toMapList";
|
||||||
|
|
||||||
|
export interface CreateBracketArgs {
|
||||||
|
id: number;
|
||||||
|
idx: number;
|
||||||
|
preview: boolean;
|
||||||
|
data?: TournamentManagerDataSet;
|
||||||
|
type: Tables["TournamentStage"]["type"];
|
||||||
|
canBeStarted?: boolean;
|
||||||
|
name: string;
|
||||||
|
teamsPendingCheckIn?: number[];
|
||||||
|
tournament: Tournament;
|
||||||
|
createdAt?: number | null;
|
||||||
|
sources?: {
|
||||||
|
bracketIdx: number;
|
||||||
|
placements: number[];
|
||||||
|
}[];
|
||||||
|
seeding?: number[];
|
||||||
|
settings: TournamentStageSettings | null;
|
||||||
|
requiresCheckIn: boolean;
|
||||||
|
startTime: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Standing {
|
||||||
|
team: TournamentDataTeam;
|
||||||
|
placement: number;
|
||||||
|
groupId?: number;
|
||||||
|
stats?: {
|
||||||
|
setWins: number;
|
||||||
|
setLosses: number;
|
||||||
|
mapWins: number;
|
||||||
|
mapLosses: number;
|
||||||
|
points: number;
|
||||||
|
winsAgainstTied: number;
|
||||||
|
lossesAgainstTied?: number;
|
||||||
|
opponentSetWinPercentage?: number;
|
||||||
|
opponentMapWinPercentage?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamTrackRecord {
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class Bracket {
|
||||||
|
id;
|
||||||
|
idx;
|
||||||
|
preview;
|
||||||
|
data;
|
||||||
|
simulatedData: TournamentManagerDataSet | undefined;
|
||||||
|
canBeStarted;
|
||||||
|
name;
|
||||||
|
teamsPendingCheckIn;
|
||||||
|
tournament;
|
||||||
|
sources;
|
||||||
|
createdAt;
|
||||||
|
seeding;
|
||||||
|
settings;
|
||||||
|
requiresCheckIn;
|
||||||
|
startTime;
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
id,
|
||||||
|
idx,
|
||||||
|
preview,
|
||||||
|
data,
|
||||||
|
canBeStarted,
|
||||||
|
name,
|
||||||
|
teamsPendingCheckIn,
|
||||||
|
tournament,
|
||||||
|
sources,
|
||||||
|
createdAt,
|
||||||
|
seeding,
|
||||||
|
settings,
|
||||||
|
requiresCheckIn,
|
||||||
|
startTime,
|
||||||
|
}: Omit<CreateBracketArgs, "format">) {
|
||||||
|
if (!data && !seeding) {
|
||||||
|
throw new Error("Bracket: seeding or data required");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.id = id;
|
||||||
|
this.idx = idx;
|
||||||
|
this.preview = preview;
|
||||||
|
this.seeding = seeding;
|
||||||
|
this.tournament = tournament;
|
||||||
|
this.settings = settings;
|
||||||
|
this.data = data ?? this.generateMatchesData(this.seeding!);
|
||||||
|
this.canBeStarted = canBeStarted;
|
||||||
|
this.name = name;
|
||||||
|
this.teamsPendingCheckIn = teamsPendingCheckIn;
|
||||||
|
this.sources = sources;
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
this.requiresCheckIn = requiresCheckIn;
|
||||||
|
this.startTime = startTime;
|
||||||
|
|
||||||
|
if (this.tournament.simulateBrackets) {
|
||||||
|
this.createdSimulation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createdSimulation() {
|
||||||
|
if (
|
||||||
|
this.type === "round_robin" ||
|
||||||
|
this.type === "swiss" ||
|
||||||
|
this.preview ||
|
||||||
|
this.tournament.ctx.isFinalized
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manager = getTournamentManager();
|
||||||
|
|
||||||
|
manager.import(this.data);
|
||||||
|
|
||||||
|
const teamOrder = this.teamOrderForSimulation();
|
||||||
|
|
||||||
|
let matchesToResolve = true;
|
||||||
|
let loopCount = 0;
|
||||||
|
while (matchesToResolve) {
|
||||||
|
if (loopCount > 100) {
|
||||||
|
logger.error("Bracket.createdSimulation: loopCount > 100");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
matchesToResolve = false;
|
||||||
|
loopCount++;
|
||||||
|
|
||||||
|
for (const match of manager.export().match) {
|
||||||
|
if (!match) continue;
|
||||||
|
// we have a result already
|
||||||
|
if (
|
||||||
|
match.opponent1?.result === "win" ||
|
||||||
|
match.opponent2?.result === "win"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// no opponent yet, let's simulate this in a coming loop
|
||||||
|
if (
|
||||||
|
(match.opponent1 && !match.opponent1.id) ||
|
||||||
|
(match.opponent2 && !match.opponent2.id)
|
||||||
|
) {
|
||||||
|
const isBracketReset =
|
||||||
|
this.type === "double_elimination" &&
|
||||||
|
match.id === this.data.match[this.data.match.length - 1].id;
|
||||||
|
|
||||||
|
if (!isBracketReset) {
|
||||||
|
matchesToResolve = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// BYE
|
||||||
|
if (match.opponent1 === null || match.opponent2 === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const winner =
|
||||||
|
(teamOrder.get(match.opponent1.id!) ?? 0) <
|
||||||
|
(teamOrder.get(match.opponent2.id!) ?? 0)
|
||||||
|
? 1
|
||||||
|
: 2;
|
||||||
|
|
||||||
|
manager.update.match({
|
||||||
|
id: match.id,
|
||||||
|
opponent1: {
|
||||||
|
score: winner === 1 ? 1 : 0,
|
||||||
|
result: winner === 1 ? "win" : undefined,
|
||||||
|
},
|
||||||
|
opponent2: {
|
||||||
|
score: winner === 2 ? 1 : 0,
|
||||||
|
result: winner === 2 ? "win" : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.simulatedData = manager.export();
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Bracket.createdSimulation: ", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private teamOrderForSimulation() {
|
||||||
|
const result = new Map(this.tournament.ctx.teams.map((t, i) => [t.id, i]));
|
||||||
|
|
||||||
|
for (const match of this.data.match) {
|
||||||
|
if (
|
||||||
|
!match.opponent1?.id ||
|
||||||
|
!match.opponent2?.id ||
|
||||||
|
(match.opponent1?.result !== "win" && match.opponent2?.result !== "win")
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const opponent1Seed = result.get(match.opponent1.id) ?? -1;
|
||||||
|
const opponent2Seed = result.get(match.opponent2.id) ?? -1;
|
||||||
|
if (opponent1Seed === -1 || opponent2Seed === -1) {
|
||||||
|
logger.error("opponent1Seed or opponent2Seed not found");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opponent1Seed < opponent2Seed && match.opponent1?.result === "win") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opponent2Seed < opponent1Seed && match.opponent2?.result === "win") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opponent1Seed < opponent2Seed) {
|
||||||
|
result.set(match.opponent1.id, opponent1Seed + 0.1);
|
||||||
|
result.set(match.opponent2.id, opponent1Seed);
|
||||||
|
} else {
|
||||||
|
result.set(match.opponent2.id, opponent2Seed + 0.1);
|
||||||
|
result.set(match.opponent1.id, opponent2Seed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
simulatedMatch(matchId: number) {
|
||||||
|
if (!this.simulatedData) return;
|
||||||
|
|
||||||
|
return this.simulatedData.match
|
||||||
|
.filter(Boolean)
|
||||||
|
.find((match) => match.id === matchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
get collectResultsWithPoints() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get type(): Tables["TournamentStage"]["type"] {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
get standings(): Standing[] {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
get participantTournamentTeamIds() {
|
||||||
|
return R.unique(
|
||||||
|
this.data.match
|
||||||
|
.flatMap((match) => [match.opponent1?.id, match.opponent2?.id])
|
||||||
|
.filter(Boolean),
|
||||||
|
) as number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
currentStandings(_includeUnfinishedGroups: boolean) {
|
||||||
|
return this.standings;
|
||||||
|
}
|
||||||
|
|
||||||
|
winnersSourceRound(_roundNumber: number): Round | undefined {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if this bracket is a starting bracket (i.e., teams in it start their tournament from this bracket). Note: there can be more than one starting bracket. */
|
||||||
|
get isStartingBracket() {
|
||||||
|
return !this.sources || this.sources.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected standingsWithoutNonParticipants(standings: Standing[]): Standing[] {
|
||||||
|
return standings.map((standing) => {
|
||||||
|
return {
|
||||||
|
...standing,
|
||||||
|
team: {
|
||||||
|
...standing.team,
|
||||||
|
members: standing.team.members.filter((member) =>
|
||||||
|
this.tournament.ctx.participatedUsers.includes(member.userId),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
generateMatchesData(teams: number[]) {
|
||||||
|
const manager = getTournamentManager();
|
||||||
|
|
||||||
|
const virtualTournamentId = 1;
|
||||||
|
|
||||||
|
if (teams.length >= TOURNAMENT.ENOUGH_TEAMS_TO_START) {
|
||||||
|
manager.create({
|
||||||
|
tournamentId: virtualTournamentId,
|
||||||
|
name: "Virtual",
|
||||||
|
type: this.type,
|
||||||
|
seeding:
|
||||||
|
this.type === "round_robin"
|
||||||
|
? teams
|
||||||
|
: fillWithNullTillPowerOfTwo(teams),
|
||||||
|
settings: this.tournament.bracketManagerSettings(
|
||||||
|
this.settings,
|
||||||
|
this.type,
|
||||||
|
teams.length,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return manager.get.tournamentData(virtualTournamentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isUnderground() {
|
||||||
|
return Progression.isUnderground(
|
||||||
|
this.idx,
|
||||||
|
this.tournament.ctx.settings.bracketProgression,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isFinals() {
|
||||||
|
return Progression.isFinals(
|
||||||
|
this.idx,
|
||||||
|
this.tournament.ctx.settings.bracketProgression,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get everyMatchOver() {
|
||||||
|
if (this.preview) return false;
|
||||||
|
|
||||||
|
for (const match of this.data.match) {
|
||||||
|
// BYE
|
||||||
|
if (match.opponent1 === null || match.opponent2 === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
match.opponent1?.result !== "win" &&
|
||||||
|
match.opponent2?.result !== "win"
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get enoughTeams() {
|
||||||
|
return (
|
||||||
|
this.participantTournamentTeamIds.length >=
|
||||||
|
TOURNAMENT.ENOUGH_TEAMS_TO_START
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
canCheckIn(user: OptionalIdObject) {
|
||||||
|
// using regular check-in
|
||||||
|
if (!this.teamsPendingCheckIn) return false;
|
||||||
|
|
||||||
|
if (this.startTime) {
|
||||||
|
const checkInOpen =
|
||||||
|
sub(this.startTime.getTime(), { hours: 1 }).getTime() < Date.now() &&
|
||||||
|
this.startTime.getTime() > Date.now();
|
||||||
|
|
||||||
|
if (!checkInOpen) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = this.tournament.ownedTeamByUser(user);
|
||||||
|
if (!team) return false;
|
||||||
|
|
||||||
|
return this.teamsPendingCheckIn.includes(team.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
source(_options: { placements: number[]; advanceThreshold?: number }): {
|
||||||
|
relevantMatchesFinished: boolean;
|
||||||
|
teams: number[];
|
||||||
|
} {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
teamsWithNames(teams: { id: number }[]) {
|
||||||
|
return teams.map((team) => {
|
||||||
|
const name = this.tournament.ctx.teams.find(
|
||||||
|
(participant) => participant.id === team.id,
|
||||||
|
)?.name;
|
||||||
|
invariant(name, `Team name not found for id: ${team.id}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: team.id,
|
||||||
|
name,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns match IDs that are currently ongoing (ready to start).
|
||||||
|
* A match is ongoing when:
|
||||||
|
* - Both teams are defined
|
||||||
|
* - No team has an earlier match (lower number) currently in progress
|
||||||
|
* - Match is not completed
|
||||||
|
*/
|
||||||
|
ongoingMatches(): number[] {
|
||||||
|
const ongoingMatchIds: number[] = [];
|
||||||
|
|
||||||
|
const teamsWithOngoingMatches = new Set<number>();
|
||||||
|
|
||||||
|
for (const match of this.data.match.toSorted(
|
||||||
|
(a, b) => a.number - b.number,
|
||||||
|
)) {
|
||||||
|
if (!match.opponent1?.id || !match.opponent2?.id) continue;
|
||||||
|
if (
|
||||||
|
match.opponent1.result === "win" ||
|
||||||
|
match.opponent2.result === "win"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
teamsWithOngoingMatches.has(match.opponent1.id) ||
|
||||||
|
teamsWithOngoingMatches.has(match.opponent2.id)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ongoingMatchIds.push(match.id);
|
||||||
|
teamsWithOngoingMatches.add(match.opponent1.id);
|
||||||
|
teamsWithOngoingMatches.add(match.opponent2.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ongoingMatchIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultRoundBestOfs(_data: TournamentManagerDataSet): BracketMapCounts {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,310 @@
|
||||||
|
import * as R from "remeda";
|
||||||
|
import type { Tables } from "~/db/tables";
|
||||||
|
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
|
||||||
|
import type { Round } from "~/modules/brackets-model";
|
||||||
|
import invariant from "~/utils/invariant";
|
||||||
|
import type { BracketMapCounts } from "../toMapList";
|
||||||
|
import { Bracket, type Standing } from "./Bracket";
|
||||||
|
|
||||||
|
export class DoubleEliminationBracket extends Bracket {
|
||||||
|
get type(): Tables["TournamentStage"]["type"] {
|
||||||
|
return "double_elimination";
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultRoundBestOfs(data: TournamentManagerDataSet) {
|
||||||
|
const result: BracketMapCounts = new Map();
|
||||||
|
|
||||||
|
for (const group of data.group) {
|
||||||
|
const roundsOfGroup = data.round.filter(
|
||||||
|
(round) => round.group_id === group.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultOfRound = (round: Round) => {
|
||||||
|
if (group.number === 3) return 5;
|
||||||
|
if (group.number === 2) {
|
||||||
|
const lastRoundNumber = Math.max(
|
||||||
|
...roundsOfGroup.map((round) => round.number),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (round.number === lastRoundNumber) return 5;
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (round.number > 2) return 5;
|
||||||
|
return 3;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const round of roundsOfGroup) {
|
||||||
|
const atLeastOneNonByeMatch = data.match.some(
|
||||||
|
(match) =>
|
||||||
|
match.round_id === round.id && match.opponent1 && match.opponent2,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!atLeastOneNonByeMatch) continue;
|
||||||
|
|
||||||
|
if (!result.get(group.id)) {
|
||||||
|
result.set(group.id, new Map());
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
.get(group.id)!
|
||||||
|
.set(round.number, { count: defaultOfRound(round), type: "BEST_OF" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
winnersSourceRound(roundNumber: number) {
|
||||||
|
const isMajorRound = roundNumber === 1 || roundNumber % 2 === 0;
|
||||||
|
if (!isMajorRound) return;
|
||||||
|
|
||||||
|
const roundNumberWB = Math.ceil((roundNumber + 1) / 2);
|
||||||
|
|
||||||
|
const groupIdWB = this.data.group.find((g) => g.number === 1)?.id;
|
||||||
|
|
||||||
|
return this.data.round.find(
|
||||||
|
(round) => round.number === roundNumberWB && round.group_id === groupIdWB,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get standings(): Standing[] {
|
||||||
|
if (!this.enoughTeams) return [];
|
||||||
|
|
||||||
|
const losersGroupId = this.data.group.find((g) => g.number === 2)?.id;
|
||||||
|
|
||||||
|
const teams: { id: number; lostAt: number }[] = [];
|
||||||
|
|
||||||
|
for (const match of this.data.match
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.round_id - b.round_id)) {
|
||||||
|
if (match.group_id !== losersGroupId) continue;
|
||||||
|
|
||||||
|
if (
|
||||||
|
match.opponent1?.result !== "win" &&
|
||||||
|
match.opponent2?.result !== "win"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BYE
|
||||||
|
if (!match.opponent1 || !match.opponent2) continue;
|
||||||
|
|
||||||
|
const loser =
|
||||||
|
match.opponent1?.result === "win" ? match.opponent2 : match.opponent1;
|
||||||
|
invariant(loser?.id, "Loser id not found");
|
||||||
|
|
||||||
|
teams.push({ id: loser.id, lostAt: match.round_id });
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamCountWhoDidntLoseInLosersYet =
|
||||||
|
this.participantTournamentTeamIds.length - teams.length;
|
||||||
|
|
||||||
|
const result: Standing[] = [];
|
||||||
|
for (const roundId of R.unique(teams.map((team) => team.lostAt))) {
|
||||||
|
const teamsLostThisRound: { id: number }[] = [];
|
||||||
|
while (teams.length && teams[0].lostAt === roundId) {
|
||||||
|
teamsLostThisRound.push(teams.shift()!);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { id: teamId } of teamsLostThisRound) {
|
||||||
|
const team = this.tournament.teamById(teamId);
|
||||||
|
invariant(team, `Team not found for id: ${teamId}`);
|
||||||
|
|
||||||
|
const teamsPlacedAbove =
|
||||||
|
teamCountWhoDidntLoseInLosersYet + teams.length;
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
team,
|
||||||
|
placement: teamsPlacedAbove + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// edge case: 1 match only
|
||||||
|
const noLosersRounds = !losersGroupId;
|
||||||
|
const grandFinalsNumber = noLosersRounds ? 1 : 3;
|
||||||
|
const grandFinalsGroupId = this.data.group.find(
|
||||||
|
(g) => g.number === grandFinalsNumber,
|
||||||
|
)?.id;
|
||||||
|
invariant(grandFinalsGroupId, "GF group not found");
|
||||||
|
const grandFinalMatches = this.data.match.filter(
|
||||||
|
(match) => match.group_id === grandFinalsGroupId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// if opponent1 won in DE it means that bracket reset is not played
|
||||||
|
if (
|
||||||
|
grandFinalMatches[0].opponent1 &&
|
||||||
|
(noLosersRounds || grandFinalMatches[0].opponent1.result === "win")
|
||||||
|
) {
|
||||||
|
const loser =
|
||||||
|
grandFinalMatches[0].opponent1.result === "win"
|
||||||
|
? "opponent2"
|
||||||
|
: "opponent1";
|
||||||
|
const winner = loser === "opponent1" ? "opponent2" : "opponent1";
|
||||||
|
|
||||||
|
const loserTeam = this.tournament.teamById(
|
||||||
|
grandFinalMatches[0][loser]!.id!,
|
||||||
|
);
|
||||||
|
invariant(loserTeam, "Loser team not found");
|
||||||
|
const winnerTeam = this.tournament.teamById(
|
||||||
|
grandFinalMatches[0][winner]!.id!,
|
||||||
|
);
|
||||||
|
invariant(winnerTeam, "Winner team not found");
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
team: loserTeam,
|
||||||
|
placement: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
team: winnerTeam,
|
||||||
|
placement: 1,
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
grandFinalMatches[1].opponent1?.result === "win" ||
|
||||||
|
grandFinalMatches[1].opponent2?.result === "win"
|
||||||
|
) {
|
||||||
|
const loser =
|
||||||
|
grandFinalMatches[1].opponent1?.result === "win"
|
||||||
|
? "opponent2"
|
||||||
|
: "opponent1";
|
||||||
|
const winner = loser === "opponent1" ? "opponent2" : "opponent1";
|
||||||
|
|
||||||
|
const loserTeam = this.tournament.teamById(
|
||||||
|
grandFinalMatches[1][loser]!.id!,
|
||||||
|
);
|
||||||
|
invariant(loserTeam, "Loser team not found");
|
||||||
|
const winnerTeam = this.tournament.teamById(
|
||||||
|
grandFinalMatches[1][winner]!.id!,
|
||||||
|
);
|
||||||
|
invariant(winnerTeam, "Winner team not found");
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
team: loserTeam,
|
||||||
|
placement: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
team: winnerTeam,
|
||||||
|
placement: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.standingsWithoutNonParticipants(result.reverse());
|
||||||
|
}
|
||||||
|
|
||||||
|
get everyMatchOver() {
|
||||||
|
if (this.preview) return false;
|
||||||
|
|
||||||
|
let lastWinner = -1;
|
||||||
|
for (const [i, match] of this.data.match.entries()) {
|
||||||
|
// special case - bracket reset might not be played depending on who wins in the grands
|
||||||
|
const isLast = i === this.data.match.length - 1;
|
||||||
|
if (isLast && lastWinner === 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// BYE
|
||||||
|
if (match.opponent1 === null || match.opponent2 === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
match.opponent1?.result !== "win" &&
|
||||||
|
match.opponent2?.result !== "win"
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastWinner = match.opponent1?.result === "win" ? 1 : 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
source({ placements }: { placements: number[] }) {
|
||||||
|
invariant(placements.length > 0, "Empty placements not supported");
|
||||||
|
const resolveLosersGroupId = (data: TournamentManagerDataSet) => {
|
||||||
|
const minGroupId = Math.min(...data.round.map((round) => round.group_id));
|
||||||
|
|
||||||
|
return minGroupId + 1;
|
||||||
|
};
|
||||||
|
const placementsToRoundsIds = (
|
||||||
|
data: TournamentManagerDataSet,
|
||||||
|
losersGroupId: number,
|
||||||
|
) => {
|
||||||
|
const firstRoundIsOnlyByes = () => {
|
||||||
|
const losersMatches = data.match.filter(
|
||||||
|
(match) => match.group_id === losersGroupId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const fistRoundId = Math.min(...losersMatches.map((m) => m.round_id));
|
||||||
|
|
||||||
|
const firstRoundMatches = losersMatches.filter(
|
||||||
|
(match) => match.round_id === fistRoundId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return firstRoundMatches.every(
|
||||||
|
(match) => match.opponent1 === null || match.opponent2 === null,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const losersRounds = data.round.filter(
|
||||||
|
(round) => round.group_id === losersGroupId,
|
||||||
|
);
|
||||||
|
const orderedRoundsIds = losersRounds
|
||||||
|
.map((round) => round.id)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
const amountOfRounds =
|
||||||
|
Math.abs(Math.min(...placements)) + (firstRoundIsOnlyByes() ? 1 : 0);
|
||||||
|
|
||||||
|
return orderedRoundsIds.slice(0, amountOfRounds);
|
||||||
|
};
|
||||||
|
|
||||||
|
invariant(
|
||||||
|
placements.every((placement) => placement < 0),
|
||||||
|
"Positive placements in DE not implemented",
|
||||||
|
);
|
||||||
|
|
||||||
|
const losersGroupId = resolveLosersGroupId(this.data);
|
||||||
|
const sourceRoundsIds = placementsToRoundsIds(
|
||||||
|
this.data,
|
||||||
|
losersGroupId,
|
||||||
|
).sort(
|
||||||
|
// teams who made it further in the bracket get higher seed
|
||||||
|
(a, b) => b - a,
|
||||||
|
);
|
||||||
|
|
||||||
|
const teams: number[] = [];
|
||||||
|
let relevantMatchesFinished = true;
|
||||||
|
for (const roundId of sourceRoundsIds) {
|
||||||
|
const roundsMatches = this.data.match.filter(
|
||||||
|
(match) => match.round_id === roundId,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const match of roundsMatches) {
|
||||||
|
// BYE
|
||||||
|
if (!match.opponent1 || !match.opponent2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
match.opponent1?.result !== "win" &&
|
||||||
|
match.opponent2?.result !== "win"
|
||||||
|
) {
|
||||||
|
relevantMatchesFinished = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loser =
|
||||||
|
match.opponent1?.result === "win" ? match.opponent2 : match.opponent1;
|
||||||
|
invariant(loser?.id, "Loser id not found");
|
||||||
|
|
||||||
|
teams.push(loser.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
relevantMatchesFinished,
|
||||||
|
teams,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,277 @@
|
||||||
|
import * as R from "remeda";
|
||||||
|
import type { Tables } from "~/db/tables";
|
||||||
|
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
|
||||||
|
import invariant from "~/utils/invariant";
|
||||||
|
import { logger } from "~/utils/logger";
|
||||||
|
import type { BracketMapCounts } from "../toMapList";
|
||||||
|
import { Bracket, type Standing } from "./Bracket";
|
||||||
|
|
||||||
|
export class RoundRobinBracket extends Bracket {
|
||||||
|
get collectResultsWithPoints() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
source({ placements }: { placements: number[] }): {
|
||||||
|
relevantMatchesFinished: boolean;
|
||||||
|
teams: number[];
|
||||||
|
} {
|
||||||
|
invariant(placements.length > 0, "Empty placements not supported");
|
||||||
|
if (placements.some((p) => p < 0)) {
|
||||||
|
throw new Error("Negative placements not implemented");
|
||||||
|
}
|
||||||
|
const standings = this.standings;
|
||||||
|
const relevantMatchesFinished =
|
||||||
|
standings.length === this.participantTournamentTeamIds.length;
|
||||||
|
|
||||||
|
const uniquePlacements = R.unique(standings.map((s) => s.placement));
|
||||||
|
|
||||||
|
// 1,3,5 -> 1,2,3 e.g.
|
||||||
|
const placementNormalized = (p: number) => {
|
||||||
|
return uniquePlacements.indexOf(p) + 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
relevantMatchesFinished,
|
||||||
|
teams: standings
|
||||||
|
.filter((s) => placements.includes(placementNormalized(s.placement)))
|
||||||
|
.map((s) => s.team.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get standings(): Standing[] {
|
||||||
|
return this.currentStandings();
|
||||||
|
}
|
||||||
|
|
||||||
|
currentStandings(includeUnfinishedGroups = false) {
|
||||||
|
const groupIds = this.data.group.map((group) => group.id);
|
||||||
|
|
||||||
|
const placements: (Standing & { groupId: number })[] = [];
|
||||||
|
for (const groupId of groupIds) {
|
||||||
|
const matches = this.data.match.filter(
|
||||||
|
(match) => match.group_id === groupId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupIsFinished = matches.every(
|
||||||
|
(match) =>
|
||||||
|
// BYE
|
||||||
|
match.opponent1 === null ||
|
||||||
|
match.opponent2 === null ||
|
||||||
|
// match was played out
|
||||||
|
match.opponent1?.result === "win" ||
|
||||||
|
match.opponent2?.result === "win",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!groupIsFinished && !includeUnfinishedGroups) continue;
|
||||||
|
|
||||||
|
const teams: {
|
||||||
|
id: number;
|
||||||
|
setWins: number;
|
||||||
|
setLosses: number;
|
||||||
|
mapWins: number;
|
||||||
|
mapLosses: number;
|
||||||
|
winsAgainstTied: number;
|
||||||
|
points: number;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
const updateTeam = ({
|
||||||
|
teamId,
|
||||||
|
setWins,
|
||||||
|
setLosses,
|
||||||
|
mapWins,
|
||||||
|
mapLosses,
|
||||||
|
points,
|
||||||
|
}: {
|
||||||
|
teamId: number;
|
||||||
|
setWins: number;
|
||||||
|
setLosses: number;
|
||||||
|
mapWins: number;
|
||||||
|
mapLosses: number;
|
||||||
|
points: number;
|
||||||
|
}) => {
|
||||||
|
const team = teams.find((team) => team.id === teamId);
|
||||||
|
if (team) {
|
||||||
|
team.setWins += setWins;
|
||||||
|
team.setLosses += setLosses;
|
||||||
|
team.mapWins += mapWins;
|
||||||
|
team.mapLosses += mapLosses;
|
||||||
|
team.points += points;
|
||||||
|
} else {
|
||||||
|
teams.push({
|
||||||
|
id: teamId,
|
||||||
|
setWins,
|
||||||
|
setLosses,
|
||||||
|
mapWins,
|
||||||
|
mapLosses,
|
||||||
|
winsAgainstTied: 0,
|
||||||
|
points,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
if (
|
||||||
|
match.opponent1?.result !== "win" &&
|
||||||
|
match.opponent2?.result !== "win"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const winner =
|
||||||
|
match.opponent1?.result === "win" ? match.opponent1 : match.opponent2;
|
||||||
|
|
||||||
|
const loser =
|
||||||
|
match.opponent1?.result === "win" ? match.opponent2 : match.opponent1;
|
||||||
|
|
||||||
|
if (!winner || !loser) continue;
|
||||||
|
|
||||||
|
invariant(
|
||||||
|
typeof winner.id === "number" &&
|
||||||
|
typeof loser.id === "number" &&
|
||||||
|
"RoundRobinBracket.standings: winner or loser id not found",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof winner.totalPoints !== "number" ||
|
||||||
|
typeof loser.totalPoints !== "number"
|
||||||
|
) {
|
||||||
|
logger.warn(
|
||||||
|
"RoundRobinBracket.standings: winner or loser points not found",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// note: score might be missing in the case the set was ended early. In the future we might want to handle this differently than defaulting both to 0.
|
||||||
|
|
||||||
|
updateTeam({
|
||||||
|
teamId: winner.id,
|
||||||
|
setWins: 1,
|
||||||
|
setLosses: 0,
|
||||||
|
mapWins: winner.score ?? 0,
|
||||||
|
mapLosses: loser.score ?? 0,
|
||||||
|
points: winner.totalPoints ?? 0,
|
||||||
|
});
|
||||||
|
updateTeam({
|
||||||
|
teamId: loser.id,
|
||||||
|
setWins: 0,
|
||||||
|
setLosses: 1,
|
||||||
|
mapWins: loser.score ?? 0,
|
||||||
|
mapLosses: winner.score ?? 0,
|
||||||
|
points: loser.totalPoints ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const team of teams) {
|
||||||
|
for (const team2 of teams) {
|
||||||
|
if (team.id === team2.id) continue;
|
||||||
|
if (team.setWins !== team2.setWins) continue;
|
||||||
|
|
||||||
|
// they are different teams and are tied, let's check who won
|
||||||
|
|
||||||
|
const wonTheirMatch = matches.some(
|
||||||
|
(match) =>
|
||||||
|
(match.opponent1?.id === team.id &&
|
||||||
|
match.opponent2?.id === team2.id &&
|
||||||
|
match.opponent1?.result === "win") ||
|
||||||
|
(match.opponent1?.id === team2.id &&
|
||||||
|
match.opponent2?.id === team.id &&
|
||||||
|
match.opponent2?.result === "win"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (wonTheirMatch) {
|
||||||
|
team.winsAgainstTied++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
placements.push(
|
||||||
|
...teams
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.setWins > b.setWins) return -1;
|
||||||
|
if (a.setWins < b.setWins) return 1;
|
||||||
|
|
||||||
|
if (a.winsAgainstTied > b.winsAgainstTied) return -1;
|
||||||
|
if (a.winsAgainstTied < b.winsAgainstTied) return 1;
|
||||||
|
|
||||||
|
if (a.mapWins > b.mapWins) return -1;
|
||||||
|
if (a.mapWins < b.mapWins) return 1;
|
||||||
|
|
||||||
|
if (a.mapLosses < b.mapLosses) return -1;
|
||||||
|
if (a.mapLosses > b.mapLosses) return 1;
|
||||||
|
|
||||||
|
if (a.points > b.points) return -1;
|
||||||
|
if (a.points < b.points) return 1;
|
||||||
|
|
||||||
|
const aSeed = Number(this.tournament.teamById(a.id)?.seed);
|
||||||
|
const bSeed = Number(this.tournament.teamById(b.id)?.seed);
|
||||||
|
|
||||||
|
if (aSeed < bSeed) return -1;
|
||||||
|
if (aSeed > bSeed) return 1;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
})
|
||||||
|
.map((team, i) => {
|
||||||
|
return {
|
||||||
|
team: this.tournament.teamById(team.id)!,
|
||||||
|
placement: i + 1,
|
||||||
|
groupId,
|
||||||
|
stats: {
|
||||||
|
setWins: team.setWins,
|
||||||
|
setLosses: team.setLosses,
|
||||||
|
mapWins: team.mapWins,
|
||||||
|
mapLosses: team.mapLosses,
|
||||||
|
points: team.points,
|
||||||
|
winsAgainstTied: team.winsAgainstTied,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = placements.sort((a, b) => {
|
||||||
|
if (a.placement < b.placement) return -1;
|
||||||
|
if (a.placement > b.placement) return 1;
|
||||||
|
|
||||||
|
if (a.groupId < b.groupId) return -1;
|
||||||
|
if (a.groupId > b.groupId) return 1;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
let lastPlacement = 0;
|
||||||
|
let currentPlacement = 1;
|
||||||
|
let teamsEncountered = 0;
|
||||||
|
return this.standingsWithoutNonParticipants(
|
||||||
|
sorted.map((team) => {
|
||||||
|
if (team.placement !== lastPlacement) {
|
||||||
|
lastPlacement = team.placement;
|
||||||
|
currentPlacement = teamsEncountered + 1;
|
||||||
|
}
|
||||||
|
teamsEncountered++;
|
||||||
|
return {
|
||||||
|
...team,
|
||||||
|
placement: currentPlacement,
|
||||||
|
stats: team.stats,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get type(): Tables["TournamentStage"]["type"] {
|
||||||
|
return "round_robin";
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultRoundBestOfs(data: TournamentManagerDataSet) {
|
||||||
|
const result: BracketMapCounts = new Map();
|
||||||
|
|
||||||
|
for (const round of data.round) {
|
||||||
|
if (!result.get(round.group_id)) {
|
||||||
|
result.set(round.group_id, new Map());
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
.get(round.group_id)!
|
||||||
|
.set(round.number, { count: 3, type: "BEST_OF" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
import * as R from "remeda";
|
||||||
|
import type { Tables } from "~/db/tables";
|
||||||
|
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
|
||||||
|
import type { Round } from "~/modules/brackets-model";
|
||||||
|
import invariant from "~/utils/invariant";
|
||||||
|
import type { BracketMapCounts } from "../toMapList";
|
||||||
|
import { Bracket, type Standing } from "./Bracket";
|
||||||
|
|
||||||
|
export class SingleEliminationBracket extends Bracket {
|
||||||
|
get type(): Tables["TournamentStage"]["type"] {
|
||||||
|
return "single_elimination";
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultRoundBestOfs(data: TournamentManagerDataSet) {
|
||||||
|
const result: BracketMapCounts = new Map();
|
||||||
|
|
||||||
|
const maxRoundNumber = Math.max(...data.round.map((round) => round.number));
|
||||||
|
for (const group of data.group) {
|
||||||
|
const roundsOfGroup = data.round.filter(
|
||||||
|
(round) => round.group_id === group.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultOfRound = (round: Round) => {
|
||||||
|
// 3rd place match
|
||||||
|
if (group.number === 2) return 5;
|
||||||
|
|
||||||
|
if (round.number > 2) return 5;
|
||||||
|
|
||||||
|
// small brackets
|
||||||
|
if (
|
||||||
|
round.number === maxRoundNumber ||
|
||||||
|
round.number === maxRoundNumber - 1
|
||||||
|
) {
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
return 3;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const round of roundsOfGroup) {
|
||||||
|
const atLeastOneNonByeMatch = data.match.some(
|
||||||
|
(match) =>
|
||||||
|
match.round_id === round.id && match.opponent1 && match.opponent2,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!atLeastOneNonByeMatch) continue;
|
||||||
|
|
||||||
|
if (!result.get(group.id)) {
|
||||||
|
result.set(group.id, new Map());
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
.get(group.id)!
|
||||||
|
.set(round.number, { count: defaultOfRound(round), type: "BEST_OF" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasThirdPlaceMatch() {
|
||||||
|
return R.unique(this.data.match.map((m) => m.group_id)).length > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
get standings(): Standing[] {
|
||||||
|
const teams: { id: number; lostAt: number }[] = [];
|
||||||
|
|
||||||
|
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"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loser =
|
||||||
|
match.opponent1?.result === "win" ? match.opponent2 : match.opponent1;
|
||||||
|
invariant(loser?.id, "Loser id not found");
|
||||||
|
|
||||||
|
teams.push({ id: loser.id, lostAt: match.round_id });
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamCountWhoDidntLoseYet =
|
||||||
|
this.participantTournamentTeamIds.length - teams.length;
|
||||||
|
|
||||||
|
const result: Standing[] = [];
|
||||||
|
for (const roundId of R.unique(teams.map((team) => team.lostAt))) {
|
||||||
|
const teamsLostThisRound: { id: number }[] = [];
|
||||||
|
while (teams.length && teams[0].lostAt === roundId) {
|
||||||
|
teamsLostThisRound.push(teams.shift()!);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { id: teamId } of teamsLostThisRound) {
|
||||||
|
const team = this.tournament.teamById(teamId);
|
||||||
|
invariant(team, `Team not found for id: ${teamId}`);
|
||||||
|
|
||||||
|
const teamsPlacedAbove = teamCountWhoDidntLoseYet + teams.length;
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
team,
|
||||||
|
placement: teamsPlacedAbove + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (teamCountWhoDidntLoseYet === 1) {
|
||||||
|
const winnerId = this.participantTournamentTeamIds.find((participantId) =>
|
||||||
|
result.every(({ team }) => team.id !== participantId),
|
||||||
|
);
|
||||||
|
invariant(winnerId, "No winner identified");
|
||||||
|
|
||||||
|
const winnerTeam = this.tournament.teamById(winnerId);
|
||||||
|
invariant(winnerTeam, `Winner team not found for id: ${winnerId}`);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
team: winnerTeam,
|
||||||
|
placement: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
503
app/features/tournament-bracket/core/Bracket/SwissBracket.ts
Normal file
503
app/features/tournament-bracket/core/Bracket/SwissBracket.ts
Normal file
|
|
@ -0,0 +1,503 @@
|
||||||
|
import * as R from "remeda";
|
||||||
|
import type { Tables } from "~/db/tables";
|
||||||
|
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
|
||||||
|
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
|
||||||
|
import invariant from "~/utils/invariant";
|
||||||
|
import { logger } from "~/utils/logger";
|
||||||
|
import { cutToNDecimalPlaces } from "../../../../utils/number";
|
||||||
|
import { calculateTeamStatus } from "../Swiss";
|
||||||
|
import type { BracketMapCounts } from "../toMapList";
|
||||||
|
import { Bracket, type Standing, type TeamTrackRecord } from "./Bracket";
|
||||||
|
|
||||||
|
export class SwissBracket extends Bracket {
|
||||||
|
get collectResultsWithPoints() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
source({
|
||||||
|
placements,
|
||||||
|
advanceThreshold,
|
||||||
|
}: {
|
||||||
|
placements: number[];
|
||||||
|
advanceThreshold?: number;
|
||||||
|
}): {
|
||||||
|
relevantMatchesFinished: boolean;
|
||||||
|
teams: number[];
|
||||||
|
} {
|
||||||
|
invariant(
|
||||||
|
advanceThreshold || placements.length > 0,
|
||||||
|
"Placements or advanceThreshold required",
|
||||||
|
);
|
||||||
|
if (placements.some((p) => p < 0)) {
|
||||||
|
throw new Error("Negative placements not implemented");
|
||||||
|
}
|
||||||
|
const standings = this.standings;
|
||||||
|
|
||||||
|
const relevantMatchesFinished = this.data.round.every((round) => {
|
||||||
|
const roundsMatches = this.data.match.filter(
|
||||||
|
(match) => match.round_id === round.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
// some round has not started yet
|
||||||
|
if (roundsMatches.length === 0) return false;
|
||||||
|
|
||||||
|
return roundsMatches.every((match) => {
|
||||||
|
if (
|
||||||
|
match.opponent1 &&
|
||||||
|
match.opponent2 &&
|
||||||
|
match.opponent1?.result !== "win" &&
|
||||||
|
match.opponent2?.result !== "win"
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (advanceThreshold) {
|
||||||
|
return {
|
||||||
|
relevantMatchesFinished,
|
||||||
|
teams: standings
|
||||||
|
.map((standing) => ({
|
||||||
|
...standing,
|
||||||
|
status: calculateTeamStatus({
|
||||||
|
advanceThreshold,
|
||||||
|
wins: standing.stats?.setWins ?? 0,
|
||||||
|
losses: standing.stats?.setLosses ?? 0,
|
||||||
|
roundCount:
|
||||||
|
this.settings?.roundCount ??
|
||||||
|
TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
.filter((t) => t.status === "advanced")
|
||||||
|
.map((t) => t.team.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard Swiss logic without early advance/elimination
|
||||||
|
const uniquePlacements = R.unique(standings.map((s) => s.placement));
|
||||||
|
|
||||||
|
// 1,3,5 -> 1,2,3 e.g.
|
||||||
|
const placementNormalized = (p: number) => {
|
||||||
|
return uniquePlacements.indexOf(p) + 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
relevantMatchesFinished,
|
||||||
|
teams: standings
|
||||||
|
.filter((s) => placements.includes(placementNormalized(s.placement)))
|
||||||
|
.map((s) => s.team.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get standings(): Standing[] {
|
||||||
|
return this.currentStandings();
|
||||||
|
}
|
||||||
|
|
||||||
|
currentStandings(includeUnfinishedGroups = false) {
|
||||||
|
const groupIds = this.data.group.map((group) => group.id);
|
||||||
|
|
||||||
|
const placements: (Standing & { groupId: number })[] = [];
|
||||||
|
for (const groupId of groupIds) {
|
||||||
|
const matches = this.data.match.filter(
|
||||||
|
(match) => match.group_id === groupId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupIsFinished = matches.every(
|
||||||
|
(match) =>
|
||||||
|
// BYE
|
||||||
|
match.opponent1 === null ||
|
||||||
|
match.opponent2 === null ||
|
||||||
|
// match was played out
|
||||||
|
match.opponent1?.result === "win" ||
|
||||||
|
match.opponent2?.result === "win",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!groupIsFinished && !includeUnfinishedGroups) continue;
|
||||||
|
|
||||||
|
const teams: {
|
||||||
|
id: number;
|
||||||
|
setWins: number;
|
||||||
|
setLosses: number;
|
||||||
|
mapWins: number;
|
||||||
|
mapLosses: number;
|
||||||
|
winsAgainstTied: number;
|
||||||
|
lossesAgainstTied: number;
|
||||||
|
opponentSets: TeamTrackRecord;
|
||||||
|
opponentMaps: TeamTrackRecord;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
const updateTeam = ({
|
||||||
|
teamId,
|
||||||
|
setWins = 0,
|
||||||
|
setLosses = 0,
|
||||||
|
mapWins = 0,
|
||||||
|
mapLosses = 0,
|
||||||
|
opponentSets = { wins: 0, losses: 0 },
|
||||||
|
opponentMaps = { wins: 0, losses: 0 },
|
||||||
|
}: {
|
||||||
|
teamId: number;
|
||||||
|
setWins?: number;
|
||||||
|
setLosses?: number;
|
||||||
|
mapWins?: number;
|
||||||
|
mapLosses?: number;
|
||||||
|
opponentSets?: TeamTrackRecord;
|
||||||
|
opponentMaps?: TeamTrackRecord;
|
||||||
|
}) => {
|
||||||
|
const team = teams.find((team) => team.id === teamId);
|
||||||
|
if (team) {
|
||||||
|
team.setWins += setWins;
|
||||||
|
team.setLosses += setLosses;
|
||||||
|
team.mapWins += mapWins;
|
||||||
|
team.mapLosses += mapLosses;
|
||||||
|
|
||||||
|
team.opponentSets.wins += opponentSets.wins;
|
||||||
|
team.opponentSets.losses += opponentSets.losses;
|
||||||
|
team.opponentMaps.wins += opponentMaps.wins;
|
||||||
|
team.opponentMaps.losses += opponentMaps.losses;
|
||||||
|
} else {
|
||||||
|
teams.push({
|
||||||
|
id: teamId,
|
||||||
|
setWins,
|
||||||
|
setLosses,
|
||||||
|
mapWins,
|
||||||
|
mapLosses,
|
||||||
|
winsAgainstTied: 0,
|
||||||
|
lossesAgainstTied: 0,
|
||||||
|
opponentMaps,
|
||||||
|
opponentSets,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchUps = new Map<number, number[]>();
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
if (match.opponent1?.id && match.opponent2?.id) {
|
||||||
|
const opponentOneMatchUps = matchUps.get(match.opponent1.id) ?? [];
|
||||||
|
const opponentTwoMatchUps = matchUps.get(match.opponent2.id) ?? [];
|
||||||
|
|
||||||
|
matchUps.set(match.opponent1.id, [
|
||||||
|
...opponentOneMatchUps,
|
||||||
|
match.opponent2.id,
|
||||||
|
]);
|
||||||
|
matchUps.set(match.opponent2.id, [
|
||||||
|
...opponentTwoMatchUps,
|
||||||
|
match.opponent1.id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
match.opponent1?.result !== "win" &&
|
||||||
|
match.opponent2?.result !== "win"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const winner =
|
||||||
|
match.opponent1?.result === "win" ? match.opponent1 : match.opponent2;
|
||||||
|
|
||||||
|
const loser =
|
||||||
|
match.opponent1?.result === "win" ? match.opponent2 : match.opponent1;
|
||||||
|
|
||||||
|
if (!winner || !loser) continue;
|
||||||
|
|
||||||
|
invariant(
|
||||||
|
typeof winner.id === "number" &&
|
||||||
|
typeof loser.id === "number" &&
|
||||||
|
typeof winner.score === "number" &&
|
||||||
|
typeof loser.score === "number",
|
||||||
|
"RoundRobinBracket.standings: winner or loser id not found",
|
||||||
|
);
|
||||||
|
|
||||||
|
updateTeam({
|
||||||
|
teamId: winner.id,
|
||||||
|
setWins: 1,
|
||||||
|
setLosses: 0,
|
||||||
|
mapWins: winner.score,
|
||||||
|
mapLosses: loser.score,
|
||||||
|
});
|
||||||
|
updateTeam({
|
||||||
|
teamId: loser.id,
|
||||||
|
setWins: 0,
|
||||||
|
setLosses: 1,
|
||||||
|
mapWins: loser.score,
|
||||||
|
mapLosses: winner.score,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// BYES
|
||||||
|
for (const match of matches) {
|
||||||
|
if (match.opponent1 && match.opponent2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const winner = match.opponent1 ? match.opponent1 : match.opponent2;
|
||||||
|
|
||||||
|
if (!winner?.id) {
|
||||||
|
logger.warn("SwissBracket.currentStandings: winner not found");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const round = this.data.round.find(
|
||||||
|
(round) => round.id === match.round_id,
|
||||||
|
);
|
||||||
|
const mapWins =
|
||||||
|
round?.maps?.type === "PLAY_ALL"
|
||||||
|
? round?.maps?.count
|
||||||
|
: Math.ceil((round?.maps?.count ?? 0) / 2);
|
||||||
|
// preview
|
||||||
|
if (!mapWins) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTeam({
|
||||||
|
teamId: winner.id,
|
||||||
|
setWins: 1,
|
||||||
|
setLosses: 0,
|
||||||
|
mapWins: mapWins,
|
||||||
|
mapLosses: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// opponent win %
|
||||||
|
for (const team of teams) {
|
||||||
|
const teamsWhoPlayedAgainst = matchUps.get(team.id) ?? [];
|
||||||
|
|
||||||
|
const opponentSets = {
|
||||||
|
wins: 0,
|
||||||
|
losses: 0,
|
||||||
|
};
|
||||||
|
const opponentMaps = {
|
||||||
|
wins: 0,
|
||||||
|
losses: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const teamId of teamsWhoPlayedAgainst) {
|
||||||
|
const opponent = teams.find((t) => t.id === teamId);
|
||||||
|
if (!opponent) {
|
||||||
|
logger.warn("SwissBracket.currentStandings: opponent not found", {
|
||||||
|
teamId,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
opponentSets.wins += opponent.setWins;
|
||||||
|
opponentSets.losses += opponent.setLosses;
|
||||||
|
|
||||||
|
opponentMaps.wins += opponent.mapWins;
|
||||||
|
opponentMaps.losses += opponent.mapLosses;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTeam({
|
||||||
|
teamId: team.id,
|
||||||
|
opponentSets,
|
||||||
|
opponentMaps,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// wins against tied
|
||||||
|
for (const team of teams) {
|
||||||
|
for (const team2 of teams) {
|
||||||
|
if (team.id === team2.id) continue;
|
||||||
|
if (
|
||||||
|
team.setWins !== team2.setWins ||
|
||||||
|
// check also set losses to account for dropped teams
|
||||||
|
team.setLosses !== team2.setLosses
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// they are different teams and are tied, let's check who won
|
||||||
|
|
||||||
|
const finishedMatchBetweenTeams = matches.find((match) => {
|
||||||
|
const isBetweenTeams =
|
||||||
|
(match.opponent1?.id === team.id &&
|
||||||
|
match.opponent2?.id === team2.id) ||
|
||||||
|
(match.opponent1?.id === team2.id &&
|
||||||
|
match.opponent2?.id === team.id);
|
||||||
|
|
||||||
|
const isFinished =
|
||||||
|
match.opponent1?.result === "win" ||
|
||||||
|
match.opponent2?.result === "win";
|
||||||
|
|
||||||
|
return isBetweenTeams && isFinished;
|
||||||
|
});
|
||||||
|
|
||||||
|
// they did not play each other
|
||||||
|
if (!finishedMatchBetweenTeams) continue;
|
||||||
|
|
||||||
|
const wonTheirMatch =
|
||||||
|
(finishedMatchBetweenTeams.opponent1!.id === team.id &&
|
||||||
|
finishedMatchBetweenTeams.opponent1!.result === "win") ||
|
||||||
|
(finishedMatchBetweenTeams.opponent2!.id === team.id &&
|
||||||
|
finishedMatchBetweenTeams.opponent2!.result === "win");
|
||||||
|
|
||||||
|
if (wonTheirMatch) {
|
||||||
|
team.winsAgainstTied++;
|
||||||
|
} else {
|
||||||
|
team.lossesAgainstTied++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const droppedOutTeams = this.tournament.ctx.teams
|
||||||
|
.filter((t) => t.droppedOut)
|
||||||
|
.map((t) => t.id);
|
||||||
|
placements.push(
|
||||||
|
...teams
|
||||||
|
.sort((a, b) => {
|
||||||
|
// TIEBREAKER 0) dropped out teams are always last
|
||||||
|
const aDroppedOut = droppedOutTeams.includes(a.id);
|
||||||
|
const bDroppedOut = droppedOutTeams.includes(b.id);
|
||||||
|
|
||||||
|
if (aDroppedOut && !bDroppedOut) return 1;
|
||||||
|
if (!aDroppedOut && bDroppedOut) return -1;
|
||||||
|
|
||||||
|
// TIEBREAKER 1) set wins
|
||||||
|
if (a.setWins > b.setWins) return -1;
|
||||||
|
if (a.setWins < b.setWins) return 1;
|
||||||
|
|
||||||
|
// also set losses because we want a team who dropped more sets ranked lower (early advance format)
|
||||||
|
if (a.setLosses < b.setLosses) return -1;
|
||||||
|
if (a.setLosses > b.setLosses) return 1;
|
||||||
|
|
||||||
|
// TIEBREAKER 2) wins against tied - ensure that a team who beat more teams that are tied with them is placed higher
|
||||||
|
if (a.lossesAgainstTied > b.lossesAgainstTied) return 1;
|
||||||
|
if (a.lossesAgainstTied < b.lossesAgainstTied) return -1;
|
||||||
|
|
||||||
|
// TIEBREAKER 3) opponent set win % - how good the opponents they played against were?
|
||||||
|
const aOpponentSetWinPercentage = this.trackRecordToWinPercentage(
|
||||||
|
a.opponentSets,
|
||||||
|
);
|
||||||
|
const bOpponentSetWinPercentage = this.trackRecordToWinPercentage(
|
||||||
|
b.opponentSets,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (aOpponentSetWinPercentage > bOpponentSetWinPercentage) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (aOpponentSetWinPercentage < bOpponentSetWinPercentage) return 1;
|
||||||
|
|
||||||
|
// TIEBREAKER 4) map wins
|
||||||
|
if (a.mapWins > b.mapWins) return -1;
|
||||||
|
if (a.mapWins < b.mapWins) return 1;
|
||||||
|
|
||||||
|
// also map losses because we want a team who dropped more maps ranked lower
|
||||||
|
if (a.mapLosses < b.mapLosses) return -1;
|
||||||
|
if (a.mapLosses > b.mapLosses) return 1;
|
||||||
|
|
||||||
|
// TIEBREAKER 5) map wins against tied OW% (M) - note that this needs to be lower than map wins tiebreaker to make sure that throwing maps is not optimal
|
||||||
|
const aOpponentMapWinPercentage = this.trackRecordToWinPercentage(
|
||||||
|
a.opponentMaps,
|
||||||
|
);
|
||||||
|
const bOpponentMapWinPercentage = this.trackRecordToWinPercentage(
|
||||||
|
b.opponentMaps,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (aOpponentMapWinPercentage > bOpponentMapWinPercentage) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (aOpponentMapWinPercentage < bOpponentMapWinPercentage) return 1;
|
||||||
|
|
||||||
|
// TIEBREAKER 6) initial seeding made by the TO
|
||||||
|
const aSeed = Number(this.tournament.teamById(a.id)?.seed);
|
||||||
|
const bSeed = Number(this.tournament.teamById(b.id)?.seed);
|
||||||
|
|
||||||
|
if (aSeed < bSeed) return -1;
|
||||||
|
if (aSeed > bSeed) return 1;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
})
|
||||||
|
.map((team, i) => {
|
||||||
|
return {
|
||||||
|
team: this.tournament.teamById(team.id)!,
|
||||||
|
placement: i + 1,
|
||||||
|
groupId,
|
||||||
|
stats: {
|
||||||
|
setWins: team.setWins,
|
||||||
|
setLosses: team.setLosses,
|
||||||
|
mapWins: team.mapWins,
|
||||||
|
mapLosses: team.mapLosses,
|
||||||
|
winsAgainstTied: team.winsAgainstTied,
|
||||||
|
lossesAgainstTied: team.lossesAgainstTied,
|
||||||
|
opponentSetWinPercentage: this.trackRecordToWinPercentage(
|
||||||
|
team.opponentSets,
|
||||||
|
),
|
||||||
|
opponentMapWinPercentage: this.trackRecordToWinPercentage(
|
||||||
|
team.opponentMaps,
|
||||||
|
),
|
||||||
|
points: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = placements.sort((a, b) => {
|
||||||
|
if (a.placement < b.placement) return -1;
|
||||||
|
if (a.placement > b.placement) return 1;
|
||||||
|
|
||||||
|
if (a.groupId < b.groupId) return -1;
|
||||||
|
if (a.groupId > b.groupId) return 1;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
let lastPlacement = 0;
|
||||||
|
let currentPlacement = 1;
|
||||||
|
let teamsEncountered = 0;
|
||||||
|
return this.standingsWithoutNonParticipants(
|
||||||
|
sorted.map((team) => {
|
||||||
|
if (team.placement !== lastPlacement) {
|
||||||
|
lastPlacement = team.placement;
|
||||||
|
currentPlacement = teamsEncountered + 1;
|
||||||
|
}
|
||||||
|
teamsEncountered++;
|
||||||
|
return {
|
||||||
|
...team,
|
||||||
|
placement: currentPlacement,
|
||||||
|
stats: team.stats,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private trackRecordToWinPercentage(trackRecord: TeamTrackRecord) {
|
||||||
|
const onlyByes = trackRecord.wins === 0 && trackRecord.losses === 0;
|
||||||
|
if (onlyByes) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cutToNDecimalPlaces(
|
||||||
|
(trackRecord.wins / (trackRecord.wins + trackRecord.losses)) * 100,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get type(): Tables["TournamentStage"]["type"] {
|
||||||
|
return "swiss";
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultRoundBestOfs(data: TournamentManagerDataSet) {
|
||||||
|
const result: BracketMapCounts = new Map();
|
||||||
|
|
||||||
|
for (const round of data.round) {
|
||||||
|
if (!result.get(round.group_id)) {
|
||||||
|
result.set(round.group_id, new Map());
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
.get(round.group_id)!
|
||||||
|
.set(round.number, { count: 3, type: "BEST_OF" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
ongoingMatches(): number[] {
|
||||||
|
// Swiss matches get startedAt at creation time, not via ongoing detection
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/features/tournament-bracket/core/Bracket/index.ts
Normal file
35
app/features/tournament-bracket/core/Bracket/index.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { assertUnreachable } from "~/utils/types";
|
||||||
|
import type { CreateBracketArgs } from "./Bracket";
|
||||||
|
import { DoubleEliminationBracket } from "./DoubleEliminationBracket";
|
||||||
|
import { RoundRobinBracket } from "./RoundRobinBracket";
|
||||||
|
import { SingleEliminationBracket } from "./SingleEliminationBracket";
|
||||||
|
import { SwissBracket } from "./SwissBracket";
|
||||||
|
|
||||||
|
export type { CreateBracketArgs, Standing, TeamTrackRecord } from "./Bracket";
|
||||||
|
export { Bracket } from "./Bracket";
|
||||||
|
export { DoubleEliminationBracket } from "./DoubleEliminationBracket";
|
||||||
|
export { RoundRobinBracket } from "./RoundRobinBracket";
|
||||||
|
export { SingleEliminationBracket } from "./SingleEliminationBracket";
|
||||||
|
export { SwissBracket } from "./SwissBracket";
|
||||||
|
|
||||||
|
export function createBracket(
|
||||||
|
args: CreateBracketArgs,
|
||||||
|
): SingleEliminationBracket | DoubleEliminationBracket | RoundRobinBracket {
|
||||||
|
switch (args.type) {
|
||||||
|
case "single_elimination": {
|
||||||
|
return new SingleEliminationBracket(args);
|
||||||
|
}
|
||||||
|
case "double_elimination": {
|
||||||
|
return new DoubleEliminationBracket(args);
|
||||||
|
}
|
||||||
|
case "round_robin": {
|
||||||
|
return new RoundRobinBracket(args);
|
||||||
|
}
|
||||||
|
case "swiss": {
|
||||||
|
return new SwissBracket(args);
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
assertUnreachable(args.type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
102
app/features/tournament-bracket/core/Deadline.test.ts
Normal file
102
app/features/tournament-bracket/core/Deadline.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import * as Deadline from "./Deadline";
|
||||||
|
|
||||||
|
describe("totalMatchTime", () => {
|
||||||
|
it("calculates total time for best of 3", () => {
|
||||||
|
expect(Deadline.totalMatchTime(3)).toBe(26);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates total time for best of 5", () => {
|
||||||
|
expect(Deadline.totalMatchTime(5)).toBe(39);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("progressPercentage", () => {
|
||||||
|
it("returns 0% when no time has elapsed", () => {
|
||||||
|
expect(Deadline.progressPercentage(0, 20)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 50% when halfway through", () => {
|
||||||
|
expect(Deadline.progressPercentage(10, 20)).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 100% when time is up", () => {
|
||||||
|
expect(Deadline.progressPercentage(20, 20)).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns over 100% when overtime", () => {
|
||||||
|
expect(Deadline.progressPercentage(30, 20)).toBe(150);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("gameMarkers", () => {
|
||||||
|
it("returns correct markers for best of 3", () => {
|
||||||
|
const markers = Deadline.gameMarkers(3);
|
||||||
|
expect(markers).toHaveLength(3);
|
||||||
|
expect(markers[0].gameNumber).toBe(1);
|
||||||
|
expect(markers[0].percentage).toBe(25);
|
||||||
|
expect(markers[0].gameStartMinute).toBe(6.5);
|
||||||
|
expect(markers[1].percentage).toBe(50);
|
||||||
|
expect(markers[1].gameStartMinute).toBe(13);
|
||||||
|
expect(markers[2].percentage).toBe(75);
|
||||||
|
expect(markers[2].gameStartMinute).toBe(19.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns correct markers for best of 5", () => {
|
||||||
|
const markers = Deadline.gameMarkers(5);
|
||||||
|
expect(markers).toHaveLength(5);
|
||||||
|
expect(markers[0].gameNumber).toBe(1);
|
||||||
|
expect(markers[0].gameStartMinute).toBe(6.5);
|
||||||
|
expect(markers[1].gameStartMinute).toBe(13);
|
||||||
|
expect(markers[2].gameStartMinute).toBe(19.5);
|
||||||
|
expect(markers[3].gameStartMinute).toBe(26);
|
||||||
|
expect(markers[4].gameStartMinute).toBe(32.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("matchStatus", () => {
|
||||||
|
it("returns normal when on schedule", () => {
|
||||||
|
const status = Deadline.matchStatus({
|
||||||
|
elapsedMinutes: 10,
|
||||||
|
gamesCompleted: 1,
|
||||||
|
maxGamesCount: 3,
|
||||||
|
});
|
||||||
|
expect(status).toBe("normal");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns warning when behind schedule", () => {
|
||||||
|
const status = Deadline.matchStatus({
|
||||||
|
elapsedMinutes: 15,
|
||||||
|
gamesCompleted: 0,
|
||||||
|
maxGamesCount: 3,
|
||||||
|
});
|
||||||
|
expect(status).toBe("warning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error when time is up", () => {
|
||||||
|
const status = Deadline.matchStatus({
|
||||||
|
elapsedMinutes: 30,
|
||||||
|
gamesCompleted: 2,
|
||||||
|
maxGamesCount: 3,
|
||||||
|
});
|
||||||
|
expect(status).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns normal during prep time", () => {
|
||||||
|
const status = Deadline.matchStatus({
|
||||||
|
elapsedMinutes: 5,
|
||||||
|
gamesCompleted: 0,
|
||||||
|
maxGamesCount: 3,
|
||||||
|
});
|
||||||
|
expect(status).toBe("normal");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to normal for zero elapsed time", () => {
|
||||||
|
const status = Deadline.matchStatus({
|
||||||
|
elapsedMinutes: 0,
|
||||||
|
gamesCompleted: 0,
|
||||||
|
maxGamesCount: 3,
|
||||||
|
});
|
||||||
|
expect(status).toBe("normal");
|
||||||
|
});
|
||||||
|
});
|
||||||
100
app/features/tournament-bracket/core/Deadline.ts
Normal file
100
app/features/tournament-bracket/core/Deadline.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
const PREP_TIME_MINUTES = 6.5;
|
||||||
|
const MINUTES_PER_GAME = 6.5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the max duration for a match considered acceptable.
|
||||||
|
* @param maxGamesCount - The maximum number of games in the match
|
||||||
|
* @returns Time in minutes (preparation time + game time)
|
||||||
|
*/
|
||||||
|
export function totalMatchTime(maxGamesCount: number): number {
|
||||||
|
return PREP_TIME_MINUTES + MINUTES_PER_GAME * maxGamesCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the progress percentage based on elapsed time.
|
||||||
|
* @param elapsedMinutes - Time elapsed since match start
|
||||||
|
* @param totalMinutes - Total expected match duration
|
||||||
|
* @returns Percentage value (0-100+)
|
||||||
|
*/
|
||||||
|
export function progressPercentage(
|
||||||
|
elapsedMinutes: number,
|
||||||
|
totalMinutes: number,
|
||||||
|
): number {
|
||||||
|
return (elapsedMinutes / totalMinutes) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates marker positions for each game in the match timeline.
|
||||||
|
* @param maxGamesCount - The maximum number of games in the match
|
||||||
|
* @returns Array of game markers with their position as a percentage
|
||||||
|
*/
|
||||||
|
export function gameMarkers(maxGamesCount: number): Array<{
|
||||||
|
gameNumber: number;
|
||||||
|
percentage: number;
|
||||||
|
gameStartMinute: number;
|
||||||
|
maxMinute: number;
|
||||||
|
}> {
|
||||||
|
const totalMinutes = totalMatchTime(maxGamesCount);
|
||||||
|
const markers = [];
|
||||||
|
|
||||||
|
for (let i = 1; i <= maxGamesCount; i++) {
|
||||||
|
const gameStartMinute = PREP_TIME_MINUTES + MINUTES_PER_GAME * (i - 1);
|
||||||
|
const maxMinute = PREP_TIME_MINUTES + MINUTES_PER_GAME * i;
|
||||||
|
const percentage = (gameStartMinute / totalMinutes) * 100;
|
||||||
|
|
||||||
|
markers.push({
|
||||||
|
gameNumber: i,
|
||||||
|
percentage: Math.min(percentage, 100),
|
||||||
|
gameStartMinute,
|
||||||
|
maxMinute,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return markers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the current status of a match based on time and progress.
|
||||||
|
* @param params - Object containing elapsed time, games completed, and max games
|
||||||
|
* @returns "normal" if on track, "warning" if behind schedule, "error" if overtime
|
||||||
|
*/
|
||||||
|
export function matchStatus({
|
||||||
|
elapsedMinutes,
|
||||||
|
gamesCompleted,
|
||||||
|
maxGamesCount,
|
||||||
|
}: {
|
||||||
|
elapsedMinutes: number;
|
||||||
|
gamesCompleted: number;
|
||||||
|
maxGamesCount: number;
|
||||||
|
}): "normal" | "warning" | "error" {
|
||||||
|
const totalMinutes = totalMatchTime(maxGamesCount);
|
||||||
|
|
||||||
|
if (elapsedMinutes >= totalMinutes) {
|
||||||
|
return "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedGames = expectedGamesCompletedByMinute(
|
||||||
|
elapsedMinutes,
|
||||||
|
maxGamesCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (gamesCompleted < expectedGames) {
|
||||||
|
return "warning";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "normal";
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedGamesCompletedByMinute(
|
||||||
|
elapsedMinutes: number,
|
||||||
|
maxGamesCount: number,
|
||||||
|
): number {
|
||||||
|
const gameTimeElapsed = elapsedMinutes - PREP_TIME_MINUTES;
|
||||||
|
|
||||||
|
if (gameTimeElapsed <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedGames = Math.floor(gameTimeElapsed / MINUTES_PER_GAME);
|
||||||
|
return Math.min(expectedGames, maxGamesCount);
|
||||||
|
}
|
||||||
|
|
@ -28,7 +28,7 @@ import {
|
||||||
fillWithNullTillPowerOfTwo,
|
fillWithNullTillPowerOfTwo,
|
||||||
groupNumberToLetters,
|
groupNumberToLetters,
|
||||||
} from "../tournament-bracket-utils";
|
} from "../tournament-bracket-utils";
|
||||||
import { Bracket } from "./Bracket";
|
import { type Bracket, createBracket } from "./Bracket";
|
||||||
import { getTournamentManager } from "./brackets-manager";
|
import { getTournamentManager } from "./brackets-manager";
|
||||||
import { getRounds } from "./rounds";
|
import { getRounds } from "./rounds";
|
||||||
import * as Swiss from "./Swiss";
|
import * as Swiss from "./Swiss";
|
||||||
|
|
@ -141,7 +141,7 @@ export class Tournament {
|
||||||
);
|
);
|
||||||
|
|
||||||
this.brackets.push(
|
this.brackets.push(
|
||||||
Bracket.create({
|
createBracket({
|
||||||
id: inProgressStage.id,
|
id: inProgressStage.id,
|
||||||
idx: bracketIdx,
|
idx: bracketIdx,
|
||||||
tournament: this,
|
tournament: this,
|
||||||
|
|
@ -182,7 +182,7 @@ export class Tournament {
|
||||||
});
|
});
|
||||||
|
|
||||||
this.brackets.push(
|
this.brackets.push(
|
||||||
Bracket.create({
|
createBracket({
|
||||||
id: -1 * bracketIdx,
|
id: -1 * bracketIdx,
|
||||||
idx: bracketIdx,
|
idx: bracketIdx,
|
||||||
tournament: this,
|
tournament: this,
|
||||||
|
|
@ -237,7 +237,7 @@ export class Tournament {
|
||||||
);
|
);
|
||||||
|
|
||||||
this.brackets.push(
|
this.brackets.push(
|
||||||
Bracket.create({
|
createBracket({
|
||||||
id: -1 * bracketIdx,
|
id: -1 * bracketIdx,
|
||||||
idx: bracketIdx,
|
idx: bracketIdx,
|
||||||
tournament: this,
|
tournament: this,
|
||||||
|
|
@ -1205,16 +1205,6 @@ export class Tournament {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const bracket of this.brackets) {
|
|
||||||
if (!bracket.preview) continue;
|
|
||||||
|
|
||||||
const isParticipant = bracket.seeding?.includes(team.id);
|
|
||||||
|
|
||||||
if (isParticipant) {
|
|
||||||
return { type: "WAITING_FOR_BRACKET" } as const;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const bracket of this.brackets) {
|
for (const bracket of this.brackets) {
|
||||||
if (bracket.preview || bracket.type !== "swiss") continue;
|
if (bracket.preview || bracket.type !== "swiss") continue;
|
||||||
|
|
||||||
|
|
@ -1228,14 +1218,24 @@ export class Tournament {
|
||||||
match.opponent1?.id === team.id || match.opponent2?.id === team.id,
|
match.opponent1?.id === team.id || match.opponent2?.id === team.id,
|
||||||
).length;
|
).length;
|
||||||
const notAllRoundsGenerated =
|
const notAllRoundsGenerated =
|
||||||
this.ctx.settings.swiss?.roundCount &&
|
bracket.settings?.roundCount &&
|
||||||
setsGeneratedCount !== this.ctx.settings.swiss?.roundCount;
|
setsGeneratedCount !== bracket.settings.roundCount;
|
||||||
|
|
||||||
if (isParticipant && notAllRoundsGenerated) {
|
if (isParticipant && notAllRoundsGenerated) {
|
||||||
return { type: "WAITING_FOR_ROUND" } as const;
|
return { type: "WAITING_FOR_ROUND" } as const;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const bracket of this.brackets) {
|
||||||
|
if (!bracket.preview) continue;
|
||||||
|
|
||||||
|
const isParticipant = bracket.seeding?.includes(team.id);
|
||||||
|
|
||||||
|
if (isParticipant) {
|
||||||
|
return { type: "WAITING_FOR_BRACKET" } as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (team.checkIns.length === 0) return null;
|
if (team.checkIns.length === 0) return null;
|
||||||
|
|
||||||
return { type: "THANKS_FOR_PLAYING" } as const;
|
return { type: "THANKS_FOR_PLAYING" } as const;
|
||||||
|
|
@ -1281,6 +1281,11 @@ export class Tournament {
|
||||||
// BYE match
|
// BYE match
|
||||||
if (!match.opponent1 || !match.opponent2) return false;
|
if (!match.opponent1 || !match.opponent2) return false;
|
||||||
|
|
||||||
|
// in round robin all matches are independent from one another
|
||||||
|
if (bracket.type === "round_robin") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const anotherMatchBlocking = this.followingMatches(matchId).some(
|
const anotherMatchBlocking = this.followingMatches(matchId).some(
|
||||||
(match) =>
|
(match) =>
|
||||||
// in swiss matches are generated round by round and the existance
|
// in swiss matches are generated round by round and the existance
|
||||||
|
|
@ -1340,10 +1345,6 @@ export class Tournament {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bracket.type === "round_robin") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return bracket.data.match
|
return bracket.data.match
|
||||||
.filter(
|
.filter(
|
||||||
// only interested in matches of the same bracket & not the match itself
|
// only interested in matches of the same bracket & not the match itself
|
||||||
|
|
|
||||||
|
|
@ -335,8 +335,7 @@ const match_getByStageIdStm = sql.prepare(/*sql*/ `
|
||||||
select
|
select
|
||||||
"TournamentMatch".*,
|
"TournamentMatch".*,
|
||||||
sum("TournamentMatchGameResult"."opponentOnePoints") as "opponentOnePointsTotal",
|
sum("TournamentMatchGameResult"."opponentOnePoints") as "opponentOnePointsTotal",
|
||||||
sum("TournamentMatchGameResult"."opponentTwoPoints") as "opponentTwoPointsTotal",
|
sum("TournamentMatchGameResult"."opponentTwoPoints") as "opponentTwoPointsTotal"
|
||||||
max("TournamentMatchGameResult"."createdAt") as "lastGameFinishedAt"
|
|
||||||
from "TournamentMatch"
|
from "TournamentMatch"
|
||||||
left join "TournamentMatchGameResult" on "TournamentMatch"."id" = "TournamentMatchGameResult"."matchId"
|
left join "TournamentMatchGameResult" on "TournamentMatch"."id" = "TournamentMatchGameResult"."matchId"
|
||||||
where "TournamentMatch"."stageId" = @stageId
|
where "TournamentMatch"."stageId" = @stageId
|
||||||
|
|
@ -353,9 +352,9 @@ const match_getByRoundAndNumberStm = sql.prepare(/*sql*/ `
|
||||||
const match_insertStm = sql.prepare(/*sql*/ `
|
const match_insertStm = sql.prepare(/*sql*/ `
|
||||||
insert into
|
insert into
|
||||||
"TournamentMatch"
|
"TournamentMatch"
|
||||||
("roundId", "stageId", "groupId", "number", "opponentOne", "opponentTwo", "status", "chatCode")
|
("roundId", "stageId", "groupId", "number", "opponentOne", "opponentTwo", "status", "chatCode", "startedAt")
|
||||||
values
|
values
|
||||||
(@roundId, @stageId, @groupId, @number, @opponentOne, @opponentTwo, @status, @chatCode)
|
(@roundId, @stageId, @groupId, @number, @opponentOne, @opponentTwo, @status, @chatCode, @startedAt)
|
||||||
returning *
|
returning *
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|
@ -412,8 +411,7 @@ export class Match {
|
||||||
opponentTwo: string;
|
opponentTwo: string;
|
||||||
opponentOnePointsTotal: number | null;
|
opponentOnePointsTotal: number | null;
|
||||||
opponentTwoPointsTotal: number | null;
|
opponentTwoPointsTotal: number | null;
|
||||||
lastGameFinishedAt: number | null;
|
startedAt: number | null;
|
||||||
createdAt: number | null;
|
|
||||||
},
|
},
|
||||||
): MatchType {
|
): MatchType {
|
||||||
return {
|
return {
|
||||||
|
|
@ -437,8 +435,7 @@ export class Match {
|
||||||
round_id: rawMatch.roundId,
|
round_id: rawMatch.roundId,
|
||||||
stage_id: rawMatch.stageId,
|
stage_id: rawMatch.stageId,
|
||||||
status: rawMatch.status,
|
status: rawMatch.status,
|
||||||
lastGameFinishedAt: rawMatch.lastGameFinishedAt,
|
startedAt: rawMatch.startedAt,
|
||||||
createdAt: rawMatch.createdAt,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -479,6 +476,7 @@ export class Match {
|
||||||
opponentTwo: this.opponentTwo ?? "null",
|
opponentTwo: this.opponentTwo ?? "null",
|
||||||
status: this.status,
|
status: this.status,
|
||||||
chatCode: shortNanoid(),
|
chatCode: shortNanoid(),
|
||||||
|
startedAt: null,
|
||||||
}) as any;
|
}) as any;
|
||||||
|
|
||||||
this.id = match.id;
|
this.id = match.id;
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,10 @@ import invariant from "~/utils/invariant";
|
||||||
import { roundToNDecimalPlaces } from "~/utils/number";
|
import { roundToNDecimalPlaces } from "~/utils/number";
|
||||||
import type { Tables, WinLossParticipationArray } from "../../../db/tables";
|
import type { Tables, WinLossParticipationArray } from "../../../db/tables";
|
||||||
import type { AllMatchResult } from "../queries/allMatchResultsByTournamentId.server";
|
import type { AllMatchResult } from "../queries/allMatchResultsByTournamentId.server";
|
||||||
import { ensureOneStandingPerUser } from "../tournament-bracket-utils";
|
import {
|
||||||
|
ensureOneStandingPerUser,
|
||||||
|
matchEndedEarly,
|
||||||
|
} from "../tournament-bracket-utils";
|
||||||
import type { Standing } from "./Bracket";
|
import type { Standing } from "./Bracket";
|
||||||
import type { ParsedBracket } from "./Progression";
|
import type { ParsedBracket } from "./Progression";
|
||||||
|
|
||||||
|
|
@ -69,9 +72,18 @@ export function tournamentSummary({
|
||||||
calculateSeasonalStats?: boolean;
|
calculateSeasonalStats?: boolean;
|
||||||
progression: ParsedBracket[];
|
progression: ParsedBracket[];
|
||||||
}): TournamentSummary {
|
}): TournamentSummary {
|
||||||
|
const resultsWithoutEarlyEndedSets = results.filter((match) => {
|
||||||
|
return !matchEndedEarly({
|
||||||
|
opponentOne: match.opponentOne,
|
||||||
|
opponentTwo: match.opponentTwo,
|
||||||
|
count: match.roundMaps.count,
|
||||||
|
countType: match.roundMaps.type,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const skills = calculateSeasonalStats
|
const skills = calculateSeasonalStats
|
||||||
? calculateSkills({
|
? calculateSkills({
|
||||||
results,
|
results: resultsWithoutEarlyEndedSets,
|
||||||
queryCurrentTeamRating,
|
queryCurrentTeamRating,
|
||||||
queryCurrentUserRating,
|
queryCurrentUserRating,
|
||||||
queryTeamPlayerRatingAverage,
|
queryTeamPlayerRatingAverage,
|
||||||
|
|
@ -86,16 +98,18 @@ export function tournamentSummary({
|
||||||
rating: queryCurrentSeedingRating(userId),
|
rating: queryCurrentSeedingRating(userId),
|
||||||
matchesCount: 0, // Seeding skills do not have matches count
|
matchesCount: 0, // Seeding skills do not have matches count
|
||||||
}),
|
}),
|
||||||
results,
|
results: resultsWithoutEarlyEndedSets,
|
||||||
}).map((skill) => ({
|
}).map((skill) => ({
|
||||||
...skill,
|
...skill,
|
||||||
type: seedingSkillCountsFor,
|
type: seedingSkillCountsFor,
|
||||||
ordinal: ordinal(skill),
|
ordinal: ordinal(skill),
|
||||||
}))
|
}))
|
||||||
: [],
|
: [],
|
||||||
mapResultDeltas: calculateSeasonalStats ? mapResultDeltas(results) : [],
|
mapResultDeltas: calculateSeasonalStats
|
||||||
|
? mapResultDeltas(resultsWithoutEarlyEndedSets)
|
||||||
|
: [],
|
||||||
playerResultDeltas: calculateSeasonalStats
|
playerResultDeltas: calculateSeasonalStats
|
||||||
? playerResultDeltas(results)
|
? playerResultDeltas(resultsWithoutEarlyEndedSets)
|
||||||
: [],
|
: [],
|
||||||
tournamentResults: tournamentResults({
|
tournamentResults: tournamentResults({
|
||||||
participantCount: teams.length,
|
participantCount: teams.length,
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,10 @@ describe("tournamentSummary()", () => {
|
||||||
result: "loss",
|
result: "loss",
|
||||||
score: 0,
|
score: 0,
|
||||||
},
|
},
|
||||||
|
roundMaps: {
|
||||||
|
count: 3,
|
||||||
|
type: "BEST_OF",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
teams,
|
teams,
|
||||||
|
|
@ -293,6 +297,10 @@ describe("tournamentSummary()", () => {
|
||||||
result: "loss",
|
result: "loss",
|
||||||
score: 0,
|
score: 0,
|
||||||
},
|
},
|
||||||
|
roundMaps: {
|
||||||
|
count: 3,
|
||||||
|
type: "BEST_OF",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
maps: [
|
maps: [
|
||||||
|
|
@ -337,6 +345,10 @@ describe("tournamentSummary()", () => {
|
||||||
result: "loss",
|
result: "loss",
|
||||||
score: 0,
|
score: 0,
|
||||||
},
|
},
|
||||||
|
roundMaps: {
|
||||||
|
count: 3,
|
||||||
|
type: "BEST_OF",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -427,6 +439,10 @@ describe("tournamentSummary()", () => {
|
||||||
result: "loss",
|
result: "loss",
|
||||||
score: 1,
|
score: 1,
|
||||||
},
|
},
|
||||||
|
roundMaps: {
|
||||||
|
count: 3,
|
||||||
|
type: "BEST_OF",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -596,6 +612,10 @@ describe("tournamentSummary()", () => {
|
||||||
result: "loss",
|
result: "loss",
|
||||||
score: 0,
|
score: 0,
|
||||||
},
|
},
|
||||||
|
roundMaps: {
|
||||||
|
count: 3,
|
||||||
|
type: "BEST_OF",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
@ -650,6 +670,10 @@ describe("tournamentSummary()", () => {
|
||||||
result: "loss",
|
result: "loss",
|
||||||
score: 0,
|
score: 0,
|
||||||
},
|
},
|
||||||
|
roundMaps: {
|
||||||
|
count: 3,
|
||||||
|
type: "BEST_OF",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
@ -782,4 +806,139 @@ describe("tournamentSummary()", () => {
|
||||||
expect(team3Results.every((r) => r.participantCount === 2)).toBeTruthy();
|
expect(team3Results.every((r) => r.participantCount === 2)).toBeTruthy();
|
||||||
expect(team4Results.every((r) => r.participantCount === 2)).toBeTruthy();
|
expect(team4Results.every((r) => r.participantCount === 2)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("excludes matches ended early by organizer from calculations", () => {
|
||||||
|
const summary = summarize({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
maps: [
|
||||||
|
{
|
||||||
|
mode: "SZ",
|
||||||
|
stageId: 1,
|
||||||
|
participants: [
|
||||||
|
{ tournamentTeamId: 1, userId: 1 },
|
||||||
|
{ tournamentTeamId: 1, userId: 2 },
|
||||||
|
{ tournamentTeamId: 1, userId: 3 },
|
||||||
|
{ tournamentTeamId: 1, userId: 4 },
|
||||||
|
{ tournamentTeamId: 2, userId: 5 },
|
||||||
|
{ tournamentTeamId: 2, userId: 6 },
|
||||||
|
{ tournamentTeamId: 2, userId: 7 },
|
||||||
|
{ tournamentTeamId: 2, userId: 8 },
|
||||||
|
],
|
||||||
|
winnerTeamId: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
opponentOne: {
|
||||||
|
id: 1,
|
||||||
|
result: "win",
|
||||||
|
score: 0,
|
||||||
|
},
|
||||||
|
opponentTwo: {
|
||||||
|
id: 2,
|
||||||
|
result: "loss",
|
||||||
|
score: 0,
|
||||||
|
},
|
||||||
|
roundMaps: {
|
||||||
|
count: 3,
|
||||||
|
type: "BEST_OF",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summary.skills.length).toBe(0);
|
||||||
|
expect(summary.mapResultDeltas.length).toBe(0);
|
||||||
|
expect(summary.playerResultDeltas.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes normal matches but excludes early-ended matches from calculations", () => {
|
||||||
|
const summary = summarize({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
maps: [
|
||||||
|
{
|
||||||
|
mode: "SZ",
|
||||||
|
stageId: 1,
|
||||||
|
participants: [
|
||||||
|
{ tournamentTeamId: 1, userId: 1 },
|
||||||
|
{ tournamentTeamId: 1, userId: 2 },
|
||||||
|
{ tournamentTeamId: 1, userId: 3 },
|
||||||
|
{ tournamentTeamId: 1, userId: 4 },
|
||||||
|
{ tournamentTeamId: 2, userId: 5 },
|
||||||
|
{ tournamentTeamId: 2, userId: 6 },
|
||||||
|
{ tournamentTeamId: 2, userId: 7 },
|
||||||
|
{ tournamentTeamId: 2, userId: 8 },
|
||||||
|
],
|
||||||
|
winnerTeamId: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
opponentOne: {
|
||||||
|
id: 1,
|
||||||
|
result: "win",
|
||||||
|
score: 1,
|
||||||
|
},
|
||||||
|
opponentTwo: {
|
||||||
|
id: 2,
|
||||||
|
result: "loss",
|
||||||
|
score: 0,
|
||||||
|
},
|
||||||
|
roundMaps: {
|
||||||
|
count: 3,
|
||||||
|
type: "BEST_OF",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maps: [
|
||||||
|
{
|
||||||
|
mode: "TC",
|
||||||
|
stageId: 2,
|
||||||
|
participants: [
|
||||||
|
{ tournamentTeamId: 3, userId: 9 },
|
||||||
|
{ tournamentTeamId: 3, userId: 10 },
|
||||||
|
{ tournamentTeamId: 3, userId: 11 },
|
||||||
|
{ tournamentTeamId: 3, userId: 12 },
|
||||||
|
{ tournamentTeamId: 4, userId: 13 },
|
||||||
|
{ tournamentTeamId: 4, userId: 14 },
|
||||||
|
{ tournamentTeamId: 4, userId: 15 },
|
||||||
|
{ tournamentTeamId: 4, userId: 16 },
|
||||||
|
],
|
||||||
|
winnerTeamId: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
opponentOne: {
|
||||||
|
id: 3,
|
||||||
|
result: "win",
|
||||||
|
score: 0,
|
||||||
|
},
|
||||||
|
opponentTwo: {
|
||||||
|
id: 4,
|
||||||
|
result: "loss",
|
||||||
|
score: 0,
|
||||||
|
},
|
||||||
|
roundMaps: {
|
||||||
|
count: 3,
|
||||||
|
type: "BEST_OF",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const skillsFromTeam1 = summary.skills.filter((s) =>
|
||||||
|
[1, 2, 3, 4].includes(s.userId ?? 0),
|
||||||
|
);
|
||||||
|
const skillsFromTeam2 = summary.skills.filter((s) =>
|
||||||
|
[5, 6, 7, 8].includes(s.userId ?? 0),
|
||||||
|
);
|
||||||
|
const skillsFromTeam3 = summary.skills.filter((s) =>
|
||||||
|
[9, 10, 11, 12].includes(s.userId ?? 0),
|
||||||
|
);
|
||||||
|
const skillsFromTeam4 = summary.skills.filter((s) =>
|
||||||
|
[13, 14, 15, 16].includes(s.userId ?? 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(skillsFromTeam1.length).toBe(0);
|
||||||
|
expect(skillsFromTeam2.length).toBe(0);
|
||||||
|
expect(skillsFromTeam3.length).toBe(0);
|
||||||
|
expect(skillsFromTeam4.length).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -432,8 +432,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10930,
|
round_id: 10930,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730942920,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31410,
|
id: 31410,
|
||||||
|
|
@ -456,8 +455,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10930,
|
round_id: 10930,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730942228,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31411,
|
id: 31411,
|
||||||
|
|
@ -480,8 +478,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10931,
|
round_id: 10931,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730943838,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31412,
|
id: 31412,
|
||||||
|
|
@ -504,8 +501,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10931,
|
round_id: 10931,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730943744,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31413,
|
id: 31413,
|
||||||
|
|
@ -528,8 +524,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10932,
|
round_id: 10932,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730944396,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31414,
|
id: 31414,
|
||||||
|
|
@ -552,8 +547,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10932,
|
round_id: 10932,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730944433,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31415,
|
id: 31415,
|
||||||
|
|
@ -576,8 +570,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10933,
|
round_id: 10933,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730942292,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31416,
|
id: 31416,
|
||||||
|
|
@ -600,8 +593,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10933,
|
round_id: 10933,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730942228,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31417,
|
id: 31417,
|
||||||
|
|
@ -624,8 +616,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10934,
|
round_id: 10934,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730943087,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31418,
|
id: 31418,
|
||||||
|
|
@ -648,8 +639,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10934,
|
round_id: 10934,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730943362,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31419,
|
id: 31419,
|
||||||
|
|
@ -672,8 +662,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10935,
|
round_id: 10935,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730944650,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31420,
|
id: 31420,
|
||||||
|
|
@ -696,8 +685,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10935,
|
round_id: 10935,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730944060,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31421,
|
id: 31421,
|
||||||
|
|
@ -720,8 +708,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10936,
|
round_id: 10936,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730942499,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31422,
|
id: 31422,
|
||||||
|
|
@ -744,8 +731,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10936,
|
round_id: 10936,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730942637,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31423,
|
id: 31423,
|
||||||
|
|
@ -768,8 +754,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10937,
|
round_id: 10937,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730943675,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31424,
|
id: 31424,
|
||||||
|
|
@ -792,8 +777,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10937,
|
round_id: 10937,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730943519,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31425,
|
id: 31425,
|
||||||
|
|
@ -816,8 +800,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10938,
|
round_id: 10938,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730944582,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31426,
|
id: 31426,
|
||||||
|
|
@ -840,8 +823,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10938,
|
round_id: 10938,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730944327,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31427,
|
id: 31427,
|
||||||
|
|
@ -864,8 +846,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10939,
|
round_id: 10939,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730942138,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31428,
|
id: 31428,
|
||||||
|
|
@ -888,8 +869,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10939,
|
round_id: 10939,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730942201,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31429,
|
id: 31429,
|
||||||
|
|
@ -912,8 +892,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10940,
|
round_id: 10940,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730943191,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31430,
|
id: 31430,
|
||||||
|
|
@ -936,8 +915,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10940,
|
round_id: 10940,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730943041,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31431,
|
id: 31431,
|
||||||
|
|
@ -960,8 +938,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10941,
|
round_id: 10941,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730944267,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31432,
|
id: 31432,
|
||||||
|
|
@ -984,8 +961,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10941,
|
round_id: 10941,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730943864,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31433,
|
id: 31433,
|
||||||
|
|
@ -1008,8 +984,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10942,
|
round_id: 10942,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730942106,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31434,
|
id: 31434,
|
||||||
|
|
@ -1032,8 +1007,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10942,
|
round_id: 10942,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730942409,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31435,
|
id: 31435,
|
||||||
|
|
@ -1056,8 +1030,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10943,
|
round_id: 10943,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730943667,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31436,
|
id: 31436,
|
||||||
|
|
@ -1080,8 +1053,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10943,
|
round_id: 10943,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730943191,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31437,
|
id: 31437,
|
||||||
|
|
@ -1104,8 +1076,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10944,
|
round_id: 10944,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730945077,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31438,
|
id: 31438,
|
||||||
|
|
@ -1128,8 +1099,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10944,
|
round_id: 10944,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730945049,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31439,
|
id: 31439,
|
||||||
|
|
@ -1152,8 +1122,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10945,
|
round_id: 10945,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730941825,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31440,
|
id: 31440,
|
||||||
|
|
@ -1176,8 +1145,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10945,
|
round_id: 10945,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730942866,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31441,
|
id: 31441,
|
||||||
|
|
@ -1200,8 +1168,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10946,
|
round_id: 10946,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730943771,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31442,
|
id: 31442,
|
||||||
|
|
@ -1224,8 +1191,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10946,
|
round_id: 10946,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730943477,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31443,
|
id: 31443,
|
||||||
|
|
@ -1248,8 +1214,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10947,
|
round_id: 10947,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730944650,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31444,
|
id: 31444,
|
||||||
|
|
@ -1272,8 +1237,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10947,
|
round_id: 10947,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730944986,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31445,
|
id: 31445,
|
||||||
|
|
@ -1296,8 +1260,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10948,
|
round_id: 10948,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730942080,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31446,
|
id: 31446,
|
||||||
|
|
@ -1320,8 +1283,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10948,
|
round_id: 10948,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730942458,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31447,
|
id: 31447,
|
||||||
|
|
@ -1344,8 +1306,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10949,
|
round_id: 10949,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730943825,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31448,
|
id: 31448,
|
||||||
|
|
@ -1368,8 +1329,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10949,
|
round_id: 10949,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730943451,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31449,
|
id: 31449,
|
||||||
|
|
@ -1392,8 +1352,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10950,
|
round_id: 10950,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730944753,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31450,
|
id: 31450,
|
||||||
|
|
@ -1416,8 +1375,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10950,
|
round_id: 10950,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730944776,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31451,
|
id: 31451,
|
||||||
|
|
@ -1440,8 +1398,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10951,
|
round_id: 10951,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730942138,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31452,
|
id: 31452,
|
||||||
|
|
@ -1464,8 +1421,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10951,
|
round_id: 10951,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730942432,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31453,
|
id: 31453,
|
||||||
|
|
@ -1488,8 +1444,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10952,
|
round_id: 10952,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730943047,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31454,
|
id: 31454,
|
||||||
|
|
@ -1512,8 +1467,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10952,
|
round_id: 10952,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730943314,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31455,
|
id: 31455,
|
||||||
|
|
@ -1536,8 +1490,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10953,
|
round_id: 10953,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730944305,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31456,
|
id: 31456,
|
||||||
|
|
@ -1560,8 +1513,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10953,
|
round_id: 10953,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730944405,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31457,
|
id: 31457,
|
||||||
|
|
@ -1584,8 +1536,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10954,
|
round_id: 10954,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730942135,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31458,
|
id: 31458,
|
||||||
|
|
@ -1608,8 +1559,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10954,
|
round_id: 10954,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730942303,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31459,
|
id: 31459,
|
||||||
|
|
@ -1632,8 +1582,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10955,
|
round_id: 10955,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730943438,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31460,
|
id: 31460,
|
||||||
|
|
@ -1656,8 +1605,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10955,
|
round_id: 10955,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730943267,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31461,
|
id: 31461,
|
||||||
|
|
@ -1680,8 +1628,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10956,
|
round_id: 10956,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730944307,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31462,
|
id: 31462,
|
||||||
|
|
@ -1704,8 +1651,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10956,
|
round_id: 10956,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730944545,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31463,
|
id: 31463,
|
||||||
|
|
@ -1728,8 +1674,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10957,
|
round_id: 10957,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730942186,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31464,
|
id: 31464,
|
||||||
|
|
@ -1752,8 +1697,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10957,
|
round_id: 10957,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730942783,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31465,
|
id: 31465,
|
||||||
|
|
@ -1776,8 +1720,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10958,
|
round_id: 10958,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730943564,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31466,
|
id: 31466,
|
||||||
|
|
@ -1800,8 +1743,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10958,
|
round_id: 10958,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730944295,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31467,
|
id: 31467,
|
||||||
|
|
@ -1824,8 +1766,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10959,
|
round_id: 10959,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730945002,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31468,
|
id: 31468,
|
||||||
|
|
@ -1848,8 +1789,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10959,
|
round_id: 10959,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730945693,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31469,
|
id: 31469,
|
||||||
|
|
@ -1872,8 +1812,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10960,
|
round_id: 10960,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730942355,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31470,
|
id: 31470,
|
||||||
|
|
@ -1896,8 +1835,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10960,
|
round_id: 10960,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730942198,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31471,
|
id: 31471,
|
||||||
|
|
@ -1920,8 +1858,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10961,
|
round_id: 10961,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730943233,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31472,
|
id: 31472,
|
||||||
|
|
@ -1944,8 +1881,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10961,
|
round_id: 10961,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730943206,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31473,
|
id: 31473,
|
||||||
|
|
@ -1968,8 +1904,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10962,
|
round_id: 10962,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730944334,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 31474,
|
id: 31474,
|
||||||
|
|
@ -1992,8 +1927,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
round_id: 10962,
|
round_id: 10962,
|
||||||
stage_id: 1118,
|
stage_id: 1118,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1730944217,
|
startedAt: null,
|
||||||
createdAt: null,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -2063,7 +1997,6 @@ export const SWIM_OR_SINK_167 = (
|
||||||
teamsPerGroup: 4,
|
teamsPerGroup: 4,
|
||||||
thirdPlaceMatch: true,
|
thirdPlaceMatch: true,
|
||||||
isRanked: true,
|
isRanked: true,
|
||||||
deadlines: "DEFAULT",
|
|
||||||
isInvitational: false,
|
isInvitational: false,
|
||||||
enableNoScreenToggle: true,
|
enableNoScreenToggle: true,
|
||||||
autonomousSubs: false,
|
autonomousSubs: false,
|
||||||
|
|
|
||||||
|
|
@ -90,8 +90,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
round_id: 13715,
|
round_id: 13715,
|
||||||
stage_id: 1457,
|
stage_id: 1457,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1734687487,
|
startedAt: 1734685232,
|
||||||
createdAt: 1734685232,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 38585,
|
id: 38585,
|
||||||
|
|
@ -110,8 +109,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
round_id: 13715,
|
round_id: 13715,
|
||||||
stage_id: 1457,
|
stage_id: 1457,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1734686471,
|
startedAt: 1734685232,
|
||||||
createdAt: 1734685232,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 38586,
|
id: 38586,
|
||||||
|
|
@ -130,8 +128,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
round_id: 13715,
|
round_id: 13715,
|
||||||
stage_id: 1457,
|
stage_id: 1457,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1734686359,
|
startedAt: 1734685232,
|
||||||
createdAt: 1734685232,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 38587,
|
id: 38587,
|
||||||
|
|
@ -144,8 +141,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
round_id: 13715,
|
round_id: 13715,
|
||||||
stage_id: 1457,
|
stage_id: 1457,
|
||||||
status: 2,
|
status: 2,
|
||||||
lastGameFinishedAt: null,
|
startedAt: 1734685232,
|
||||||
createdAt: 1734685232,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 38588,
|
id: 38588,
|
||||||
|
|
@ -164,8 +160,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
round_id: 13716,
|
round_id: 13716,
|
||||||
stage_id: 1457,
|
stage_id: 1457,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1734688988,
|
startedAt: 1734687519,
|
||||||
createdAt: 1734687519,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 38589,
|
id: 38589,
|
||||||
|
|
@ -184,8 +179,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
round_id: 13716,
|
round_id: 13716,
|
||||||
stage_id: 1457,
|
stage_id: 1457,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1734689658,
|
startedAt: 1734687519,
|
||||||
createdAt: 1734687519,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 38590,
|
id: 38590,
|
||||||
|
|
@ -204,8 +198,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
round_id: 13716,
|
round_id: 13716,
|
||||||
stage_id: 1457,
|
stage_id: 1457,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1734688872,
|
startedAt: 1734687519,
|
||||||
createdAt: 1734687519,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 38591,
|
id: 38591,
|
||||||
|
|
@ -218,8 +211,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
round_id: 13716,
|
round_id: 13716,
|
||||||
stage_id: 1457,
|
stage_id: 1457,
|
||||||
status: 2,
|
status: 2,
|
||||||
lastGameFinishedAt: null,
|
startedAt: 1734687519,
|
||||||
createdAt: 1734687519,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 38592,
|
id: 38592,
|
||||||
|
|
@ -238,8 +230,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
round_id: 13717,
|
round_id: 13717,
|
||||||
stage_id: 1457,
|
stage_id: 1457,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1734691279,
|
startedAt: 1734689680,
|
||||||
createdAt: 1734689680,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 38593,
|
id: 38593,
|
||||||
|
|
@ -258,8 +249,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
round_id: 13717,
|
round_id: 13717,
|
||||||
stage_id: 1457,
|
stage_id: 1457,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1734690877,
|
startedAt: 1734689680,
|
||||||
createdAt: 1734689680,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 38594,
|
id: 38594,
|
||||||
|
|
@ -278,8 +268,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
round_id: 13717,
|
round_id: 13717,
|
||||||
stage_id: 1457,
|
stage_id: 1457,
|
||||||
status: 4,
|
status: 4,
|
||||||
lastGameFinishedAt: 1734690966,
|
startedAt: 1734689680,
|
||||||
createdAt: 1734689680,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 38595,
|
id: 38595,
|
||||||
|
|
@ -292,8 +281,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
round_id: 13717,
|
round_id: 13717,
|
||||||
stage_id: 1457,
|
stage_id: 1457,
|
||||||
status: 2,
|
status: 2,
|
||||||
lastGameFinishedAt: null,
|
startedAt: 1734689680,
|
||||||
createdAt: 1734689680,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -315,7 +303,6 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||||
],
|
],
|
||||||
thirdPlaceMatch: false,
|
thirdPlaceMatch: false,
|
||||||
isRanked: false,
|
isRanked: false,
|
||||||
deadlines: "DEFAULT",
|
|
||||||
isInvitational: false,
|
isInvitational: false,
|
||||||
enableNoScreenToggle: true,
|
enableNoScreenToggle: true,
|
||||||
enableSubs: true,
|
enableSubs: true,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -11,6 +11,7 @@ import { resolveMapList } from "../core/mapList.server";
|
||||||
import { findMatchById } from "../queries/findMatchById.server";
|
import { findMatchById } from "../queries/findMatchById.server";
|
||||||
import { findResultsByMatchId } from "../queries/findResultsByMatchId.server";
|
import { findResultsByMatchId } from "../queries/findResultsByMatchId.server";
|
||||||
import { matchPageParamsSchema } from "../tournament-bracket-schemas.server";
|
import { matchPageParamsSchema } from "../tournament-bracket-schemas.server";
|
||||||
|
import { matchEndedEarly } from "../tournament-bracket-utils";
|
||||||
|
|
||||||
export type TournamentMatchLoaderData = typeof loader;
|
export type TournamentMatchLoaderData = typeof loader;
|
||||||
|
|
||||||
|
|
@ -69,13 +70,30 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const matchIsOver =
|
||||||
|
match.opponentOne?.result === "win" || match.opponentTwo?.result === "win";
|
||||||
|
|
||||||
|
const endedEarly = matchIsOver
|
||||||
|
? matchEndedEarly({
|
||||||
|
opponentOne: {
|
||||||
|
score: match.opponentOne?.score,
|
||||||
|
result: match.opponentOne?.result,
|
||||||
|
},
|
||||||
|
opponentTwo: {
|
||||||
|
score: match.opponentTwo?.score,
|
||||||
|
result: match.opponentTwo?.result,
|
||||||
|
},
|
||||||
|
count: match.roundMaps.count,
|
||||||
|
countType: match.roundMaps.type,
|
||||||
|
})
|
||||||
|
: false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
match,
|
match,
|
||||||
results: findResultsByMatchId(matchId),
|
results: findResultsByMatchId(matchId),
|
||||||
mapList,
|
mapList,
|
||||||
matchIsOver:
|
matchIsOver,
|
||||||
match.opponentOne?.result === "win" ||
|
endedEarly,
|
||||||
match.opponentTwo?.result === "win",
|
|
||||||
noScreen,
|
noScreen,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { sql } from "~/db/sql";
|
import { sql } from "~/db/sql";
|
||||||
|
import type { TournamentRoundMaps } from "~/db/tables";
|
||||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||||
import invariant from "~/utils/invariant";
|
import invariant from "~/utils/invariant";
|
||||||
import { parseDBArray, parseDBJsonArray } from "~/utils/sql";
|
import { parseDBArray, parseDBJsonArray } from "~/utils/sql";
|
||||||
|
|
@ -24,6 +25,7 @@ const stm = sql.prepare(/* sql */ `
|
||||||
"m"."opponentTwo" ->> '$.score' as "opponentTwoScore",
|
"m"."opponentTwo" ->> '$.score' as "opponentTwoScore",
|
||||||
"m"."opponentOne" ->> '$.result' as "opponentOneResult",
|
"m"."opponentOne" ->> '$.result' as "opponentOneResult",
|
||||||
"m"."opponentTwo" ->> '$.result' as "opponentTwoResult",
|
"m"."opponentTwo" ->> '$.result' as "opponentTwoResult",
|
||||||
|
"TournamentRound"."maps" as "roundMaps",
|
||||||
json_group_array(
|
json_group_array(
|
||||||
json_object(
|
json_object(
|
||||||
'stageId',
|
'stageId',
|
||||||
|
|
@ -38,6 +40,7 @@ const stm = sql.prepare(/* sql */ `
|
||||||
) as "maps"
|
) as "maps"
|
||||||
from
|
from
|
||||||
"TournamentMatch" as "m"
|
"TournamentMatch" as "m"
|
||||||
|
inner join "TournamentRound" on "TournamentRound"."id" = "m"."roundId"
|
||||||
left join "TournamentStage" on "TournamentStage"."id" = "m"."stageId"
|
left join "TournamentStage" on "TournamentStage"."id" = "m"."stageId"
|
||||||
left join "q1" on "q1"."matchId" = "m"."id"
|
left join "q1" on "q1"."matchId" = "m"."id"
|
||||||
where "TournamentStage"."tournamentId" = @tournamentId
|
where "TournamentStage"."tournamentId" = @tournamentId
|
||||||
|
|
@ -56,6 +59,7 @@ interface Opponent {
|
||||||
export interface AllMatchResult {
|
export interface AllMatchResult {
|
||||||
opponentOne: Opponent;
|
opponentOne: Opponent;
|
||||||
opponentTwo: Opponent;
|
opponentTwo: Opponent;
|
||||||
|
roundMaps: TournamentRoundMaps;
|
||||||
maps: Array<{
|
maps: Array<{
|
||||||
stageId: StageId;
|
stageId: StageId;
|
||||||
mode: ModeShort;
|
mode: ModeShort;
|
||||||
|
|
@ -74,17 +78,23 @@ export function allMatchResultsByTournamentId(
|
||||||
const rows = stm.all({ tournamentId }) as unknown as any[];
|
const rows = stm.all({ tournamentId }) as unknown as any[];
|
||||||
|
|
||||||
return rows.map((row) => {
|
return rows.map((row) => {
|
||||||
return {
|
const roundMaps = JSON.parse(row.roundMaps) as TournamentRoundMaps;
|
||||||
opponentOne: {
|
|
||||||
|
const opponentOne = {
|
||||||
id: row.opponentOneId,
|
id: row.opponentOneId,
|
||||||
score: row.opponentOneScore,
|
score: row.opponentOneScore,
|
||||||
result: row.opponentOneResult,
|
result: row.opponentOneResult,
|
||||||
},
|
};
|
||||||
opponentTwo: {
|
const opponentTwo = {
|
||||||
id: row.opponentTwoId,
|
id: row.opponentTwoId,
|
||||||
score: row.opponentTwoScore,
|
score: row.opponentTwoScore,
|
||||||
result: row.opponentTwoResult,
|
result: row.opponentTwoResult,
|
||||||
},
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
opponentOne,
|
||||||
|
opponentTwo,
|
||||||
|
roundMaps,
|
||||||
maps: parseDBJsonArray(row.maps).map((map: any) => {
|
maps: parseDBJsonArray(row.maps).map((map: any) => {
|
||||||
const participants = parseDBArray(map.participants);
|
const participants = parseDBArray(map.participants);
|
||||||
invariant(participants.length > 0, "No participants found");
|
invariant(participants.length > 0, "No participants found");
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,6 @@ const stm = sql.prepare(/* sql */ `
|
||||||
where "matchId" = @matchId
|
where "matchId" = @matchId
|
||||||
`);
|
`);
|
||||||
|
|
||||||
export function deleteMatchPickBanEvents({ matchId }: { matchId: number }) {
|
export function deleteMatchPickBanEvents(matchId: number) {
|
||||||
return stm.run({ matchId });
|
return stm.run({ matchId });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ const stm = sql.prepare(/* sql */ `
|
||||||
"TournamentMatch"."opponentOne",
|
"TournamentMatch"."opponentOne",
|
||||||
"TournamentMatch"."opponentTwo",
|
"TournamentMatch"."opponentTwo",
|
||||||
"TournamentMatch"."chatCode",
|
"TournamentMatch"."chatCode",
|
||||||
|
"TournamentMatch"."startedAt",
|
||||||
|
"TournamentMatch"."status",
|
||||||
"Tournament"."mapPickingStyle",
|
"Tournament"."mapPickingStyle",
|
||||||
"TournamentRound"."id" as "roundId",
|
"TournamentRound"."id" as "roundId",
|
||||||
"TournamentRound"."maps" as "roundMaps",
|
"TournamentRound"."maps" as "roundMaps",
|
||||||
|
|
@ -49,7 +51,10 @@ export type FindMatchById = ReturnType<typeof findMatchById>;
|
||||||
|
|
||||||
export const findMatchById = (id: number) => {
|
export const findMatchById = (id: number) => {
|
||||||
const row = stm.get({ id }) as
|
const row = stm.get({ id }) as
|
||||||
| ((Pick<Tables["TournamentMatch"], "id" | "groupId" | "chatCode"> &
|
| ((Pick<
|
||||||
|
Tables["TournamentMatch"],
|
||||||
|
"id" | "groupId" | "chatCode" | "startedAt" | "status"
|
||||||
|
> &
|
||||||
Pick<Tables["Tournament"], "mapPickingStyle"> & { players: string }) & {
|
Pick<Tables["Tournament"], "mapPickingStyle"> & { players: string }) & {
|
||||||
opponentOne: string;
|
opponentOne: string;
|
||||||
opponentTwo: string;
|
opponentTwo: string;
|
||||||
|
|
@ -69,6 +74,7 @@ export const findMatchById = (id: number) => {
|
||||||
roundMaps,
|
roundMaps,
|
||||||
opponentOne: JSON.parse(row.opponentOne) as Match["opponent1"],
|
opponentOne: JSON.parse(row.opponentOne) as Match["opponent1"],
|
||||||
opponentTwo: JSON.parse(row.opponentTwo) as Match["opponent2"],
|
opponentTwo: JSON.parse(row.opponentTwo) as Match["opponent2"],
|
||||||
|
status: row.status,
|
||||||
players: (
|
players: (
|
||||||
parseDBArray(row.players) as Array<{
|
parseDBArray(row.players) as Array<{
|
||||||
id: Tables["User"]["id"];
|
id: Tables["User"]["id"];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { sql } from "~/db/sql";
|
||||||
|
|
||||||
|
const stm = sql.prepare(/* sql */ `
|
||||||
|
update "TournamentMatch"
|
||||||
|
set
|
||||||
|
"status" = 0,
|
||||||
|
"startedAt" = null
|
||||||
|
where "id" = @matchId
|
||||||
|
`);
|
||||||
|
|
||||||
|
export function resetMatchStatus(matchId: number) {
|
||||||
|
stm.run({ matchId });
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { useLoaderData, useRevalidator } from "@remix-run/react";
|
import { Form, useLoaderData, useRevalidator } from "@remix-run/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { LinkButton } from "~/components/elements/Button";
|
import { LinkButton } from "~/components/elements/Button";
|
||||||
import { ArrowLongLeftIcon } from "~/components/icons/ArrowLongLeft";
|
import { ArrowLongLeftIcon } from "~/components/icons/ArrowLongLeft";
|
||||||
import { containerClassName } from "~/components/Main";
|
import { containerClassName } from "~/components/Main";
|
||||||
|
import { SubmitButton } from "~/components/SubmitButton";
|
||||||
import { useUser } from "~/features/auth/core/user";
|
import { useUser } from "~/features/auth/core/user";
|
||||||
import { useWebsocketRevalidation } from "~/features/chat/chat-hooks";
|
import { useWebsocketRevalidation } from "~/features/chat/chat-hooks";
|
||||||
import { ConnectedChat } from "~/features/chat/components/Chat";
|
import { ConnectedChat } from "~/features/chat/components/Chat";
|
||||||
|
|
@ -113,7 +114,10 @@ export default function TournamentMatchPage() {
|
||||||
data.match.opponentOne?.id && data.match.opponentTwo?.id,
|
data.match.opponentOne?.id && data.match.opponentTwo?.id,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{data.matchIsOver ? <ResultsSection /> : null}
|
{data.matchIsOver && !data.endedEarly && data.results.length > 0 ? (
|
||||||
|
<ResultsSection />
|
||||||
|
) : null}
|
||||||
|
{data.matchIsOver && data.endedEarly ? <EndedEarlyMessage /> : null}
|
||||||
{!data.matchIsOver &&
|
{!data.matchIsOver &&
|
||||||
typeof data.match.opponentOne?.id === "number" &&
|
typeof data.match.opponentOne?.id === "number" &&
|
||||||
typeof data.match.opponentTwo?.id === "number" ? (
|
typeof data.match.opponentTwo?.id === "number" ? (
|
||||||
|
|
@ -368,3 +372,46 @@ function ResultsSection() {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EndedEarlyMessage() {
|
||||||
|
const user = useUser();
|
||||||
|
const data = useLoaderData<typeof loader>();
|
||||||
|
const tournament = useTournament();
|
||||||
|
|
||||||
|
const winnerTeamId =
|
||||||
|
data.match.opponentOne?.result === "win"
|
||||||
|
? data.match.opponentOne.id
|
||||||
|
: data.match.opponentTwo?.result === "win"
|
||||||
|
? data.match.opponentTwo.id
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const winnerTeam = winnerTeamId ? tournament.teamById(winnerTeamId) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tournament-bracket__during-match-actions">
|
||||||
|
<div className="tournament-bracket__locked-banner tournament-bracket__locked-banner__lonely">
|
||||||
|
<div className="stack sm items-center">
|
||||||
|
<div className="text-lg text-center font-bold">Match ended early</div>
|
||||||
|
{winnerTeam ? (
|
||||||
|
<div className="text-xs text-lighter text-center">
|
||||||
|
The organizer ended this match as it exceeded the time limit.
|
||||||
|
Winner: {winnerTeam.name}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{tournament.isOrganizer(user) &&
|
||||||
|
tournament.matchCanBeReopened(data.match.id) ? (
|
||||||
|
<Form method="post" className="contents">
|
||||||
|
<SubmitButton
|
||||||
|
_action="REOPEN_MATCH"
|
||||||
|
className="tournament-bracket__stage-banner__undo-button"
|
||||||
|
testId="reopen-match-button"
|
||||||
|
>
|
||||||
|
Reopen match
|
||||||
|
</SubmitButton>
|
||||||
|
</Form>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,10 @@ export const matchSchema = z.union([
|
||||||
z.object({
|
z.object({
|
||||||
_action: _action("UNLOCK"),
|
_action: _action("UNLOCK"),
|
||||||
}),
|
}),
|
||||||
|
z.object({
|
||||||
|
_action: _action("END_SET"),
|
||||||
|
winnerTeamId: z.preprocess(nullLiteraltoNull, id.nullable()),
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const bracketIdx = z.coerce.number().int().min(0).max(100);
|
export const bracketIdx = z.coerce.number().int().min(0).max(100);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
fillWithNullTillPowerOfTwo,
|
fillWithNullTillPowerOfTwo,
|
||||||
groupNumberToLetters,
|
groupNumberToLetters,
|
||||||
mapCountPlayedInSetWithCertainty,
|
mapCountPlayedInSetWithCertainty,
|
||||||
|
matchEndedEarly,
|
||||||
resolveRoomPass,
|
resolveRoomPass,
|
||||||
validateBadgeReceivers,
|
validateBadgeReceivers,
|
||||||
} from "./tournament-bracket-utils";
|
} from "./tournament-bracket-utils";
|
||||||
|
|
@ -149,4 +150,94 @@ describe("groupNumberToLetters()", () => {
|
||||||
expect(pass1).not.toBe(pass2);
|
expect(pass1).not.toBe(pass2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("matchEndedEarly", () => {
|
||||||
|
test("returns false when no winner", () => {
|
||||||
|
expect(
|
||||||
|
matchEndedEarly({
|
||||||
|
opponentOne: { score: 1 },
|
||||||
|
opponentTwo: { score: 1 },
|
||||||
|
count: 3,
|
||||||
|
countType: "BEST_OF",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false when match completed normally (best of 3)", () => {
|
||||||
|
expect(
|
||||||
|
matchEndedEarly({
|
||||||
|
opponentOne: { score: 2, result: "win" },
|
||||||
|
opponentTwo: { score: 1, result: "loss" },
|
||||||
|
count: 3,
|
||||||
|
countType: "BEST_OF",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns true when match ended early (best of 3)", () => {
|
||||||
|
expect(
|
||||||
|
matchEndedEarly({
|
||||||
|
opponentOne: { score: 1, result: "win" },
|
||||||
|
opponentTwo: { score: 0, result: "loss" },
|
||||||
|
count: 3,
|
||||||
|
countType: "BEST_OF",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns true when match ended early (best of 5)", () => {
|
||||||
|
expect(
|
||||||
|
matchEndedEarly({
|
||||||
|
opponentOne: { score: 2, result: "win" },
|
||||||
|
opponentTwo: { score: 1, result: "loss" },
|
||||||
|
count: 5,
|
||||||
|
countType: "BEST_OF",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false when match completed normally (best of 5)", () => {
|
||||||
|
expect(
|
||||||
|
matchEndedEarly({
|
||||||
|
opponentOne: { score: 3, result: "win" },
|
||||||
|
opponentTwo: { score: 2, result: "loss" },
|
||||||
|
count: 5,
|
||||||
|
countType: "BEST_OF",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false when all maps played (play all)", () => {
|
||||||
|
expect(
|
||||||
|
matchEndedEarly({
|
||||||
|
opponentOne: { score: 2, result: "win" },
|
||||||
|
opponentTwo: { score: 1, result: "loss" },
|
||||||
|
count: 3,
|
||||||
|
countType: "PLAY_ALL",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns true when not all maps played (play all)", () => {
|
||||||
|
expect(
|
||||||
|
matchEndedEarly({
|
||||||
|
opponentOne: { score: 2, result: "win" },
|
||||||
|
opponentTwo: { score: 0, result: "loss" },
|
||||||
|
count: 3,
|
||||||
|
countType: "PLAY_ALL",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles missing scores as 0", () => {
|
||||||
|
expect(
|
||||||
|
matchEndedEarly({
|
||||||
|
opponentOne: { result: "win" },
|
||||||
|
opponentTwo: { result: "loss" },
|
||||||
|
count: 3,
|
||||||
|
countType: "BEST_OF",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -269,6 +269,29 @@ export function isSetOverByScore({
|
||||||
return scores[0] === matchOverAtXWins || scores[1] === matchOverAtXWins;
|
return scores[0] === matchOverAtXWins || scores[1] === matchOverAtXWins;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function matchEndedEarly({
|
||||||
|
opponentOne,
|
||||||
|
opponentTwo,
|
||||||
|
count,
|
||||||
|
countType,
|
||||||
|
}: {
|
||||||
|
opponentOne: { score?: number; result?: "win" | "loss" };
|
||||||
|
opponentTwo: { score?: number; result?: "win" | "loss" };
|
||||||
|
count: number;
|
||||||
|
countType: TournamentRoundMaps["type"];
|
||||||
|
}) {
|
||||||
|
if (opponentOne.result !== "win" && opponentTwo.result !== "win") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scores: [number, number] = [
|
||||||
|
opponentOne.score ?? 0,
|
||||||
|
opponentTwo.score ?? 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
return !isSetOverByScore({ scores, count, countType });
|
||||||
|
}
|
||||||
|
|
||||||
export function tournamentTeamToActiveRosterUserIds(
|
export function tournamentTeamToActiveRosterUserIds(
|
||||||
team: TournamentLoaderData["tournament"]["ctx"]["teams"][number],
|
team: TournamentLoaderData["tournament"]["ctx"]["teams"][number],
|
||||||
teamMinMemberCount: number,
|
teamMinMemberCount: number,
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,10 @@
|
||||||
padding-inline: var(--s-2);
|
padding-inline: var(--s-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tournament-bracket__locked-banner__lonely {
|
||||||
|
border-radius: var(--rounded);
|
||||||
|
}
|
||||||
|
|
||||||
.tournament-bracket__stage-banner {
|
.tournament-bracket__stage-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -100,6 +104,55 @@
|
||||||
bottom: 8px;
|
bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tournament-bracket__stage-banner__end-set-button {
|
||||||
|
right: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tournament-bracket__stage-banner:has(
|
||||||
|
.tournament-bracket__stage-banner__end-set-button
|
||||||
|
)
|
||||||
|
.tournament-bracket__stage-banner__undo-button:not(
|
||||||
|
.tournament-bracket__stage-banner__end-set-button
|
||||||
|
) {
|
||||||
|
right: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tournament-bracket__deadline-popover {
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tournament-bracket__deadline-popover__trigger {
|
||||||
|
margin-block-start: 0;
|
||||||
|
background-color: var(--bg-lightest-solid) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tournament-bracket__deadline-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: var(--fonts-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tournament-bracket__deadline-indicator__warning {
|
||||||
|
background-color: var(--theme-warning);
|
||||||
|
color: var(--button-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tournament-bracket__deadline-indicator__error {
|
||||||
|
background-color: var(--theme-error);
|
||||||
|
color: var(--button-text);
|
||||||
|
}
|
||||||
|
|
||||||
.tournament-bracket__stage-banner__top-bar__header {
|
.tournament-bracket__stage-banner__top-bar__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -987,6 +987,19 @@ export function unlockMatch({
|
||||||
})
|
})
|
||||||
.where("id", "=", tournamentId)
|
.where("id", "=", tournamentId)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
|
// Make sure that a match is not marked as started when it is unlocked
|
||||||
|
// as we use this timestamp to determine the "deadline" for the match
|
||||||
|
// so it doesn't make sense for that timer to run if players can't play yet
|
||||||
|
await trx
|
||||||
|
.updateTable("TournamentMatch")
|
||||||
|
.set({
|
||||||
|
startedAt: databaseTimestampNow(),
|
||||||
|
})
|
||||||
|
.where("id", "=", matchId)
|
||||||
|
// ensure we don't set startedAt if it was never set before
|
||||||
|
.where("TournamentMatch.startedAt", "is not", null)
|
||||||
|
.execute();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1105,7 +1118,6 @@ export function insertSwissMatches(
|
||||||
roundId: match.roundId,
|
roundId: match.roundId,
|
||||||
stageId: match.stageId,
|
stageId: match.stageId,
|
||||||
status: Status.Ready,
|
status: Status.Ready,
|
||||||
createdAt: dateToDatabaseTimestamp(new Date()),
|
|
||||||
chatCode: shortNanoid(),
|
chatCode: shortNanoid(),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { sql } from "~/db/sql";
|
import { sql } from "~/db/sql";
|
||||||
import type { Tables } from "~/db/tables";
|
import type { Tables } from "~/db/tables";
|
||||||
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
|
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
|
||||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
import { databaseTimestampNow } from "~/utils/dates";
|
||||||
import { shortNanoid } from "~/utils/id";
|
import { shortNanoid } from "~/utils/id";
|
||||||
import invariant from "~/utils/invariant";
|
import invariant from "~/utils/invariant";
|
||||||
|
|
||||||
|
|
@ -54,8 +54,7 @@ const createTournamentMatchStm = sql.prepare(/* sql */ `
|
||||||
"opponentTwo",
|
"opponentTwo",
|
||||||
"roundId",
|
"roundId",
|
||||||
"stageId",
|
"stageId",
|
||||||
"status",
|
"status"
|
||||||
"createdAt"
|
|
||||||
) values (
|
) values (
|
||||||
@chatCode,
|
@chatCode,
|
||||||
@groupId,
|
@groupId,
|
||||||
|
|
@ -64,8 +63,7 @@ const createTournamentMatchStm = sql.prepare(/* sql */ `
|
||||||
@opponentTwo,
|
@opponentTwo,
|
||||||
@roundId,
|
@roundId,
|
||||||
@stageId,
|
@stageId,
|
||||||
@status,
|
@status
|
||||||
@createdAt
|
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|
@ -79,7 +77,7 @@ export function createSwissBracketInTransaction(
|
||||||
const stageFromDB = createTournamentStageStm.get({
|
const stageFromDB = createTournamentStageStm.get({
|
||||||
tournamentId: stageInput.tournament_id,
|
tournamentId: stageInput.tournament_id,
|
||||||
type: stageInput.type,
|
type: stageInput.type,
|
||||||
createdAt: dateToDatabaseTimestamp(new Date()),
|
createdAt: databaseTimestampNow(),
|
||||||
settings: JSON.stringify(stageInput.settings),
|
settings: JSON.stringify(stageInput.settings),
|
||||||
name: stageInput.name,
|
name: stageInput.name,
|
||||||
}) as Tables["TournamentStage"];
|
}) as Tables["TournamentStage"];
|
||||||
|
|
@ -119,7 +117,6 @@ export function createSwissBracketInTransaction(
|
||||||
roundId: roundFromDB.id,
|
roundId: roundFromDB.id,
|
||||||
stageId: stageFromDB.id,
|
stageId: stageFromDB.id,
|
||||||
status: match.status,
|
status: match.status,
|
||||||
createdAt: dateToDatabaseTimestamp(new Date()),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ export async function dbInsertTournament() {
|
||||||
bracketUrl: "https://example.com/bracket",
|
bracketUrl: "https://example.com/bracket",
|
||||||
description: null,
|
description: null,
|
||||||
discordInviteCode: "test-discord",
|
discordInviteCode: "test-discord",
|
||||||
deadlines: "DEFAULT",
|
|
||||||
name: "Test Tournament",
|
name: "Test Tournament",
|
||||||
organizationId: null,
|
organizationId: null,
|
||||||
rules: null,
|
rules: null,
|
||||||
|
|
|
||||||
|
|
@ -63,8 +63,11 @@ export class BaseUpdater extends BaseGetter {
|
||||||
// Don't update related matches if it's a simple score update.
|
// Don't update related matches if it's a simple score update.
|
||||||
if (!statusChanged && !resultChanged) return;
|
if (!statusChanged && !resultChanged) return;
|
||||||
|
|
||||||
if (!helpers.isRoundRobin(stage) && !helpers.isSwiss(stage))
|
if (!helpers.isRoundRobin(stage) && !helpers.isSwiss(stage)) {
|
||||||
this.updateRelatedMatches(stored, statusChanged, resultChanged);
|
this.updateRelatedMatches(stored, statusChanged, resultChanged);
|
||||||
|
} else if (helpers.isRoundRobin(stage) && resultChanged) {
|
||||||
|
this.unlockNextRoundRobinRound(stored);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -250,4 +253,79 @@ export class BaseUpdater extends BaseGetter {
|
||||||
|
|
||||||
if (helpers.hasBye(match)) this.updateRelatedMatches(match, true, true);
|
if (helpers.hasBye(match)) this.updateRelatedMatches(match, true, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlocks matches in the next round of a round-robin group if both participants are ready.
|
||||||
|
*
|
||||||
|
* @param match The match that was just completed.
|
||||||
|
*/
|
||||||
|
protected unlockNextRoundRobinRound(match: Match): void {
|
||||||
|
const round = this.storage.select("round", match.round_id);
|
||||||
|
if (!round) throw Error("Round not found.");
|
||||||
|
|
||||||
|
const nextRound = this.storage.selectFirst("round", {
|
||||||
|
group_id: round.group_id,
|
||||||
|
number: round.number + 1,
|
||||||
|
});
|
||||||
|
if (!nextRound) return;
|
||||||
|
|
||||||
|
const currentRoundMatches = this.storage.select("match", {
|
||||||
|
round_id: round.id,
|
||||||
|
});
|
||||||
|
if (!currentRoundMatches) return;
|
||||||
|
|
||||||
|
const nextRoundMatches = this.storage.select("match", {
|
||||||
|
round_id: nextRound.id,
|
||||||
|
});
|
||||||
|
if (!nextRoundMatches) return;
|
||||||
|
|
||||||
|
for (const nextMatch of nextRoundMatches) {
|
||||||
|
if (nextMatch.status !== Status.Locked) continue;
|
||||||
|
|
||||||
|
const participant1Id = nextMatch.opponent1?.id;
|
||||||
|
const participant2Id = nextMatch.opponent2?.id;
|
||||||
|
|
||||||
|
if (!participant1Id || !participant2Id) continue;
|
||||||
|
|
||||||
|
const participant1Ready = this.isParticipantReadyForNextRound(
|
||||||
|
participant1Id,
|
||||||
|
currentRoundMatches,
|
||||||
|
);
|
||||||
|
const participant2Ready = this.isParticipantReadyForNextRound(
|
||||||
|
participant2Id,
|
||||||
|
currentRoundMatches,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (participant1Ready && participant2Ready) {
|
||||||
|
nextMatch.status = Status.Ready;
|
||||||
|
this.applyMatchUpdate(nextMatch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a participant has completed their match in the current round.
|
||||||
|
*
|
||||||
|
* @param participantId The participant to check.
|
||||||
|
* @param roundMatches All matches in the round.
|
||||||
|
*/
|
||||||
|
protected isParticipantReadyForNextRound(
|
||||||
|
participantId: number,
|
||||||
|
roundMatches: Match[],
|
||||||
|
): boolean {
|
||||||
|
const participantMatch = roundMatches.find(
|
||||||
|
(m) =>
|
||||||
|
m.opponent1?.id === participantId || m.opponent2?.id === participantId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// If the participant doesn't have a match in this round, they had a bye/didn't play
|
||||||
|
// and are considered ready
|
||||||
|
if (!participantMatch) return true;
|
||||||
|
|
||||||
|
// If the match has a BYE (one opponent is null), it's considered completed
|
||||||
|
if (!participantMatch.opponent1?.id || !participantMatch.opponent2?.id)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return participantMatch.status >= Status.Completed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import type {
|
import {
|
||||||
Group,
|
type Group,
|
||||||
InputStage,
|
type InputStage,
|
||||||
Match,
|
type Match,
|
||||||
Round,
|
type Round,
|
||||||
Seeding,
|
type Seeding,
|
||||||
SeedOrdering,
|
type SeedOrdering,
|
||||||
Stage,
|
type Stage,
|
||||||
|
Status,
|
||||||
} from "~/modules/brackets-model";
|
} from "~/modules/brackets-model";
|
||||||
import type { BracketsManager } from ".";
|
import type { BracketsManager } from ".";
|
||||||
import * as helpers from "./helpers";
|
import * as helpers from "./helpers";
|
||||||
|
|
@ -33,9 +34,7 @@ export class Create {
|
||||||
private storage: Storage;
|
private storage: Storage;
|
||||||
private stage: InputStage;
|
private stage: InputStage;
|
||||||
private readonly seedOrdering: SeedOrdering[];
|
private readonly seedOrdering: SeedOrdering[];
|
||||||
private updateMode: boolean;
|
|
||||||
private enableByesInUpdate: boolean;
|
private enableByesInUpdate: boolean;
|
||||||
private currentStageId!: number;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an instance of Create, which will handle the creation of the stage.
|
* Creates an instance of Create, which will handle the creation of the stage.
|
||||||
|
|
@ -48,7 +47,6 @@ export class Create {
|
||||||
this.stage = stage;
|
this.stage = stage;
|
||||||
this.stage.settings = this.stage.settings || {};
|
this.stage.settings = this.stage.settings || {};
|
||||||
this.seedOrdering = this.stage.settings.seedOrdering || [];
|
this.seedOrdering = this.stage.settings.seedOrdering || [];
|
||||||
this.updateMode = false;
|
|
||||||
this.enableByesInUpdate = false;
|
this.enableByesInUpdate = false;
|
||||||
|
|
||||||
if (!this.stage.name) throw Error("You must provide a name for the stage.");
|
if (!this.stage.name) throw Error("You must provide a name for the stage.");
|
||||||
|
|
@ -96,18 +94,6 @@ export class Create {
|
||||||
return stage;
|
return stage;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Enables the update mode.
|
|
||||||
*
|
|
||||||
* @param stageId ID of the stage.
|
|
||||||
* @param enableByes Whether to use BYEs or TBDs for `null` values in an input seeding.
|
|
||||||
*/
|
|
||||||
public setExisting(stageId: number, enableByes: boolean): void {
|
|
||||||
this.updateMode = true;
|
|
||||||
this.currentStageId = stageId;
|
|
||||||
this.enableByesInUpdate = enableByes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a round-robin stage.
|
* Creates a round-robin stage.
|
||||||
*
|
*
|
||||||
|
|
@ -396,8 +382,9 @@ export class Create {
|
||||||
|
|
||||||
if (roundId === -1) throw Error("Could not insert the round.");
|
if (roundId === -1) throw Error("Could not insert the round.");
|
||||||
|
|
||||||
for (let i = 0; i < matchCount; i++)
|
for (let i = 0; i < matchCount; i++) {
|
||||||
this.createMatch(stageId, groupId, roundId, i + 1, duels[i]);
|
this.createMatch(stageId, groupId, roundId, i + 1, roundNumber, duels[i]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -414,6 +401,7 @@ export class Create {
|
||||||
groupId: number,
|
groupId: number,
|
||||||
roundId: number,
|
roundId: number,
|
||||||
matchNumber: number,
|
matchNumber: number,
|
||||||
|
roundNumber: number,
|
||||||
opponents: Duel,
|
opponents: Duel,
|
||||||
): void {
|
): void {
|
||||||
const opponent1 = helpers.toResultWithPosition(opponents[0]);
|
const opponent1 = helpers.toResultWithPosition(opponents[0]);
|
||||||
|
|
@ -427,20 +415,12 @@ export class Create {
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
let existing: Match | null = null;
|
|
||||||
let status = helpers.getMatchStatus(opponents);
|
let status = helpers.getMatchStatus(opponents);
|
||||||
|
|
||||||
if (this.updateMode) {
|
// In round-robin, only the first round is ready to play at the beginning.
|
||||||
existing = this.storage.selectFirst("match", {
|
// other matches have teams set but they are busy playing the first round.
|
||||||
round_id: roundId,
|
if (this.stage.type === "round_robin" && roundNumber > 1) {
|
||||||
number: matchNumber,
|
status = Status.Locked;
|
||||||
});
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
// Keep the most advanced status when updating a match.
|
|
||||||
const existingStatus = helpers.getMatchStatus(existing);
|
|
||||||
if (existingStatus > status) status = existingStatus;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const parentId = this.insertMatch(
|
const parentId = this.insertMatch(
|
||||||
|
|
@ -449,11 +429,11 @@ export class Create {
|
||||||
stage_id: stageId,
|
stage_id: stageId,
|
||||||
group_id: groupId,
|
group_id: groupId,
|
||||||
round_id: roundId,
|
round_id: roundId,
|
||||||
status: status,
|
status,
|
||||||
opponent1,
|
opponent1,
|
||||||
opponent2,
|
opponent2,
|
||||||
},
|
},
|
||||||
existing,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (parentId === -1) throw Error("Could not insert the match.");
|
if (parentId === -1) throw Error("Could not insert the match.");
|
||||||
|
|
@ -755,14 +735,7 @@ export class Create {
|
||||||
* @param stage The stage to insert.
|
* @param stage The stage to insert.
|
||||||
*/
|
*/
|
||||||
private insertStage(stage: OmitId<Stage>): number {
|
private insertStage(stage: OmitId<Stage>): number {
|
||||||
let existing: Stage | null = null;
|
return this.storage.insert("stage", stage);
|
||||||
|
|
||||||
if (this.updateMode)
|
|
||||||
existing = this.storage.select("stage", this.currentStageId);
|
|
||||||
|
|
||||||
if (!existing) return this.storage.insert("stage", stage);
|
|
||||||
|
|
||||||
return existing.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -771,18 +744,7 @@ export class Create {
|
||||||
* @param group The group to insert.
|
* @param group The group to insert.
|
||||||
*/
|
*/
|
||||||
private insertGroup(group: OmitId<Group>): number {
|
private insertGroup(group: OmitId<Group>): number {
|
||||||
let existing: Group | null = null;
|
return this.storage.insert("group", group);
|
||||||
|
|
||||||
if (this.updateMode) {
|
|
||||||
existing = this.storage.selectFirst("group", {
|
|
||||||
stage_id: group.stage_id,
|
|
||||||
number: group.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existing) return this.storage.insert("group", group);
|
|
||||||
|
|
||||||
return existing.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -791,18 +753,7 @@ export class Create {
|
||||||
* @param round The round to insert.
|
* @param round The round to insert.
|
||||||
*/
|
*/
|
||||||
private insertRound(round: OmitId<Round>): number {
|
private insertRound(round: OmitId<Round>): number {
|
||||||
let existing: Round | null = null;
|
return this.storage.insert("round", round);
|
||||||
|
|
||||||
if (this.updateMode) {
|
|
||||||
existing = this.storage.selectFirst("round", {
|
|
||||||
group_id: round.group_id,
|
|
||||||
number: round.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existing) return this.storage.insert("round", round);
|
|
||||||
|
|
||||||
return existing.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -228,4 +228,96 @@ describe("Update scores in a round-robin stage", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should unlock next round matches as soon as both participants are ready", () => {
|
||||||
|
// Round robin with 4 teams: [1, 2, 3, 4]
|
||||||
|
// Round 1: Match 0 (1 vs 2), Match 1 (3 vs 4)
|
||||||
|
// Round 2: Match 2 (1 vs 3), Match 3 (2 vs 4)
|
||||||
|
// Round 3: Match 4 (1 vs 4), Match 5 (2 vs 3)
|
||||||
|
|
||||||
|
const round1Match1 = storage.select<any>("match", 0)!;
|
||||||
|
const round1Match2 = storage.select<any>("match", 1)!;
|
||||||
|
const round2Match1 = storage.select<any>("match", 2)!;
|
||||||
|
const round2Match2 = storage.select<any>("match", 3)!;
|
||||||
|
|
||||||
|
// Initially, only round 1 matches should be ready
|
||||||
|
expect(round1Match1.status).toBe(2); // Ready (1 vs 2)
|
||||||
|
expect(round1Match2.status).toBe(2); // Ready (3 vs 4)
|
||||||
|
expect(round2Match1.status).toBe(0); // Locked (1 vs 3)
|
||||||
|
expect(round2Match2.status).toBe(0); // Locked (2 vs 4)
|
||||||
|
|
||||||
|
// Complete first match of round 1 (1 vs 2)
|
||||||
|
manager.update.match({
|
||||||
|
id: 0,
|
||||||
|
opponent1: { score: 16, result: "win" }, // Team 1 wins
|
||||||
|
opponent2: { score: 9 }, // Team 2 loses
|
||||||
|
});
|
||||||
|
|
||||||
|
// Round 2 Match 1 (1 vs 3) should still be locked because team 3 hasn't finished
|
||||||
|
// Round 2 Match 2 (2 vs 4) should still be locked because team 4 hasn't finished
|
||||||
|
let round2Match1After = storage.select<any>("match", 2)!;
|
||||||
|
let round2Match2After = storage.select<any>("match", 3)!;
|
||||||
|
expect(round2Match1After.status).toBe(0); // Still Locked
|
||||||
|
expect(round2Match2After.status).toBe(0); // Still Locked
|
||||||
|
|
||||||
|
// Complete second match of round 1 (3 vs 4)
|
||||||
|
manager.update.match({
|
||||||
|
id: 1,
|
||||||
|
opponent1: { score: 3 }, // Team 3 loses
|
||||||
|
opponent2: { score: 16, result: "win" }, // Team 4 wins
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now both matches in round 2 should be unlocked
|
||||||
|
// Match 2 (1 vs 3): both team 1 and team 3 have finished round 1
|
||||||
|
// Match 3 (2 vs 4): both team 2 and team 4 have finished round 1
|
||||||
|
round2Match1After = storage.select<any>("match", 2)!;
|
||||||
|
round2Match2After = storage.select<any>("match", 3)!;
|
||||||
|
expect(round2Match1After.status).toBe(2); // Ready
|
||||||
|
expect(round2Match2After.status).toBe(2); // Ready
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should unlock next round matches with BYE participants", () => {
|
||||||
|
storage.reset();
|
||||||
|
// Create a round robin with 3 teams (odd number creates rounds where one team doesn't play)
|
||||||
|
manager.create({
|
||||||
|
name: "Example with BYEs",
|
||||||
|
tournamentId: 0,
|
||||||
|
type: "round_robin",
|
||||||
|
seeding: [1, 2, 3],
|
||||||
|
settings: { groupCount: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// With 3 teams, the rounds look like:
|
||||||
|
// Round 1: Match (teams 3 vs 2) - Team 1 doesn't play
|
||||||
|
// Round 2: Match (teams 1 vs 3) - Team 2 doesn't play
|
||||||
|
// Round 3: Match (teams 2 vs 1) - Team 3 doesn't play
|
||||||
|
|
||||||
|
const allMatches = storage.select<any>("match")!;
|
||||||
|
const allRounds = storage.select<any>("round")!;
|
||||||
|
|
||||||
|
// Find the actual match (not BYE vs BYE which doesn't exist)
|
||||||
|
const round1RealMatch = allMatches.find(
|
||||||
|
(m: any) => m.round_id === allRounds[0].id && m.opponent1 && m.opponent2,
|
||||||
|
)!;
|
||||||
|
const round2RealMatch = allMatches.find(
|
||||||
|
(m: any) => m.round_id === allRounds[1].id && m.opponent1 && m.opponent2,
|
||||||
|
)!;
|
||||||
|
|
||||||
|
expect(round1RealMatch.status).toBe(2); // Ready
|
||||||
|
expect(round2RealMatch.status).toBe(0); // Locked initially
|
||||||
|
|
||||||
|
// Complete the only real match in round 1 (teams 3 vs 2)
|
||||||
|
// Team 1 didn't play in round 1
|
||||||
|
manager.update.match({
|
||||||
|
id: round1RealMatch.id,
|
||||||
|
opponent1: { score: 16, result: "win" },
|
||||||
|
opponent2: { score: 9 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// The real match in round 2 (teams 1 vs 3) should now be unlocked
|
||||||
|
// because team 1 didn't play in round 1 (considered ready)
|
||||||
|
// and team 3 just finished their match
|
||||||
|
const round2AfterUpdate = storage.select<any>("match", round2RealMatch.id)!;
|
||||||
|
expect(round2AfterUpdate.status).toBe(2); // Ready
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,5 @@ export interface Match extends MatchResults {
|
||||||
/** The number of the match in its round. */
|
/** The number of the match in its round. */
|
||||||
number: number;
|
number: number;
|
||||||
|
|
||||||
lastGameFinishedAt?: number | null;
|
startedAt?: number | null;
|
||||||
|
|
||||||
createdAt?: number | null;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@ async function createTestTournament({
|
||||||
bracketUrl: "https://example.com/bracket",
|
bracketUrl: "https://example.com/bracket",
|
||||||
description: null,
|
description: null,
|
||||||
discordInviteCode,
|
discordInviteCode,
|
||||||
deadlines: "DEFAULT",
|
|
||||||
name,
|
name,
|
||||||
organizationId: null,
|
organizationId: null,
|
||||||
rules: null,
|
rules: null,
|
||||||
|
|
|
||||||
|
|
@ -310,6 +310,10 @@
|
||||||
margin-inline-start: var(--s-4);
|
margin-inline-start: var(--s-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ml-6 {
|
||||||
|
margin-inline-start: var(--s-6);
|
||||||
|
}
|
||||||
|
|
||||||
.mr-auto {
|
.mr-auto {
|
||||||
margin-inline-end: auto;
|
margin-inline-end: auto;
|
||||||
}
|
}
|
||||||
|
|
@ -404,6 +408,10 @@
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contents {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
.flex {
|
.flex {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
|
|
@ -79,10 +79,6 @@ Especially for tournaments where verification is important. Players need to have
|
||||||
|
|
||||||
All teams added by the tournament organizer manually. No open registration or subs list. In addition for invitational teams can add only 5 members before the tournament starts on their own (and 6 during it if autonomous subs are enabled).
|
All teams added by the tournament organizer manually. No open registration or subs list. In addition for invitational teams can add only 5 members before the tournament starts on their own (and 6 during it if autonomous subs are enabled).
|
||||||
|
|
||||||
### Strict deadlines
|
|
||||||
|
|
||||||
Display the "deadline" for each round as 5 minutes stricter. Note that this is only visual and it's up to the tournament organizer how to enforce these if at all.
|
|
||||||
|
|
||||||
## Tournament maps
|
## Tournament maps
|
||||||
|
|
||||||
With sendou.ink tournaments all maps are decided ahead of time.
|
With sendou.ink tournaments all maps are decided ahead of time.
|
||||||
|
|
|
||||||
|
|
@ -682,6 +682,16 @@ test.describe("Tournament bracket", () => {
|
||||||
await page.getByTestId("finalize-bracket-button").click();
|
await page.getByTestId("finalize-bracket-button").click();
|
||||||
await page.getByTestId("confirm-finalize-bracket-button").click();
|
await page.getByTestId("confirm-finalize-bracket-button").click();
|
||||||
|
|
||||||
|
// needs also to be completed so 9 unlocks
|
||||||
|
await navigateToMatch(page, 7);
|
||||||
|
await reportResult({
|
||||||
|
page,
|
||||||
|
amountOfMapsToReport: 2,
|
||||||
|
sidesWithMoreThanFourPlayers: ["last"],
|
||||||
|
points: [100, 0],
|
||||||
|
});
|
||||||
|
await backToBracket(page);
|
||||||
|
|
||||||
// set situation where match A is completed and its participants also completed their follow up matches B & C
|
// set situation where match A is completed and its participants also completed their follow up matches B & C
|
||||||
// and then we go back and change the winner of A
|
// and then we go back and change the winner of A
|
||||||
await navigateToMatch(page, 8);
|
await navigateToMatch(page, 8);
|
||||||
|
|
@ -1079,4 +1089,42 @@ test.describe("Tournament bracket", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("can end set early when past time limit and shows timer on bracket and match page", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const tournamentId = 2;
|
||||||
|
const matchId = 5;
|
||||||
|
|
||||||
|
await startBracket(page, tournamentId);
|
||||||
|
await navigateToMatch(page, matchId);
|
||||||
|
|
||||||
|
await page.clock.install({ time: new Date() });
|
||||||
|
|
||||||
|
await reportResult({ page, amountOfMapsToReport: 1, winner: 1 });
|
||||||
|
|
||||||
|
await expect(page.getByTestId("match-timer")).toBeVisible();
|
||||||
|
|
||||||
|
await backToBracket(page);
|
||||||
|
|
||||||
|
// Fast forward a bit to ensure timer shows on bracket
|
||||||
|
await page.clock.fastForward("00:10"); // 10 seconds
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
const bracketMatch = page.locator('[data-match-id="5"]');
|
||||||
|
await expect(bracketMatch).toBeVisible();
|
||||||
|
|
||||||
|
// Fast forward time past limit (30 minutes for Bo3 = 26min limit)
|
||||||
|
await page.clock.fastForward("29:50"); // Total 30 minutes
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await navigateToMatch(page, matchId);
|
||||||
|
|
||||||
|
await page.getByText("End Set").click();
|
||||||
|
await page.getByRole("radio", { name: /Random/ }).check();
|
||||||
|
await page.getByTestId("end-set-button").click();
|
||||||
|
|
||||||
|
// Verify match ended early
|
||||||
|
await expect(page.getByText("Match ended early")).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,11 @@
|
||||||
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Spil alle runder {{bestOf}})",
|
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Spil alle runder {{bestOf}})",
|
||||||
"match.action.undoLastScore": "Annuller sidste score",
|
"match.action.undoLastScore": "Annuller sidste score",
|
||||||
"match.action.reopenMatch": "Genåbn kamp",
|
"match.action.reopenMatch": "Genåbn kamp",
|
||||||
|
"match.action.endSet": "",
|
||||||
|
"match.action.confirmEndSet": "",
|
||||||
|
"match.endSet.selectWinner": "",
|
||||||
|
"match.endSet.randomWinner": "",
|
||||||
|
"match.deadline.explanation": "",
|
||||||
"join.error.MISSING_CODE": "Invitationskoden mangler. Blev hele linket kopieret?",
|
"join.error.MISSING_CODE": "Invitationskoden mangler. Blev hele linket kopieret?",
|
||||||
"join.error.SHORT_CODE": "Invitationskoden har ikke den korrekte længde. Blev hele linket kopieret?",
|
"join.error.SHORT_CODE": "Invitationskoden har ikke den korrekte længde. Blev hele linket kopieret?",
|
||||||
"join.error.NO_TEAM_MATCHING_CODE": "Ingen hold passer til denne invitationskode",
|
"join.error.NO_TEAM_MATCHING_CODE": "Ingen hold passer til denne invitationskode",
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,11 @@
|
||||||
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Play all {{bestOf}})",
|
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Play all {{bestOf}})",
|
||||||
"match.action.undoLastScore": "Letztes Ergebnis widerrufen",
|
"match.action.undoLastScore": "Letztes Ergebnis widerrufen",
|
||||||
"match.action.reopenMatch": "Match erneut öffnen",
|
"match.action.reopenMatch": "Match erneut öffnen",
|
||||||
|
"match.action.endSet": "",
|
||||||
|
"match.action.confirmEndSet": "",
|
||||||
|
"match.endSet.selectWinner": "",
|
||||||
|
"match.endSet.randomWinner": "",
|
||||||
|
"match.deadline.explanation": "",
|
||||||
"join.error.MISSING_CODE": "Invite-Code fehlt. Wurde die vollständige URL kopiert?",
|
"join.error.MISSING_CODE": "Invite-Code fehlt. Wurde die vollständige URL kopiert?",
|
||||||
"join.error.SHORT_CODE": "Invite-Code hat nicht die richtige Länge. Wurde die vollständige URL kopiert?",
|
"join.error.SHORT_CODE": "Invite-Code hat nicht die richtige Länge. Wurde die vollständige URL kopiert?",
|
||||||
"join.error.NO_TEAM_MATCHING_CODE": "Kein Team mit diesem Invite-Code gefunden.",
|
"join.error.NO_TEAM_MATCHING_CODE": "Kein Team mit diesem Invite-Code gefunden.",
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,11 @@
|
||||||
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Play all {{bestOf}})",
|
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Play all {{bestOf}})",
|
||||||
"match.action.undoLastScore": "Undo last score",
|
"match.action.undoLastScore": "Undo last score",
|
||||||
"match.action.reopenMatch": "Reopen match",
|
"match.action.reopenMatch": "Reopen match",
|
||||||
|
"match.action.endSet": "End set",
|
||||||
|
"match.action.confirmEndSet": "Confirm End Set",
|
||||||
|
"match.endSet.selectWinner": "Select Winner",
|
||||||
|
"match.endSet.randomWinner": "Random (50/50)",
|
||||||
|
"match.deadline.explanation": "Please let tournament organizers know about any delays. Matches that go past their deadline may be ended early. Consult tournament rules for details.",
|
||||||
"join.error.MISSING_CODE": "Invite code is missing. Was the full URL copied?",
|
"join.error.MISSING_CODE": "Invite code is missing. Was the full URL copied?",
|
||||||
"join.error.SHORT_CODE": "Invite code is not the right length. Was the full URL copied?",
|
"join.error.SHORT_CODE": "Invite code is not the right length. Was the full URL copied?",
|
||||||
"join.error.NO_TEAM_MATCHING_CODE": "No team matching the invite code.",
|
"join.error.NO_TEAM_MATCHING_CODE": "No team matching the invite code.",
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,11 @@
|
||||||
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Jugar todos los {{bestOf}})",
|
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Jugar todos los {{bestOf}})",
|
||||||
"match.action.undoLastScore": "Anular resultado previo",
|
"match.action.undoLastScore": "Anular resultado previo",
|
||||||
"match.action.reopenMatch": "Reabrir partido",
|
"match.action.reopenMatch": "Reabrir partido",
|
||||||
|
"match.action.endSet": "",
|
||||||
|
"match.action.confirmEndSet": "",
|
||||||
|
"match.endSet.selectWinner": "",
|
||||||
|
"match.endSet.randomWinner": "",
|
||||||
|
"match.deadline.explanation": "",
|
||||||
"join.error.MISSING_CODE": "Falta código de invitación. ¿Copiaste el enlace completo?",
|
"join.error.MISSING_CODE": "Falta código de invitación. ¿Copiaste el enlace completo?",
|
||||||
"join.error.SHORT_CODE": "Código de invitación es de cantidad incorrecata. ¿Copiaste el enlace completo?",
|
"join.error.SHORT_CODE": "Código de invitación es de cantidad incorrecata. ¿Copiaste el enlace completo?",
|
||||||
"join.error.NO_TEAM_MATCHING_CODE": "Ningún equipo coincide con el código de invitación.",
|
"join.error.NO_TEAM_MATCHING_CODE": "Ningún equipo coincide con el código de invitación.",
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,11 @@
|
||||||
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Jugar todos los {{bestOf}})",
|
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Jugar todos los {{bestOf}})",
|
||||||
"match.action.undoLastScore": "Anular resultado previo",
|
"match.action.undoLastScore": "Anular resultado previo",
|
||||||
"match.action.reopenMatch": "Reabrir partido",
|
"match.action.reopenMatch": "Reabrir partido",
|
||||||
|
"match.action.endSet": "",
|
||||||
|
"match.action.confirmEndSet": "",
|
||||||
|
"match.endSet.selectWinner": "",
|
||||||
|
"match.endSet.randomWinner": "",
|
||||||
|
"match.deadline.explanation": "",
|
||||||
"join.error.MISSING_CODE": "Falta código de invitación. ¿Copiaste el enlace completo?",
|
"join.error.MISSING_CODE": "Falta código de invitación. ¿Copiaste el enlace completo?",
|
||||||
"join.error.SHORT_CODE": "Código de invitación es de cantidad incorrecata. ¿Copiaste el enlace completo?",
|
"join.error.SHORT_CODE": "Código de invitación es de cantidad incorrecata. ¿Copiaste el enlace completo?",
|
||||||
"join.error.NO_TEAM_MATCHING_CODE": "Ningún equipo coincide con el código de invitación.",
|
"join.error.NO_TEAM_MATCHING_CODE": "Ningún equipo coincide con el código de invitación.",
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,11 @@
|
||||||
"match.score.playAll": "",
|
"match.score.playAll": "",
|
||||||
"match.action.undoLastScore": "Annuler le dernier score",
|
"match.action.undoLastScore": "Annuler le dernier score",
|
||||||
"match.action.reopenMatch": "Rouvrir le match",
|
"match.action.reopenMatch": "Rouvrir le match",
|
||||||
|
"match.action.endSet": "",
|
||||||
|
"match.action.confirmEndSet": "",
|
||||||
|
"match.endSet.selectWinner": "",
|
||||||
|
"match.endSet.randomWinner": "",
|
||||||
|
"match.deadline.explanation": "",
|
||||||
"join.error.MISSING_CODE": "Le code d'invitation est manquant. L'URL complète a-t-elle été copiée ?",
|
"join.error.MISSING_CODE": "Le code d'invitation est manquant. L'URL complète a-t-elle été copiée ?",
|
||||||
"join.error.SHORT_CODE": "Le code d'invitation n'a pas la bonne longueur. L'URL complète a-t-elle été copiée ?",
|
"join.error.SHORT_CODE": "Le code d'invitation n'a pas la bonne longueur. L'URL complète a-t-elle été copiée ?",
|
||||||
"join.error.NO_TEAM_MATCHING_CODE": "Aucune équipe ne correspond au code d'invitation.",
|
"join.error.NO_TEAM_MATCHING_CODE": "Aucune équipe ne correspond au code d'invitation.",
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,11 @@
|
||||||
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Tous jouer {{bestOf}})",
|
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Tous jouer {{bestOf}})",
|
||||||
"match.action.undoLastScore": "Annuler le dernier score",
|
"match.action.undoLastScore": "Annuler le dernier score",
|
||||||
"match.action.reopenMatch": "Rouvrir le match",
|
"match.action.reopenMatch": "Rouvrir le match",
|
||||||
|
"match.action.endSet": "",
|
||||||
|
"match.action.confirmEndSet": "",
|
||||||
|
"match.endSet.selectWinner": "",
|
||||||
|
"match.endSet.randomWinner": "",
|
||||||
|
"match.deadline.explanation": "",
|
||||||
"join.error.MISSING_CODE": "Le code d'invitation est manquant. L'URL complète a-t-elle été copiée ?",
|
"join.error.MISSING_CODE": "Le code d'invitation est manquant. L'URL complète a-t-elle été copiée ?",
|
||||||
"join.error.SHORT_CODE": "Le code d'invitation n'a pas la bonne longueur. L'URL complète a-t-elle été copiée ?",
|
"join.error.SHORT_CODE": "Le code d'invitation n'a pas la bonne longueur. L'URL complète a-t-elle été copiée ?",
|
||||||
"join.error.NO_TEAM_MATCHING_CODE": "Aucune équipe ne correspond au code d'invitation.",
|
"join.error.NO_TEAM_MATCHING_CODE": "Aucune équipe ne correspond au code d'invitation.",
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,11 @@
|
||||||
"match.score.playAll": "",
|
"match.score.playAll": "",
|
||||||
"match.action.undoLastScore": "בטלו את התוצאה האחרונה",
|
"match.action.undoLastScore": "בטלו את התוצאה האחרונה",
|
||||||
"match.action.reopenMatch": "פתיחה מחדש של הקרב",
|
"match.action.reopenMatch": "פתיחה מחדש של הקרב",
|
||||||
|
"match.action.endSet": "",
|
||||||
|
"match.action.confirmEndSet": "",
|
||||||
|
"match.endSet.selectWinner": "",
|
||||||
|
"match.endSet.randomWinner": "",
|
||||||
|
"match.deadline.explanation": "",
|
||||||
"join.error.MISSING_CODE": "חסר קוד הזמנה. האם כתובת האתר המלאה הועתקה?",
|
"join.error.MISSING_CODE": "חסר קוד הזמנה. האם כתובת האתר המלאה הועתקה?",
|
||||||
"join.error.SHORT_CODE": "קוד ההזמנה אינו באורך המתאים. האם כתובת האתר המלאה הועתקה?",
|
"join.error.SHORT_CODE": "קוד ההזמנה אינו באורך המתאים. האם כתובת האתר המלאה הועתקה?",
|
||||||
"join.error.NO_TEAM_MATCHING_CODE": "אין צוות שתואם את קוד ההזמנה.",
|
"join.error.NO_TEAM_MATCHING_CODE": "אין צוות שתואם את קוד ההזמנה.",
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,11 @@
|
||||||
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Play all {{bestOf}})",
|
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Play all {{bestOf}})",
|
||||||
"match.action.undoLastScore": "Annulla ultimo punteggio",
|
"match.action.undoLastScore": "Annulla ultimo punteggio",
|
||||||
"match.action.reopenMatch": "Riapri match",
|
"match.action.reopenMatch": "Riapri match",
|
||||||
|
"match.action.endSet": "",
|
||||||
|
"match.action.confirmEndSet": "",
|
||||||
|
"match.endSet.selectWinner": "",
|
||||||
|
"match.endSet.randomWinner": "",
|
||||||
|
"match.deadline.explanation": "",
|
||||||
"join.error.MISSING_CODE": "Il codice invito è mancante. Hai copiato l'intero URL?",
|
"join.error.MISSING_CODE": "Il codice invito è mancante. Hai copiato l'intero URL?",
|
||||||
"join.error.SHORT_CODE": "Il codice invito non ha la lunghezza giusta. Hai copiato l'intero URL?",
|
"join.error.SHORT_CODE": "Il codice invito non ha la lunghezza giusta. Hai copiato l'intero URL?",
|
||||||
"join.error.NO_TEAM_MATCHING_CODE": "Nessun team associato a questo codice invito.",
|
"join.error.NO_TEAM_MATCHING_CODE": "Nessun team associato a questo codice invito.",
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,11 @@
|
||||||
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (全てプレイする {{bestOf}})",
|
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (全てプレイする {{bestOf}})",
|
||||||
"match.action.undoLastScore": "最後のスコアをやりなおす",
|
"match.action.undoLastScore": "最後のスコアをやりなおす",
|
||||||
"match.action.reopenMatch": "対戦を再度開く",
|
"match.action.reopenMatch": "対戦を再度開く",
|
||||||
|
"match.action.endSet": "",
|
||||||
|
"match.action.confirmEndSet": "",
|
||||||
|
"match.endSet.selectWinner": "",
|
||||||
|
"match.endSet.randomWinner": "",
|
||||||
|
"match.deadline.explanation": "",
|
||||||
"join.error.MISSING_CODE": "招待コードがみつかりません。すべての URL をコピーしましたか?",
|
"join.error.MISSING_CODE": "招待コードがみつかりません。すべての URL をコピーしましたか?",
|
||||||
"join.error.SHORT_CODE": "招待コードの長さが正しくありません。すべての URL をコピーしましたか?",
|
"join.error.SHORT_CODE": "招待コードの長さが正しくありません。すべての URL をコピーしましたか?",
|
||||||
"join.error.NO_TEAM_MATCHING_CODE": "この招待コードにあうチームがみつかりません。",
|
"join.error.NO_TEAM_MATCHING_CODE": "この招待コードにあうチームがみつかりません。",
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,11 @@
|
||||||
"match.score.playAll": "",
|
"match.score.playAll": "",
|
||||||
"match.action.undoLastScore": "",
|
"match.action.undoLastScore": "",
|
||||||
"match.action.reopenMatch": "",
|
"match.action.reopenMatch": "",
|
||||||
|
"match.action.endSet": "",
|
||||||
|
"match.action.confirmEndSet": "",
|
||||||
|
"match.endSet.selectWinner": "",
|
||||||
|
"match.endSet.randomWinner": "",
|
||||||
|
"match.deadline.explanation": "",
|
||||||
"join.error.MISSING_CODE": "",
|
"join.error.MISSING_CODE": "",
|
||||||
"join.error.SHORT_CODE": "",
|
"join.error.SHORT_CODE": "",
|
||||||
"join.error.NO_TEAM_MATCHING_CODE": "",
|
"join.error.NO_TEAM_MATCHING_CODE": "",
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,11 @@
|
||||||
"match.score.playAll": "",
|
"match.score.playAll": "",
|
||||||
"match.action.undoLastScore": "",
|
"match.action.undoLastScore": "",
|
||||||
"match.action.reopenMatch": "",
|
"match.action.reopenMatch": "",
|
||||||
|
"match.action.endSet": "",
|
||||||
|
"match.action.confirmEndSet": "",
|
||||||
|
"match.endSet.selectWinner": "",
|
||||||
|
"match.endSet.randomWinner": "",
|
||||||
|
"match.deadline.explanation": "",
|
||||||
"join.error.MISSING_CODE": "",
|
"join.error.MISSING_CODE": "",
|
||||||
"join.error.SHORT_CODE": "",
|
"join.error.SHORT_CODE": "",
|
||||||
"join.error.NO_TEAM_MATCHING_CODE": "",
|
"join.error.NO_TEAM_MATCHING_CODE": "",
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,11 @@
|
||||||
"match.score.playAll": "",
|
"match.score.playAll": "",
|
||||||
"match.action.undoLastScore": "",
|
"match.action.undoLastScore": "",
|
||||||
"match.action.reopenMatch": "",
|
"match.action.reopenMatch": "",
|
||||||
|
"match.action.endSet": "",
|
||||||
|
"match.action.confirmEndSet": "",
|
||||||
|
"match.endSet.selectWinner": "",
|
||||||
|
"match.endSet.randomWinner": "",
|
||||||
|
"match.deadline.explanation": "",
|
||||||
"join.error.MISSING_CODE": "",
|
"join.error.MISSING_CODE": "",
|
||||||
"join.error.SHORT_CODE": "",
|
"join.error.SHORT_CODE": "",
|
||||||
"join.error.NO_TEAM_MATCHING_CODE": "",
|
"join.error.NO_TEAM_MATCHING_CODE": "",
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,11 @@
|
||||||
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Jogar todas {{bestOf}})",
|
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Jogar todas {{bestOf}})",
|
||||||
"match.action.undoLastScore": "Desfazer última pontuação",
|
"match.action.undoLastScore": "Desfazer última pontuação",
|
||||||
"match.action.reopenMatch": "Reabrir partida",
|
"match.action.reopenMatch": "Reabrir partida",
|
||||||
|
"match.action.endSet": "",
|
||||||
|
"match.action.confirmEndSet": "",
|
||||||
|
"match.endSet.selectWinner": "",
|
||||||
|
"match.endSet.randomWinner": "",
|
||||||
|
"match.deadline.explanation": "",
|
||||||
"join.error.MISSING_CODE": "O código de convite está faltando. O URL foi copiado completamente?",
|
"join.error.MISSING_CODE": "O código de convite está faltando. O URL foi copiado completamente?",
|
||||||
"join.error.SHORT_CODE": "O código de convite está com o comprimento incorreto. O URL foi copiado completamente?",
|
"join.error.SHORT_CODE": "O código de convite está com o comprimento incorreto. O URL foi copiado completamente?",
|
||||||
"join.error.NO_TEAM_MATCHING_CODE": "Nenhum time corresponde ao código de convite.",
|
"join.error.NO_TEAM_MATCHING_CODE": "Nenhum time corresponde ao código de convite.",
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,11 @@
|
||||||
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Играть все {{bestOf}})",
|
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Играть все {{bestOf}})",
|
||||||
"match.action.undoLastScore": "Отменить последний результат",
|
"match.action.undoLastScore": "Отменить последний результат",
|
||||||
"match.action.reopenMatch": "Открыть матч заново",
|
"match.action.reopenMatch": "Открыть матч заново",
|
||||||
|
"match.action.endSet": "",
|
||||||
|
"match.action.confirmEndSet": "",
|
||||||
|
"match.endSet.selectWinner": "",
|
||||||
|
"match.endSet.randomWinner": "",
|
||||||
|
"match.deadline.explanation": "",
|
||||||
"join.error.MISSING_CODE": "Код приглашения отсутствует. Был ли URL скопирован полностью?",
|
"join.error.MISSING_CODE": "Код приглашения отсутствует. Был ли URL скопирован полностью?",
|
||||||
"join.error.SHORT_CODE": "Длина кода приглашения неверна. Был ли URL скопирован полностью?",
|
"join.error.SHORT_CODE": "Длина кода приглашения неверна. Был ли URL скопирован полностью?",
|
||||||
"join.error.NO_TEAM_MATCHING_CODE": "Нет команды, соответствующей коду приглашения.",
|
"join.error.NO_TEAM_MATCHING_CODE": "Нет команды, соответствующей коду приглашения.",
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,11 @@
|
||||||
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (全部 {{bestOf}} 场)",
|
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (全部 {{bestOf}} 场)",
|
||||||
"match.action.undoLastScore": "撤销上次比分",
|
"match.action.undoLastScore": "撤销上次比分",
|
||||||
"match.action.reopenMatch": "重新开始对战",
|
"match.action.reopenMatch": "重新开始对战",
|
||||||
|
"match.action.endSet": "",
|
||||||
|
"match.action.confirmEndSet": "",
|
||||||
|
"match.endSet.selectWinner": "",
|
||||||
|
"match.endSet.randomWinner": "",
|
||||||
|
"match.deadline.explanation": "",
|
||||||
"join.error.MISSING_CODE": "缺少邀请码。您是否复制了完整URL?",
|
"join.error.MISSING_CODE": "缺少邀请码。您是否复制了完整URL?",
|
||||||
"join.error.SHORT_CODE": "邀请码长度不符。您是否复制了完整URL?",
|
"join.error.SHORT_CODE": "邀请码长度不符。您是否复制了完整URL?",
|
||||||
"join.error.NO_TEAM_MATCHING_CODE": "没有队伍与邀请码相匹配。",
|
"join.error.NO_TEAM_MATCHING_CODE": "没有队伍与邀请码相匹配。",
|
||||||
|
|
|
||||||
62
migrations/108-match-started-at.js
Normal file
62
migrations/108-match-started-at.js
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
export function up(db) {
|
||||||
|
db.transaction(() => {
|
||||||
|
db.prepare(
|
||||||
|
/* sql */ `alter table "TournamentMatch" add "startedAt" integer`,
|
||||||
|
).run();
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
/* sql */ `alter table "TournamentMatch" drop column "createdAt"`,
|
||||||
|
).run();
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
/* sql */ `
|
||||||
|
create trigger set_started_at_on_insert
|
||||||
|
after insert on "TournamentMatch"
|
||||||
|
for each row
|
||||||
|
when json_extract(new."opponentOne", '$.id') is not null
|
||||||
|
and json_extract(new."opponentTwo", '$.id') is not null
|
||||||
|
and new."status" = 2
|
||||||
|
and new."startedAt" is null
|
||||||
|
begin
|
||||||
|
update "TournamentMatch"
|
||||||
|
set "startedAt" = (strftime('%s', 'now'))
|
||||||
|
where "id" = new."id";
|
||||||
|
end
|
||||||
|
`,
|
||||||
|
).run();
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
/* sql */ `
|
||||||
|
create trigger set_started_at_on_update
|
||||||
|
after update on "TournamentMatch"
|
||||||
|
for each row
|
||||||
|
when new."startedAt" is null
|
||||||
|
and json_extract(new."opponentOne", '$.id') is not null
|
||||||
|
and json_extract(new."opponentTwo", '$.id') is not null
|
||||||
|
and new."status" = 2
|
||||||
|
begin
|
||||||
|
update "TournamentMatch"
|
||||||
|
set "startedAt" = (strftime('%s', 'now'))
|
||||||
|
where "id" = new."id";
|
||||||
|
end
|
||||||
|
`,
|
||||||
|
).run();
|
||||||
|
|
||||||
|
// note: we are on purpose not handling the case where round robin match is reopened
|
||||||
|
// this could be a future improvement
|
||||||
|
db.prepare(
|
||||||
|
/* sql */ `
|
||||||
|
create trigger clear_started_at_on_update
|
||||||
|
after update on "TournamentMatch"
|
||||||
|
for each row
|
||||||
|
when new."startedAt" is not null
|
||||||
|
and (json_extract(new."opponentOne", '$.id') is null or json_extract(new."opponentTwo", '$.id') is null)
|
||||||
|
begin
|
||||||
|
update "TournamentMatch"
|
||||||
|
set "startedAt" = null
|
||||||
|
where "id" = new."id";
|
||||||
|
end
|
||||||
|
`,
|
||||||
|
).run();
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
@ -70,7 +70,6 @@ async function main() {
|
||||||
authorId: tournament.ctx.author.id,
|
authorId: tournament.ctx.author.id,
|
||||||
bracketProgression: tournament.ctx.settings.bracketProgression,
|
bracketProgression: tournament.ctx.settings.bracketProgression,
|
||||||
description: tournament.ctx.description,
|
description: tournament.ctx.description,
|
||||||
deadlines: tournament.ctx.settings.deadlines,
|
|
||||||
discordInviteCode:
|
discordInviteCode:
|
||||||
tournament.ctx.discordUrl?.replace("https://discord.gg/", "") ?? null,
|
tournament.ctx.discordUrl?.replace("https://discord.gg/", "") ?? null,
|
||||||
mapPickingStyle: tournament.ctx.mapPickingStyle,
|
mapPickingStyle: tournament.ctx.mapPickingStyle,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user