mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -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;
|
||||
/** Enable the subs tab, default true */
|
||||
enableSubs?: boolean;
|
||||
deadlines?: "STRICT" | "DEFAULT";
|
||||
requireInGameNames?: boolean;
|
||||
isInvitational?: boolean;
|
||||
/** Can teams add subs on their own while tournament is in progress? */
|
||||
|
|
@ -551,8 +550,9 @@ export interface TournamentMatch {
|
|||
roundId: number;
|
||||
stageId: number;
|
||||
status: (typeof TournamentMatchStatus)[keyof typeof TournamentMatchStatus];
|
||||
// used only for swiss because it's the only stage type where matches are not created in advance
|
||||
createdAt: Generated<number>;
|
||||
// set when match becomes ongoing (both teams ready and no earlier matches for either team)
|
||||
// for swiss: set at creation time
|
||||
startedAt: number | null;
|
||||
}
|
||||
|
||||
/** Represents one decision, pick or ban, during tournaments pick/ban (counterpick, ban 2) phase. */
|
||||
|
|
|
|||
|
|
@ -495,7 +495,6 @@ type CreateArgs = Pick<
|
|||
isRanked?: boolean;
|
||||
isTest?: boolean;
|
||||
isInvitational?: boolean;
|
||||
deadlines: TournamentSettings["deadlines"];
|
||||
enableNoScreenToggle?: boolean;
|
||||
enableSubs?: boolean;
|
||||
autonomousSubs?: boolean;
|
||||
|
|
@ -529,7 +528,6 @@ export async function create(args: CreateArgs) {
|
|||
thirdPlaceMatch: args.thirdPlaceMatch,
|
||||
isRanked: args.isRanked,
|
||||
isTest: args.isTest,
|
||||
deadlines: args.deadlines,
|
||||
isInvitational: args.isInvitational,
|
||||
enableNoScreenToggle: args.enableNoScreenToggle,
|
||||
enableSubs: args.enableSubs,
|
||||
|
|
@ -726,7 +724,6 @@ async function updateTournamentTables(
|
|||
thirdPlaceMatch: args.thirdPlaceMatch,
|
||||
isRanked: args.isRanked,
|
||||
isTest: existingSettings.isTest, // this one is not editable after creation
|
||||
deadlines: args.deadlines,
|
||||
isInvitational: args.isInvitational,
|
||||
enableNoScreenToggle: args.enableNoScreenToggle,
|
||||
enableSubs: args.enableSubs,
|
||||
|
|
|
|||
|
|
@ -98,7 +98,6 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
isRanked: data.isRanked ?? undefined,
|
||||
isTest: data.isTest ?? undefined,
|
||||
isInvitational: data.isInvitational ?? false,
|
||||
deadlines: data.strictDeadline ? ("STRICT" as const) : ("DEFAULT" as const),
|
||||
enableNoScreenToggle: data.enableNoScreenToggle ?? undefined,
|
||||
enableSubs: data.enableSubs ?? undefined,
|
||||
requireInGameNames: data.requireInGameNames ?? undefined,
|
||||
|
|
|
|||
|
|
@ -80,7 +80,6 @@ export const newCalendarEventActionSchema = z
|
|||
),
|
||||
enableSubs: 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()),
|
||||
requireInGameNames: z.preprocess(
|
||||
checkboxValueToBoolean,
|
||||
|
|
|
|||
|
|
@ -258,7 +258,6 @@ function EventForm() {
|
|||
isInvitational={isInvitational}
|
||||
setIsInvitational={setIsInvitational}
|
||||
/>
|
||||
<StrictDeadlinesToggle />
|
||||
{!eventToEdit ? <TestToggle /> : 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() {
|
||||
const baseEvent = useBaseEvent();
|
||||
const [isTest, setIsTest] = React.useState(
|
||||
|
|
|
|||
|
|
@ -65,7 +65,6 @@ const createCalendarEvent = async (authorId: number, avatarImgId?: number) => {
|
|||
tags: null,
|
||||
mapPickingStyle: "AUTO_SZ",
|
||||
bracketProgression: null,
|
||||
deadlines: "DEFAULT",
|
||||
rules: null,
|
||||
avatarImgId,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import {
|
|||
import { findResultsByMatchId } from "../queries/findResultsByMatchId.server";
|
||||
import { insertTournamentMatchGameResult } from "../queries/insertTournamentMatchGameResult.server";
|
||||
import { insertTournamentMatchGameResultParticipant } from "../queries/insertTournamentMatchGameResultParticipant.server";
|
||||
import { resetMatchStatus } from "../queries/resetMatchStatus.server";
|
||||
import { updateMatchGameResultPoints } from "../queries/updateMatchGameResultPoints.server";
|
||||
import {
|
||||
matchPageParamsSchema,
|
||||
|
|
@ -39,6 +40,7 @@ import {
|
|||
} from "../tournament-bracket-schemas.server";
|
||||
import {
|
||||
isSetOverByScore,
|
||||
matchEndedEarly,
|
||||
matchIsLocked,
|
||||
tournamentMatchWebsocketRoom,
|
||||
tournamentTeamToActiveRosterUserIds,
|
||||
|
|
@ -108,6 +110,7 @@ export const action: ActionFunction = async ({ params, request }) => {
|
|||
|
||||
let emitMatchUpdate = false;
|
||||
let emitTournamentUpdate = false;
|
||||
|
||||
switch (data._action) {
|
||||
case "REPORT_SCORE": {
|
||||
// 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;
|
||||
invariant(typeof scoreOne === "number", "Score one is missing");
|
||||
invariant(typeof scoreTwo === "number", "Score two is missing");
|
||||
invariant(scoreOne !== scoreTwo, "Scores are equal");
|
||||
|
||||
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
|
||||
errorToastIfFalsy(
|
||||
|
|
@ -474,32 +476,54 @@ export const action: ActionFunction = async ({ params, request }) => {
|
|||
|
||||
const results = findResultsByMatchId(matchId);
|
||||
const lastResult = results[results.length - 1];
|
||||
invariant(lastResult, "Last result is missing");
|
||||
|
||||
if (scoreOne > scoreTwo) {
|
||||
scores[0]--;
|
||||
} else {
|
||||
scores[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");
|
||||
|
||||
if (scoreOne > scoreTwo) {
|
||||
scores[0]--;
|
||||
} else {
|
||||
scores[1]--;
|
||||
}
|
||||
}
|
||||
|
||||
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 bracketFormat = tournament.bracketByIdx(
|
||||
tournament.matchIdToBracketIdx(match.id)!,
|
||||
)!.type;
|
||||
sql.transaction(() => {
|
||||
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({
|
||||
id: match.id,
|
||||
opponent1: {
|
||||
score: scores[0],
|
||||
score: endedEarly ? scoreOne : scores[0],
|
||||
result: undefined,
|
||||
},
|
||||
opponent2: {
|
||||
score: scores[1],
|
||||
score: endedEarly ? scoreTwo : scores[1],
|
||||
result: undefined,
|
||||
},
|
||||
});
|
||||
|
|
@ -561,6 +585,56 @@ export const action: ActionFunction = async ({ params, request }) => {
|
|||
|
||||
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: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Link, useFetcher } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import { differenceInMinutes } from "date-fns";
|
||||
import * as React from "react";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
|
|
@ -11,10 +12,13 @@ import {
|
|||
useStreamingParticipants,
|
||||
useTournament,
|
||||
} from "~/features/tournament/routes/to.$id";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
import { tournamentMatchPage, tournamentStreamsPage } from "~/utils/urls";
|
||||
import type { Bracket } from "../../core/Bracket";
|
||||
import * as Deadline from "../../core/Deadline";
|
||||
import type { TournamentData } from "../../core/Tournament.server";
|
||||
import { matchEndedEarly } from "../../tournament-bracket-utils";
|
||||
|
||||
interface MatchProps {
|
||||
match: Unpacked<TournamentData["data"]["match"]>;
|
||||
|
|
@ -24,6 +28,7 @@ interface MatchProps {
|
|||
roundNumber: number;
|
||||
showSimulation: boolean;
|
||||
bracket: Bracket;
|
||||
hideMatchTimer?: boolean;
|
||||
}
|
||||
|
||||
export function Match(props: MatchProps) {
|
||||
|
|
@ -41,6 +46,9 @@ export function Match(props: MatchProps) {
|
|||
<div className="bracket__match__separator" />
|
||||
<MatchRow {...props} side={2} />
|
||||
</MatchWrapper>
|
||||
{!props.hideMatchTimer ? (
|
||||
<MatchTimer match={props.match} bracket={props.bracket} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -96,7 +104,7 @@ function MatchHeader({ match, type, roundNumber, group }: MatchProps) {
|
|||
>
|
||||
Match is scheduled to be casted
|
||||
</SendouPopover>
|
||||
) : hasStreams() ? (
|
||||
) : hasStreams() && match.startedAt ? (
|
||||
<SendouPopover
|
||||
placement="top"
|
||||
popoverClassName="w-max"
|
||||
|
|
@ -154,7 +162,25 @@ function MatchRow({
|
|||
const score = () => {
|
||||
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";
|
||||
|
|
@ -267,3 +293,64 @@ function MatchStreams({ match }: Pick<MatchProps, "match">) {
|
|||
</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 { useTournament } from "~/features/tournament/routes/to.$id";
|
||||
import { resolveLeagueRoundStartDate } from "~/features/tournament/tournament-utils";
|
||||
import { useAutoRerender } from "~/hooks/useAutoRerender";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { TOURNAMENT } from "../../../tournament/tournament-constants";
|
||||
import { useDeadline } from "./useDeadline";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
import * as Deadline from "../../core/Deadline";
|
||||
import type { TournamentData } from "../../core/Tournament.server";
|
||||
|
||||
export function RoundHeader({
|
||||
roundId,
|
||||
|
|
@ -14,22 +15,19 @@ export function RoundHeader({
|
|||
bestOf,
|
||||
showInfos,
|
||||
maps,
|
||||
roundStartedAt = null,
|
||||
matches = [],
|
||||
}: {
|
||||
roundId: number;
|
||||
name: string;
|
||||
bestOf?: number;
|
||||
showInfos?: boolean;
|
||||
maps?: TournamentRoundMaps | null;
|
||||
roundStartedAt?: number | null;
|
||||
matches?: Array<Unpacked<TournamentData["data"]["match"]>>;
|
||||
}) {
|
||||
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 pickBanSuffix =
|
||||
|
|
@ -49,7 +47,13 @@ export function RoundHeader({
|
|||
{bestOf}
|
||||
{pickBanSuffix}
|
||||
</div>
|
||||
{hasDeadline ? <Deadline roundId={roundId} bestOf={bestOf} /> : null}
|
||||
{roundStartedAt && matches && matches.length > 0 ? (
|
||||
<RoundTimer
|
||||
startedAt={roundStartedAt}
|
||||
bestOf={bestOf}
|
||||
matches={matches}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : leagueRoundStartDate ? (
|
||||
<LeagueRoundStartDate date={leagueRoundStartDate} />
|
||||
|
|
@ -78,23 +82,63 @@ function LeagueRoundStartDate({ date }: { date: Date }) {
|
|||
);
|
||||
}
|
||||
|
||||
function Deadline({ roundId, bestOf }: { roundId: number; bestOf: number }) {
|
||||
useAutoRerender("ten seconds");
|
||||
const isMounted = useIsMounted();
|
||||
const deadline = useDeadline(roundId, bestOf);
|
||||
const { formatTime } = useTimeFormat();
|
||||
function RoundTimer({
|
||||
startedAt,
|
||||
bestOf,
|
||||
matches,
|
||||
}: {
|
||||
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 (
|
||||
<div
|
||||
className={clsx({
|
||||
"text-warning": isMounted && deadline < new Date(),
|
||||
})}
|
||||
>
|
||||
DL {formatTime(deadline)}
|
||||
</div>
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const elapsedMinutes = differenceInMinutes(
|
||||
now,
|
||||
databaseTimestampToDate(startedAt),
|
||||
);
|
||||
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export function RoundRobinBracket({ bracket }: { bracket: BracketType }) {
|
|||
});
|
||||
|
||||
return (
|
||||
<div key={groupName} className="stack lg">
|
||||
<div key={groupName} className="stack lg ml-4">
|
||||
<h2 className="text-lg">{groupName}</h2>
|
||||
<div
|
||||
className="elim-bracket__container"
|
||||
|
|
|
|||
|
|
@ -138,6 +138,19 @@ export function SwissBracket({
|
|||
|
||||
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
|
||||
?.id;
|
||||
const teamWithBye = teamWithByeId
|
||||
|
|
@ -156,6 +169,8 @@ export function SwissBracket({
|
|||
bestOf={bestOf}
|
||||
showInfos={someMatchOngoing(matches)}
|
||||
maps={round.maps}
|
||||
roundStartedAt={roundStartedAt}
|
||||
matches={ongoingMatches}
|
||||
/>
|
||||
{roundThatCanBeStartedId() === round.id ? (
|
||||
<fetcher.Form method="post">
|
||||
|
|
@ -223,6 +238,7 @@ export function SwissBracket({
|
|||
bracket={bracket}
|
||||
type="groups"
|
||||
group={selectedGroup.groupName.split(" ")[1]}
|
||||
hideMatchTimer
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,14 @@
|
|||
height: 18.86px;
|
||||
}
|
||||
|
||||
.bracket__match__timer {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
transform: translate(-100%, -50%);
|
||||
margin-inline-start: 8px;
|
||||
}
|
||||
|
||||
.bracket__match {
|
||||
width: var(--match-width);
|
||||
min-height: var(--match-height);
|
||||
|
|
@ -114,6 +122,7 @@ a.bracket__match:hover {
|
|||
var(--round-count),
|
||||
calc(var(--match-width) + var(--line-width))
|
||||
);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.elim-bracket__round-matches-container {
|
||||
|
|
@ -123,6 +132,7 @@ a.bracket__match:hover {
|
|||
gap: var(--s-7);
|
||||
margin-top: var(--s-6);
|
||||
flex: 1;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.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 { Form, useLoaderData } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import { differenceInMinutes } from "date-fns";
|
||||
import type { TFunction } from "i18next";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
|
@ -16,6 +17,7 @@ import { Image } from "~/components/Image";
|
|||
import { CheckmarkIcon } from "~/components/icons/Checkmark";
|
||||
import { CrossIcon } from "~/components/icons/Cross";
|
||||
import { PickIcon } from "~/components/icons/Pick";
|
||||
import { Label } from "~/components/Label";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useChat } from "~/features/chat/chat-hooks";
|
||||
|
|
@ -36,6 +38,7 @@ import {
|
|||
stageImageUrl,
|
||||
} from "~/utils/urls";
|
||||
import type { Bracket } from "../core/Bracket";
|
||||
import * as Deadline from "../core/Deadline";
|
||||
import * as PickBan from "../core/PickBan";
|
||||
import type { TournamentDataTeam } from "../core/Tournament.server";
|
||||
import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server";
|
||||
|
|
@ -48,8 +51,10 @@ import {
|
|||
resolveRoomPass,
|
||||
tournamentTeamToActiveRosterUserIds,
|
||||
} from "../tournament-bracket-utils";
|
||||
import { DeadlineInfoPopover } from "./DeadlineInfoPopover";
|
||||
import { MatchActions } from "./MatchActions";
|
||||
import { MatchRosters } from "./MatchRosters";
|
||||
import { MatchTimer } from "./MatchTimer";
|
||||
|
||||
export type Result = Unpacked<
|
||||
SerializeFrom<TournamentMatchLoaderData>["results"]
|
||||
|
|
@ -90,6 +95,8 @@ export function StartedMatch({
|
|||
(p) => p.id === user?.id,
|
||||
);
|
||||
|
||||
const waitingForPreviousMatch = data.match.status === 0;
|
||||
|
||||
const hostingTeamId = resolveHostingTeam(teams).id;
|
||||
const poolCode = React.useMemo(() => {
|
||||
const match = tournament.brackets
|
||||
|
|
@ -171,6 +178,7 @@ export function StartedMatch({
|
|||
scores: [scoreOne, scoreTwo],
|
||||
tournament,
|
||||
})}
|
||||
waitingForPreviousMatch={waitingForPreviousMatch}
|
||||
>
|
||||
{currentPosition > 0 &&
|
||||
!presentational &&
|
||||
|
|
@ -208,6 +216,19 @@ export function StartedMatch({
|
|||
</div>
|
||||
</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>
|
||||
<ModeProgressIndicator
|
||||
scores={[scoreOne, scoreTwo]}
|
||||
|
|
@ -215,7 +236,7 @@ export function StartedMatch({
|
|||
selectedResultIndex={selectedResultIndex}
|
||||
setSelectedResultIndex={setSelectedResultIndex}
|
||||
/>
|
||||
{type === "EDIT" || presentational ? (
|
||||
{!waitingForPreviousMatch && (type === "EDIT" || presentational) ? (
|
||||
<StartedMatchTabs
|
||||
presentational={presentational}
|
||||
scores={[scoreOne, scoreTwo]}
|
||||
|
|
@ -245,17 +266,22 @@ function FancyStageBanner({
|
|||
children,
|
||||
teams,
|
||||
matchIsLocked,
|
||||
waitingForPreviousMatch,
|
||||
}: {
|
||||
stage?: TournamentMapListMap;
|
||||
infos?: (JSX.Element | null)[];
|
||||
children?: React.ReactNode;
|
||||
teams: [TournamentDataTeam, TournamentDataTeam];
|
||||
matchIsLocked: boolean;
|
||||
waitingForPreviousMatch: boolean;
|
||||
}) {
|
||||
const user = useUser();
|
||||
const data = useLoaderData<TournamentMatchLoaderData>();
|
||||
const { t } = useTranslation(["game-misc", "tournament"]);
|
||||
const tournament = useTournament();
|
||||
|
||||
const gamesCompleted = data.results.length;
|
||||
|
||||
const stageNameToBannerImageUrl = (stageId: StageId) => {
|
||||
return `${stageImageUrl(stageId)}.png`;
|
||||
};
|
||||
|
|
@ -367,6 +393,17 @@ function FancyStageBanner({
|
|||
</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 ? (
|
||||
<div className="tournament-bracket__locked-banner">
|
||||
<div className="stack sm items-center">
|
||||
|
|
@ -383,6 +420,15 @@ function FancyStageBanner({
|
|||
: waitingForActiveRosterSelectionFor}
|
||||
</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
|
||||
|
|
@ -417,9 +463,27 @@ function FancyStageBanner({
|
|||
})}
|
||||
</h4>
|
||||
</div>
|
||||
{data.match.startedAt && !data.matchIsOver ? (
|
||||
<DeadlineInfoPopover
|
||||
startedAt={databaseTimestampToDate(data.match.startedAt)}
|
||||
bestOf={data.match.bestOf}
|
||||
gamesCompleted={gamesCompleted}
|
||||
/>
|
||||
) : null}
|
||||
{children}
|
||||
</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 && (
|
||||
<div className="tournament-bracket__infos">
|
||||
{infos.filter(Boolean).map((info, i) => (
|
||||
|
|
@ -790,3 +854,85 @@ function ScreenBanIcons({ banned }: { banned: boolean }) {
|
|||
</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,
|
||||
groupNumberToLetters,
|
||||
} from "../tournament-bracket-utils";
|
||||
import { Bracket } from "./Bracket";
|
||||
import { type Bracket, createBracket } from "./Bracket";
|
||||
import { getTournamentManager } from "./brackets-manager";
|
||||
import { getRounds } from "./rounds";
|
||||
import * as Swiss from "./Swiss";
|
||||
|
|
@ -141,7 +141,7 @@ export class Tournament {
|
|||
);
|
||||
|
||||
this.brackets.push(
|
||||
Bracket.create({
|
||||
createBracket({
|
||||
id: inProgressStage.id,
|
||||
idx: bracketIdx,
|
||||
tournament: this,
|
||||
|
|
@ -182,7 +182,7 @@ export class Tournament {
|
|||
});
|
||||
|
||||
this.brackets.push(
|
||||
Bracket.create({
|
||||
createBracket({
|
||||
id: -1 * bracketIdx,
|
||||
idx: bracketIdx,
|
||||
tournament: this,
|
||||
|
|
@ -237,7 +237,7 @@ export class Tournament {
|
|||
);
|
||||
|
||||
this.brackets.push(
|
||||
Bracket.create({
|
||||
createBracket({
|
||||
id: -1 * bracketIdx,
|
||||
idx: bracketIdx,
|
||||
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) {
|
||||
if (bracket.preview || bracket.type !== "swiss") continue;
|
||||
|
||||
|
|
@ -1228,14 +1218,24 @@ export class Tournament {
|
|||
match.opponent1?.id === team.id || match.opponent2?.id === team.id,
|
||||
).length;
|
||||
const notAllRoundsGenerated =
|
||||
this.ctx.settings.swiss?.roundCount &&
|
||||
setsGeneratedCount !== this.ctx.settings.swiss?.roundCount;
|
||||
bracket.settings?.roundCount &&
|
||||
setsGeneratedCount !== bracket.settings.roundCount;
|
||||
|
||||
if (isParticipant && notAllRoundsGenerated) {
|
||||
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;
|
||||
|
||||
return { type: "THANKS_FOR_PLAYING" } as const;
|
||||
|
|
@ -1281,6 +1281,11 @@ export class Tournament {
|
|||
// BYE match
|
||||
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(
|
||||
(match) =>
|
||||
// in swiss matches are generated round by round and the existance
|
||||
|
|
@ -1340,10 +1345,6 @@ export class Tournament {
|
|||
return [];
|
||||
}
|
||||
|
||||
if (bracket.type === "round_robin") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return bracket.data.match
|
||||
.filter(
|
||||
// only interested in matches of the same bracket & not the match itself
|
||||
|
|
|
|||
|
|
@ -332,11 +332,10 @@ const match_getByRoundIdStm = sql.prepare(/*sql*/ `
|
|||
`);
|
||||
|
||||
const match_getByStageIdStm = sql.prepare(/*sql*/ `
|
||||
select
|
||||
"TournamentMatch".*,
|
||||
select
|
||||
"TournamentMatch".*,
|
||||
sum("TournamentMatchGameResult"."opponentOnePoints") as "opponentOnePointsTotal",
|
||||
sum("TournamentMatchGameResult"."opponentTwoPoints") as "opponentTwoPointsTotal",
|
||||
max("TournamentMatchGameResult"."createdAt") as "lastGameFinishedAt"
|
||||
sum("TournamentMatchGameResult"."opponentTwoPoints") as "opponentTwoPointsTotal"
|
||||
from "TournamentMatch"
|
||||
left join "TournamentMatchGameResult" on "TournamentMatch"."id" = "TournamentMatchGameResult"."matchId"
|
||||
where "TournamentMatch"."stageId" = @stageId
|
||||
|
|
@ -353,9 +352,9 @@ const match_getByRoundAndNumberStm = sql.prepare(/*sql*/ `
|
|||
const match_insertStm = sql.prepare(/*sql*/ `
|
||||
insert into
|
||||
"TournamentMatch"
|
||||
("roundId", "stageId", "groupId", "number", "opponentOne", "opponentTwo", "status", "chatCode")
|
||||
("roundId", "stageId", "groupId", "number", "opponentOne", "opponentTwo", "status", "chatCode", "startedAt")
|
||||
values
|
||||
(@roundId, @stageId, @groupId, @number, @opponentOne, @opponentTwo, @status, @chatCode)
|
||||
(@roundId, @stageId, @groupId, @number, @opponentOne, @opponentTwo, @status, @chatCode, @startedAt)
|
||||
returning *
|
||||
`);
|
||||
|
||||
|
|
@ -412,8 +411,7 @@ export class Match {
|
|||
opponentTwo: string;
|
||||
opponentOnePointsTotal: number | null;
|
||||
opponentTwoPointsTotal: number | null;
|
||||
lastGameFinishedAt: number | null;
|
||||
createdAt: number | null;
|
||||
startedAt: number | null;
|
||||
},
|
||||
): MatchType {
|
||||
return {
|
||||
|
|
@ -437,8 +435,7 @@ export class Match {
|
|||
round_id: rawMatch.roundId,
|
||||
stage_id: rawMatch.stageId,
|
||||
status: rawMatch.status,
|
||||
lastGameFinishedAt: rawMatch.lastGameFinishedAt,
|
||||
createdAt: rawMatch.createdAt,
|
||||
startedAt: rawMatch.startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -479,6 +476,7 @@ export class Match {
|
|||
opponentTwo: this.opponentTwo ?? "null",
|
||||
status: this.status,
|
||||
chatCode: shortNanoid(),
|
||||
startedAt: null,
|
||||
}) as any;
|
||||
|
||||
this.id = match.id;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ import invariant from "~/utils/invariant";
|
|||
import { roundToNDecimalPlaces } from "~/utils/number";
|
||||
import type { Tables, WinLossParticipationArray } from "../../../db/tables";
|
||||
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 { ParsedBracket } from "./Progression";
|
||||
|
||||
|
|
@ -69,9 +72,18 @@ export function tournamentSummary({
|
|||
calculateSeasonalStats?: boolean;
|
||||
progression: ParsedBracket[];
|
||||
}): 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
|
||||
? calculateSkills({
|
||||
results,
|
||||
results: resultsWithoutEarlyEndedSets,
|
||||
queryCurrentTeamRating,
|
||||
queryCurrentUserRating,
|
||||
queryTeamPlayerRatingAverage,
|
||||
|
|
@ -86,16 +98,18 @@ export function tournamentSummary({
|
|||
rating: queryCurrentSeedingRating(userId),
|
||||
matchesCount: 0, // Seeding skills do not have matches count
|
||||
}),
|
||||
results,
|
||||
results: resultsWithoutEarlyEndedSets,
|
||||
}).map((skill) => ({
|
||||
...skill,
|
||||
type: seedingSkillCountsFor,
|
||||
ordinal: ordinal(skill),
|
||||
}))
|
||||
: [],
|
||||
mapResultDeltas: calculateSeasonalStats ? mapResultDeltas(results) : [],
|
||||
mapResultDeltas: calculateSeasonalStats
|
||||
? mapResultDeltas(resultsWithoutEarlyEndedSets)
|
||||
: [],
|
||||
playerResultDeltas: calculateSeasonalStats
|
||||
? playerResultDeltas(results)
|
||||
? playerResultDeltas(resultsWithoutEarlyEndedSets)
|
||||
: [],
|
||||
tournamentResults: tournamentResults({
|
||||
participantCount: teams.length,
|
||||
|
|
|
|||
|
|
@ -180,6 +180,10 @@ describe("tournamentSummary()", () => {
|
|||
result: "loss",
|
||||
score: 0,
|
||||
},
|
||||
roundMaps: {
|
||||
count: 3,
|
||||
type: "BEST_OF",
|
||||
},
|
||||
},
|
||||
],
|
||||
teams,
|
||||
|
|
@ -293,6 +297,10 @@ describe("tournamentSummary()", () => {
|
|||
result: "loss",
|
||||
score: 0,
|
||||
},
|
||||
roundMaps: {
|
||||
count: 3,
|
||||
type: "BEST_OF",
|
||||
},
|
||||
},
|
||||
{
|
||||
maps: [
|
||||
|
|
@ -337,6 +345,10 @@ describe("tournamentSummary()", () => {
|
|||
result: "loss",
|
||||
score: 0,
|
||||
},
|
||||
roundMaps: {
|
||||
count: 3,
|
||||
type: "BEST_OF",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -427,6 +439,10 @@ describe("tournamentSummary()", () => {
|
|||
result: "loss",
|
||||
score: 1,
|
||||
},
|
||||
roundMaps: {
|
||||
count: 3,
|
||||
type: "BEST_OF",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -596,6 +612,10 @@ describe("tournamentSummary()", () => {
|
|||
result: "loss",
|
||||
score: 0,
|
||||
},
|
||||
roundMaps: {
|
||||
count: 3,
|
||||
type: "BEST_OF",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -650,6 +670,10 @@ describe("tournamentSummary()", () => {
|
|||
result: "loss",
|
||||
score: 0,
|
||||
},
|
||||
roundMaps: {
|
||||
count: 3,
|
||||
type: "BEST_OF",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -782,4 +806,139 @@ describe("tournamentSummary()", () => {
|
|||
expect(team3Results.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,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730942920,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31410,
|
||||
|
|
@ -456,8 +455,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10930,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730942228,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31411,
|
||||
|
|
@ -480,8 +478,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10931,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730943838,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31412,
|
||||
|
|
@ -504,8 +501,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10931,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730943744,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31413,
|
||||
|
|
@ -528,8 +524,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10932,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730944396,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31414,
|
||||
|
|
@ -552,8 +547,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10932,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730944433,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31415,
|
||||
|
|
@ -576,8 +570,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10933,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730942292,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31416,
|
||||
|
|
@ -600,8 +593,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10933,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730942228,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31417,
|
||||
|
|
@ -624,8 +616,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10934,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730943087,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31418,
|
||||
|
|
@ -648,8 +639,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10934,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730943362,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31419,
|
||||
|
|
@ -672,8 +662,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10935,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730944650,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31420,
|
||||
|
|
@ -696,8 +685,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10935,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730944060,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31421,
|
||||
|
|
@ -720,8 +708,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10936,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730942499,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31422,
|
||||
|
|
@ -744,8 +731,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10936,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730942637,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31423,
|
||||
|
|
@ -768,8 +754,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10937,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730943675,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31424,
|
||||
|
|
@ -792,8 +777,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10937,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730943519,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31425,
|
||||
|
|
@ -816,8 +800,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10938,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730944582,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31426,
|
||||
|
|
@ -840,8 +823,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10938,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730944327,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31427,
|
||||
|
|
@ -864,8 +846,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10939,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730942138,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31428,
|
||||
|
|
@ -888,8 +869,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10939,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730942201,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31429,
|
||||
|
|
@ -912,8 +892,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10940,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730943191,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31430,
|
||||
|
|
@ -936,8 +915,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10940,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730943041,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31431,
|
||||
|
|
@ -960,8 +938,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10941,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730944267,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31432,
|
||||
|
|
@ -984,8 +961,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10941,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730943864,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31433,
|
||||
|
|
@ -1008,8 +984,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10942,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730942106,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31434,
|
||||
|
|
@ -1032,8 +1007,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10942,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730942409,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31435,
|
||||
|
|
@ -1056,8 +1030,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10943,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730943667,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31436,
|
||||
|
|
@ -1080,8 +1053,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10943,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730943191,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31437,
|
||||
|
|
@ -1104,8 +1076,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10944,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730945077,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31438,
|
||||
|
|
@ -1128,8 +1099,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10944,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730945049,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31439,
|
||||
|
|
@ -1152,8 +1122,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10945,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730941825,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31440,
|
||||
|
|
@ -1176,8 +1145,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10945,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730942866,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31441,
|
||||
|
|
@ -1200,8 +1168,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10946,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730943771,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31442,
|
||||
|
|
@ -1224,8 +1191,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10946,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730943477,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31443,
|
||||
|
|
@ -1248,8 +1214,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10947,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730944650,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31444,
|
||||
|
|
@ -1272,8 +1237,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10947,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730944986,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31445,
|
||||
|
|
@ -1296,8 +1260,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10948,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730942080,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31446,
|
||||
|
|
@ -1320,8 +1283,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10948,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730942458,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31447,
|
||||
|
|
@ -1344,8 +1306,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10949,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730943825,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31448,
|
||||
|
|
@ -1368,8 +1329,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10949,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730943451,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31449,
|
||||
|
|
@ -1392,8 +1352,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10950,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730944753,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31450,
|
||||
|
|
@ -1416,8 +1375,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10950,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730944776,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31451,
|
||||
|
|
@ -1440,8 +1398,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10951,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730942138,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31452,
|
||||
|
|
@ -1464,8 +1421,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10951,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730942432,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31453,
|
||||
|
|
@ -1488,8 +1444,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10952,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730943047,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31454,
|
||||
|
|
@ -1512,8 +1467,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10952,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730943314,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31455,
|
||||
|
|
@ -1536,8 +1490,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10953,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730944305,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31456,
|
||||
|
|
@ -1560,8 +1513,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10953,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730944405,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31457,
|
||||
|
|
@ -1584,8 +1536,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10954,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730942135,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31458,
|
||||
|
|
@ -1608,8 +1559,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10954,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730942303,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31459,
|
||||
|
|
@ -1632,8 +1582,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10955,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730943438,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31460,
|
||||
|
|
@ -1656,8 +1605,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10955,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730943267,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31461,
|
||||
|
|
@ -1680,8 +1628,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10956,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730944307,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31462,
|
||||
|
|
@ -1704,8 +1651,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10956,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730944545,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31463,
|
||||
|
|
@ -1728,8 +1674,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10957,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730942186,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31464,
|
||||
|
|
@ -1752,8 +1697,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10957,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730942783,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31465,
|
||||
|
|
@ -1776,8 +1720,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10958,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730943564,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31466,
|
||||
|
|
@ -1800,8 +1743,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10958,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730944295,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31467,
|
||||
|
|
@ -1824,8 +1766,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10959,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730945002,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31468,
|
||||
|
|
@ -1848,8 +1789,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10959,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730945693,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31469,
|
||||
|
|
@ -1872,8 +1812,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10960,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730942355,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31470,
|
||||
|
|
@ -1896,8 +1835,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10960,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730942198,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31471,
|
||||
|
|
@ -1920,8 +1858,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10961,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730943233,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31472,
|
||||
|
|
@ -1944,8 +1881,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10961,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730943206,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31473,
|
||||
|
|
@ -1968,8 +1904,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10962,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730944334,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
{
|
||||
id: 31474,
|
||||
|
|
@ -1992,8 +1927,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
round_id: 10962,
|
||||
stage_id: 1118,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1730944217,
|
||||
createdAt: null,
|
||||
startedAt: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -2063,7 +1997,6 @@ export const SWIM_OR_SINK_167 = (
|
|||
teamsPerGroup: 4,
|
||||
thirdPlaceMatch: true,
|
||||
isRanked: true,
|
||||
deadlines: "DEFAULT",
|
||||
isInvitational: false,
|
||||
enableNoScreenToggle: true,
|
||||
autonomousSubs: false,
|
||||
|
|
|
|||
|
|
@ -90,8 +90,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
|||
round_id: 13715,
|
||||
stage_id: 1457,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1734687487,
|
||||
createdAt: 1734685232,
|
||||
startedAt: 1734685232,
|
||||
},
|
||||
{
|
||||
id: 38585,
|
||||
|
|
@ -110,8 +109,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
|||
round_id: 13715,
|
||||
stage_id: 1457,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1734686471,
|
||||
createdAt: 1734685232,
|
||||
startedAt: 1734685232,
|
||||
},
|
||||
{
|
||||
id: 38586,
|
||||
|
|
@ -130,8 +128,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
|||
round_id: 13715,
|
||||
stage_id: 1457,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1734686359,
|
||||
createdAt: 1734685232,
|
||||
startedAt: 1734685232,
|
||||
},
|
||||
{
|
||||
id: 38587,
|
||||
|
|
@ -144,8 +141,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
|||
round_id: 13715,
|
||||
stage_id: 1457,
|
||||
status: 2,
|
||||
lastGameFinishedAt: null,
|
||||
createdAt: 1734685232,
|
||||
startedAt: 1734685232,
|
||||
},
|
||||
{
|
||||
id: 38588,
|
||||
|
|
@ -164,8 +160,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
|||
round_id: 13716,
|
||||
stage_id: 1457,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1734688988,
|
||||
createdAt: 1734687519,
|
||||
startedAt: 1734687519,
|
||||
},
|
||||
{
|
||||
id: 38589,
|
||||
|
|
@ -184,8 +179,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
|||
round_id: 13716,
|
||||
stage_id: 1457,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1734689658,
|
||||
createdAt: 1734687519,
|
||||
startedAt: 1734687519,
|
||||
},
|
||||
{
|
||||
id: 38590,
|
||||
|
|
@ -204,8 +198,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
|||
round_id: 13716,
|
||||
stage_id: 1457,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1734688872,
|
||||
createdAt: 1734687519,
|
||||
startedAt: 1734687519,
|
||||
},
|
||||
{
|
||||
id: 38591,
|
||||
|
|
@ -218,8 +211,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
|||
round_id: 13716,
|
||||
stage_id: 1457,
|
||||
status: 2,
|
||||
lastGameFinishedAt: null,
|
||||
createdAt: 1734687519,
|
||||
startedAt: 1734687519,
|
||||
},
|
||||
{
|
||||
id: 38592,
|
||||
|
|
@ -238,8 +230,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
|||
round_id: 13717,
|
||||
stage_id: 1457,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1734691279,
|
||||
createdAt: 1734689680,
|
||||
startedAt: 1734689680,
|
||||
},
|
||||
{
|
||||
id: 38593,
|
||||
|
|
@ -258,8 +249,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
|||
round_id: 13717,
|
||||
stage_id: 1457,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1734690877,
|
||||
createdAt: 1734689680,
|
||||
startedAt: 1734689680,
|
||||
},
|
||||
{
|
||||
id: 38594,
|
||||
|
|
@ -278,8 +268,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
|||
round_id: 13717,
|
||||
stage_id: 1457,
|
||||
status: 4,
|
||||
lastGameFinishedAt: 1734690966,
|
||||
createdAt: 1734689680,
|
||||
startedAt: 1734689680,
|
||||
},
|
||||
{
|
||||
id: 38595,
|
||||
|
|
@ -292,8 +281,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
|||
round_id: 13717,
|
||||
stage_id: 1457,
|
||||
status: 2,
|
||||
lastGameFinishedAt: null,
|
||||
createdAt: 1734689680,
|
||||
startedAt: 1734689680,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -315,7 +303,6 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
|||
],
|
||||
thirdPlaceMatch: false,
|
||||
isRanked: false,
|
||||
deadlines: "DEFAULT",
|
||||
isInvitational: false,
|
||||
enableNoScreenToggle: 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 { findResultsByMatchId } from "../queries/findResultsByMatchId.server";
|
||||
import { matchPageParamsSchema } from "../tournament-bracket-schemas.server";
|
||||
import { matchEndedEarly } from "../tournament-bracket-utils";
|
||||
|
||||
export type TournamentMatchLoaderData = typeof loader;
|
||||
|
||||
|
|
@ -69,13 +70,30 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
|||
})
|
||||
: 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 {
|
||||
match,
|
||||
results: findResultsByMatchId(matchId),
|
||||
mapList,
|
||||
matchIsOver:
|
||||
match.opponentOne?.result === "win" ||
|
||||
match.opponentTwo?.result === "win",
|
||||
matchIsOver,
|
||||
endedEarly,
|
||||
noScreen,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { sql } from "~/db/sql";
|
||||
import type { TournamentRoundMaps } from "~/db/tables";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { parseDBArray, parseDBJsonArray } from "~/utils/sql";
|
||||
|
|
@ -24,6 +25,7 @@ const stm = sql.prepare(/* sql */ `
|
|||
"m"."opponentTwo" ->> '$.score' as "opponentTwoScore",
|
||||
"m"."opponentOne" ->> '$.result' as "opponentOneResult",
|
||||
"m"."opponentTwo" ->> '$.result' as "opponentTwoResult",
|
||||
"TournamentRound"."maps" as "roundMaps",
|
||||
json_group_array(
|
||||
json_object(
|
||||
'stageId',
|
||||
|
|
@ -34,10 +36,11 @@ const stm = sql.prepare(/* sql */ `
|
|||
"q1"."winnerTeamId",
|
||||
'participants',
|
||||
"q1"."participants"
|
||||
)
|
||||
)
|
||||
) as "maps"
|
||||
from
|
||||
"TournamentMatch" as "m"
|
||||
inner join "TournamentRound" on "TournamentRound"."id" = "m"."roundId"
|
||||
left join "TournamentStage" on "TournamentStage"."id" = "m"."stageId"
|
||||
left join "q1" on "q1"."matchId" = "m"."id"
|
||||
where "TournamentStage"."tournamentId" = @tournamentId
|
||||
|
|
@ -56,6 +59,7 @@ interface Opponent {
|
|||
export interface AllMatchResult {
|
||||
opponentOne: Opponent;
|
||||
opponentTwo: Opponent;
|
||||
roundMaps: TournamentRoundMaps;
|
||||
maps: Array<{
|
||||
stageId: StageId;
|
||||
mode: ModeShort;
|
||||
|
|
@ -74,17 +78,23 @@ export function allMatchResultsByTournamentId(
|
|||
const rows = stm.all({ tournamentId }) as unknown as any[];
|
||||
|
||||
return rows.map((row) => {
|
||||
const roundMaps = JSON.parse(row.roundMaps) as TournamentRoundMaps;
|
||||
|
||||
const opponentOne = {
|
||||
id: row.opponentOneId,
|
||||
score: row.opponentOneScore,
|
||||
result: row.opponentOneResult,
|
||||
};
|
||||
const opponentTwo = {
|
||||
id: row.opponentTwoId,
|
||||
score: row.opponentTwoScore,
|
||||
result: row.opponentTwoResult,
|
||||
};
|
||||
|
||||
return {
|
||||
opponentOne: {
|
||||
id: row.opponentOneId,
|
||||
score: row.opponentOneScore,
|
||||
result: row.opponentOneResult,
|
||||
},
|
||||
opponentTwo: {
|
||||
id: row.opponentTwoId,
|
||||
score: row.opponentTwoScore,
|
||||
result: row.opponentTwoResult,
|
||||
},
|
||||
opponentOne,
|
||||
opponentTwo,
|
||||
roundMaps,
|
||||
maps: parseDBJsonArray(row.maps).map((map: any) => {
|
||||
const participants = parseDBArray(map.participants);
|
||||
invariant(participants.length > 0, "No participants found");
|
||||
|
|
|
|||
|
|
@ -5,6 +5,6 @@ const stm = sql.prepare(/* sql */ `
|
|||
where "matchId" = @matchId
|
||||
`);
|
||||
|
||||
export function deleteMatchPickBanEvents({ matchId }: { matchId: number }) {
|
||||
export function deleteMatchPickBanEvents(matchId: number) {
|
||||
return stm.run({ matchId });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@ import type { Match } from "~/modules/brackets-model";
|
|||
import { parseDBArray } from "~/utils/sql";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
select
|
||||
select
|
||||
"TournamentMatch"."id",
|
||||
"TournamentMatch"."groupId",
|
||||
"TournamentMatch"."opponentOne",
|
||||
"TournamentMatch"."opponentTwo",
|
||||
"TournamentMatch"."chatCode",
|
||||
"TournamentMatch"."startedAt",
|
||||
"TournamentMatch"."status",
|
||||
"Tournament"."mapPickingStyle",
|
||||
"TournamentRound"."id" as "roundId",
|
||||
"TournamentRound"."maps" as "roundMaps",
|
||||
|
|
@ -49,7 +51,10 @@ export type FindMatchById = ReturnType<typeof findMatchById>;
|
|||
|
||||
export const findMatchById = (id: number) => {
|
||||
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 }) & {
|
||||
opponentOne: string;
|
||||
opponentTwo: string;
|
||||
|
|
@ -69,6 +74,7 @@ export const findMatchById = (id: number) => {
|
|||
roundMaps,
|
||||
opponentOne: JSON.parse(row.opponentOne) as Match["opponent1"],
|
||||
opponentTwo: JSON.parse(row.opponentTwo) as Match["opponent2"],
|
||||
status: row.status,
|
||||
players: (
|
||||
parseDBArray(row.players) as Array<{
|
||||
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 * as React from "react";
|
||||
import { LinkButton } from "~/components/elements/Button";
|
||||
import { ArrowLongLeftIcon } from "~/components/icons/ArrowLongLeft";
|
||||
import { containerClassName } from "~/components/Main";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useWebsocketRevalidation } from "~/features/chat/chat-hooks";
|
||||
import { ConnectedChat } from "~/features/chat/components/Chat";
|
||||
|
|
@ -113,7 +114,10 @@ export default function TournamentMatchPage() {
|
|||
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 &&
|
||||
typeof data.match.opponentOne?.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({
|
||||
_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);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
fillWithNullTillPowerOfTwo,
|
||||
groupNumberToLetters,
|
||||
mapCountPlayedInSetWithCertainty,
|
||||
matchEndedEarly,
|
||||
resolveRoomPass,
|
||||
validateBadgeReceivers,
|
||||
} from "./tournament-bracket-utils";
|
||||
|
|
@ -149,4 +150,94 @@ describe("groupNumberToLetters()", () => {
|
|||
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;
|
||||
}
|
||||
|
||||
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(
|
||||
team: TournamentLoaderData["tournament"]["ctx"]["teams"][number],
|
||||
teamMinMemberCount: number,
|
||||
|
|
|
|||
|
|
@ -54,6 +54,10 @@
|
|||
padding-inline: var(--s-2);
|
||||
}
|
||||
|
||||
.tournament-bracket__locked-banner__lonely {
|
||||
border-radius: var(--rounded);
|
||||
}
|
||||
|
||||
.tournament-bracket__stage-banner {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
|
@ -100,6 +104,55 @@
|
|||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -987,6 +987,19 @@ export function unlockMatch({
|
|||
})
|
||||
.where("id", "=", tournamentId)
|
||||
.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,
|
||||
stageId: match.stageId,
|
||||
status: Status.Ready,
|
||||
createdAt: dateToDatabaseTimestamp(new Date()),
|
||||
chatCode: shortNanoid(),
|
||||
})),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { sql } from "~/db/sql";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { databaseTimestampNow } from "~/utils/dates";
|
||||
import { shortNanoid } from "~/utils/id";
|
||||
import invariant from "~/utils/invariant";
|
||||
|
||||
|
|
@ -54,8 +54,7 @@ const createTournamentMatchStm = sql.prepare(/* sql */ `
|
|||
"opponentTwo",
|
||||
"roundId",
|
||||
"stageId",
|
||||
"status",
|
||||
"createdAt"
|
||||
"status"
|
||||
) values (
|
||||
@chatCode,
|
||||
@groupId,
|
||||
|
|
@ -64,8 +63,7 @@ const createTournamentMatchStm = sql.prepare(/* sql */ `
|
|||
@opponentTwo,
|
||||
@roundId,
|
||||
@stageId,
|
||||
@status,
|
||||
@createdAt
|
||||
@status
|
||||
)
|
||||
`);
|
||||
|
||||
|
|
@ -79,7 +77,7 @@ export function createSwissBracketInTransaction(
|
|||
const stageFromDB = createTournamentStageStm.get({
|
||||
tournamentId: stageInput.tournament_id,
|
||||
type: stageInput.type,
|
||||
createdAt: dateToDatabaseTimestamp(new Date()),
|
||||
createdAt: databaseTimestampNow(),
|
||||
settings: JSON.stringify(stageInput.settings),
|
||||
name: stageInput.name,
|
||||
}) as Tables["TournamentStage"];
|
||||
|
|
@ -119,7 +117,6 @@ export function createSwissBracketInTransaction(
|
|||
roundId: roundFromDB.id,
|
||||
stageId: stageFromDB.id,
|
||||
status: match.status,
|
||||
createdAt: dateToDatabaseTimestamp(new Date()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ export async function dbInsertTournament() {
|
|||
bracketUrl: "https://example.com/bracket",
|
||||
description: null,
|
||||
discordInviteCode: "test-discord",
|
||||
deadlines: "DEFAULT",
|
||||
name: "Test Tournament",
|
||||
organizationId: null,
|
||||
rules: null,
|
||||
|
|
|
|||
|
|
@ -63,8 +63,11 @@ export class BaseUpdater extends BaseGetter {
|
|||
// Don't update related matches if it's a simple score update.
|
||||
if (!statusChanged && !resultChanged) return;
|
||||
|
||||
if (!helpers.isRoundRobin(stage) && !helpers.isSwiss(stage))
|
||||
if (!helpers.isRoundRobin(stage) && !helpers.isSwiss(stage)) {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
Group,
|
||||
InputStage,
|
||||
Match,
|
||||
Round,
|
||||
Seeding,
|
||||
SeedOrdering,
|
||||
Stage,
|
||||
import {
|
||||
type Group,
|
||||
type InputStage,
|
||||
type Match,
|
||||
type Round,
|
||||
type Seeding,
|
||||
type SeedOrdering,
|
||||
type Stage,
|
||||
Status,
|
||||
} from "~/modules/brackets-model";
|
||||
import type { BracketsManager } from ".";
|
||||
import * as helpers from "./helpers";
|
||||
|
|
@ -33,9 +34,7 @@ export class Create {
|
|||
private storage: Storage;
|
||||
private stage: InputStage;
|
||||
private readonly seedOrdering: SeedOrdering[];
|
||||
private updateMode: boolean;
|
||||
private enableByesInUpdate: boolean;
|
||||
private currentStageId!: number;
|
||||
|
||||
/**
|
||||
* 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.settings = this.stage.settings || {};
|
||||
this.seedOrdering = this.stage.settings.seedOrdering || [];
|
||||
this.updateMode = false;
|
||||
this.enableByesInUpdate = false;
|
||||
|
||||
if (!this.stage.name) throw Error("You must provide a name for the stage.");
|
||||
|
|
@ -96,18 +94,6 @@ export class Create {
|
|||
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.
|
||||
*
|
||||
|
|
@ -396,8 +382,9 @@ export class Create {
|
|||
|
||||
if (roundId === -1) throw Error("Could not insert the round.");
|
||||
|
||||
for (let i = 0; i < matchCount; i++)
|
||||
this.createMatch(stageId, groupId, roundId, i + 1, duels[i]);
|
||||
for (let i = 0; i < matchCount; i++) {
|
||||
this.createMatch(stageId, groupId, roundId, i + 1, roundNumber, duels[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -414,6 +401,7 @@ export class Create {
|
|||
groupId: number,
|
||||
roundId: number,
|
||||
matchNumber: number,
|
||||
roundNumber: number,
|
||||
opponents: Duel,
|
||||
): void {
|
||||
const opponent1 = helpers.toResultWithPosition(opponents[0]);
|
||||
|
|
@ -427,20 +415,12 @@ export class Create {
|
|||
)
|
||||
return;
|
||||
|
||||
let existing: Match | null = null;
|
||||
let status = helpers.getMatchStatus(opponents);
|
||||
|
||||
if (this.updateMode) {
|
||||
existing = this.storage.selectFirst("match", {
|
||||
round_id: roundId,
|
||||
number: matchNumber,
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// Keep the most advanced status when updating a match.
|
||||
const existingStatus = helpers.getMatchStatus(existing);
|
||||
if (existingStatus > status) status = existingStatus;
|
||||
}
|
||||
// In round-robin, only the first round is ready to play at the beginning.
|
||||
// other matches have teams set but they are busy playing the first round.
|
||||
if (this.stage.type === "round_robin" && roundNumber > 1) {
|
||||
status = Status.Locked;
|
||||
}
|
||||
|
||||
const parentId = this.insertMatch(
|
||||
|
|
@ -449,11 +429,11 @@ export class Create {
|
|||
stage_id: stageId,
|
||||
group_id: groupId,
|
||||
round_id: roundId,
|
||||
status: status,
|
||||
status,
|
||||
opponent1,
|
||||
opponent2,
|
||||
},
|
||||
existing,
|
||||
null,
|
||||
);
|
||||
|
||||
if (parentId === -1) throw Error("Could not insert the match.");
|
||||
|
|
@ -755,14 +735,7 @@ export class Create {
|
|||
* @param stage The stage to insert.
|
||||
*/
|
||||
private insertStage(stage: OmitId<Stage>): number {
|
||||
let existing: Stage | null = null;
|
||||
|
||||
if (this.updateMode)
|
||||
existing = this.storage.select("stage", this.currentStageId);
|
||||
|
||||
if (!existing) return this.storage.insert("stage", stage);
|
||||
|
||||
return existing.id;
|
||||
return this.storage.insert("stage", stage);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -771,18 +744,7 @@ export class Create {
|
|||
* @param group The group to insert.
|
||||
*/
|
||||
private insertGroup(group: OmitId<Group>): number {
|
||||
let existing: Group | null = null;
|
||||
|
||||
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;
|
||||
return this.storage.insert("group", group);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -791,18 +753,7 @@ export class Create {
|
|||
* @param round The round to insert.
|
||||
*/
|
||||
private insertRound(round: OmitId<Round>): number {
|
||||
let existing: Round | null = null;
|
||||
|
||||
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;
|
||||
return this.storage.insert("round", round);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
number: number;
|
||||
|
||||
lastGameFinishedAt?: number | null;
|
||||
|
||||
createdAt?: number | null;
|
||||
startedAt?: number | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ async function createTestTournament({
|
|||
bracketUrl: "https://example.com/bracket",
|
||||
description: null,
|
||||
discordInviteCode,
|
||||
deadlines: "DEFAULT",
|
||||
name,
|
||||
organizationId: null,
|
||||
rules: null,
|
||||
|
|
|
|||
|
|
@ -310,6 +310,10 @@
|
|||
margin-inline-start: var(--s-4);
|
||||
}
|
||||
|
||||
.ml-6 {
|
||||
margin-inline-start: var(--s-6);
|
||||
}
|
||||
|
||||
.mr-auto {
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
|
|
@ -404,6 +408,10 @@
|
|||
font-style: italic;
|
||||
}
|
||||
|
||||
.contents {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.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).
|
||||
|
||||
### 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
|
||||
|
||||
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("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
|
||||
// and then we go back and change the winner of A
|
||||
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.action.undoLastScore": "Annuller sidste score",
|
||||
"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.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",
|
||||
|
|
|
|||
|
|
@ -109,6 +109,11 @@
|
|||
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Play all {{bestOf}})",
|
||||
"match.action.undoLastScore": "Letztes Ergebnis widerrufen",
|
||||
"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.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.",
|
||||
|
|
|
|||
|
|
@ -109,6 +109,11 @@
|
|||
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Play all {{bestOf}})",
|
||||
"match.action.undoLastScore": "Undo last score",
|
||||
"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.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.",
|
||||
|
|
|
|||
|
|
@ -111,6 +111,11 @@
|
|||
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Jugar todos los {{bestOf}})",
|
||||
"match.action.undoLastScore": "Anular resultado previo",
|
||||
"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.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.",
|
||||
|
|
|
|||
|
|
@ -111,6 +111,11 @@
|
|||
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Jugar todos los {{bestOf}})",
|
||||
"match.action.undoLastScore": "Anular resultado previo",
|
||||
"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.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.",
|
||||
|
|
|
|||
|
|
@ -111,6 +111,11 @@
|
|||
"match.score.playAll": "",
|
||||
"match.action.undoLastScore": "Annuler le dernier score",
|
||||
"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.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.",
|
||||
|
|
|
|||
|
|
@ -111,6 +111,11 @@
|
|||
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Tous jouer {{bestOf}})",
|
||||
"match.action.undoLastScore": "Annuler le dernier score",
|
||||
"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.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.",
|
||||
|
|
|
|||
|
|
@ -111,6 +111,11 @@
|
|||
"match.score.playAll": "",
|
||||
"match.action.undoLastScore": "בטלו את התוצאה האחרונה",
|
||||
"match.action.reopenMatch": "פתיחה מחדש של הקרב",
|
||||
"match.action.endSet": "",
|
||||
"match.action.confirmEndSet": "",
|
||||
"match.endSet.selectWinner": "",
|
||||
"match.endSet.randomWinner": "",
|
||||
"match.deadline.explanation": "",
|
||||
"join.error.MISSING_CODE": "חסר קוד הזמנה. האם כתובת האתר המלאה הועתקה?",
|
||||
"join.error.SHORT_CODE": "קוד ההזמנה אינו באורך המתאים. האם כתובת האתר המלאה הועתקה?",
|
||||
"join.error.NO_TEAM_MATCHING_CODE": "אין צוות שתואם את קוד ההזמנה.",
|
||||
|
|
|
|||
|
|
@ -111,6 +111,11 @@
|
|||
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Play all {{bestOf}})",
|
||||
"match.action.undoLastScore": "Annulla ultimo punteggio",
|
||||
"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.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.",
|
||||
|
|
|
|||
|
|
@ -105,6 +105,11 @@
|
|||
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (全てプレイする {{bestOf}})",
|
||||
"match.action.undoLastScore": "最後のスコアをやりなおす",
|
||||
"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.SHORT_CODE": "招待コードの長さが正しくありません。すべての URL をコピーしましたか?",
|
||||
"join.error.NO_TEAM_MATCHING_CODE": "この招待コードにあうチームがみつかりません。",
|
||||
|
|
|
|||
|
|
@ -105,6 +105,11 @@
|
|||
"match.score.playAll": "",
|
||||
"match.action.undoLastScore": "",
|
||||
"match.action.reopenMatch": "",
|
||||
"match.action.endSet": "",
|
||||
"match.action.confirmEndSet": "",
|
||||
"match.endSet.selectWinner": "",
|
||||
"match.endSet.randomWinner": "",
|
||||
"match.deadline.explanation": "",
|
||||
"join.error.MISSING_CODE": "",
|
||||
"join.error.SHORT_CODE": "",
|
||||
"join.error.NO_TEAM_MATCHING_CODE": "",
|
||||
|
|
|
|||
|
|
@ -109,6 +109,11 @@
|
|||
"match.score.playAll": "",
|
||||
"match.action.undoLastScore": "",
|
||||
"match.action.reopenMatch": "",
|
||||
"match.action.endSet": "",
|
||||
"match.action.confirmEndSet": "",
|
||||
"match.endSet.selectWinner": "",
|
||||
"match.endSet.randomWinner": "",
|
||||
"match.deadline.explanation": "",
|
||||
"join.error.MISSING_CODE": "",
|
||||
"join.error.SHORT_CODE": "",
|
||||
"join.error.NO_TEAM_MATCHING_CODE": "",
|
||||
|
|
|
|||
|
|
@ -113,6 +113,11 @@
|
|||
"match.score.playAll": "",
|
||||
"match.action.undoLastScore": "",
|
||||
"match.action.reopenMatch": "",
|
||||
"match.action.endSet": "",
|
||||
"match.action.confirmEndSet": "",
|
||||
"match.endSet.selectWinner": "",
|
||||
"match.endSet.randomWinner": "",
|
||||
"match.deadline.explanation": "",
|
||||
"join.error.MISSING_CODE": "",
|
||||
"join.error.SHORT_CODE": "",
|
||||
"join.error.NO_TEAM_MATCHING_CODE": "",
|
||||
|
|
|
|||
|
|
@ -111,6 +111,11 @@
|
|||
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Jogar todas {{bestOf}})",
|
||||
"match.action.undoLastScore": "Desfazer última pontuação",
|
||||
"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.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.",
|
||||
|
|
|
|||
|
|
@ -113,6 +113,11 @@
|
|||
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Играть все {{bestOf}})",
|
||||
"match.action.undoLastScore": "Отменить последний результат",
|
||||
"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.SHORT_CODE": "Длина кода приглашения неверна. Был ли URL скопирован полностью?",
|
||||
"join.error.NO_TEAM_MATCHING_CODE": "Нет команды, соответствующей коду приглашения.",
|
||||
|
|
|
|||
|
|
@ -105,6 +105,11 @@
|
|||
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (全部 {{bestOf}} 场)",
|
||||
"match.action.undoLastScore": "撤销上次比分",
|
||||
"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.SHORT_CODE": "邀请码长度不符。您是否复制了完整URL?",
|
||||
"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,
|
||||
bracketProgression: tournament.ctx.settings.bracketProgression,
|
||||
description: tournament.ctx.description,
|
||||
deadlines: tournament.ctx.settings.deadlines,
|
||||
discordInviteCode:
|
||||
tournament.ctx.discordUrl?.replace("https://discord.gg/", "") ?? null,
|
||||
mapPickingStyle: tournament.ctx.mapPickingStyle,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user