Deadline per tournament match (#2657)

This commit is contained in:
Kalle 2025-12-10 19:42:30 +02:00 committed by GitHub
parent 9f3a22b618
commit 42cb33641d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
74 changed files with 3897 additions and 3095 deletions

View File

@ -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. */

View File

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

View File

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

View File

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

View File

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

View File

@ -65,7 +65,6 @@ const createCalendarEvent = async (authorId: number, avatarImgId?: number) => {
tags: null,
mapPickingStyle: "AUTO_SZ",
bracketProgression: null,
deadlines: "DEFAULT",
rules: null,
avatarImgId,
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View 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");
}
}

View File

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

View File

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

View File

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

View 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 [];
}
}

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

View 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");
});
});

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -32,7 +32,6 @@ async function createTestTournament({
bracketUrl: "https://example.com/bracket",
description: null,
discordInviteCode,
deadlines: "DEFAULT",
name,
organizationId: null,
rules: null,

View File

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

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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": "אין צוות שתואם את קוד ההזמנה.",

View File

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

View File

@ -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": "この招待コードにあうチームがみつかりません。",

View File

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

View File

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

View File

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

View File

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

View File

@ -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": "Нет команды, соответствующей коду приглашения.",

View File

@ -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": "没有队伍与邀请码相匹配。",

View 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();
})();
}

View File

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