diff --git a/app/db/tables.ts b/app/db/tables.ts index fea653137..0b7c2896f 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -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; + // 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. */ diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index dff57b081..7ed059020 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -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, diff --git a/app/features/calendar/actions/calendar.new.server.ts b/app/features/calendar/actions/calendar.new.server.ts index f2be3cac2..56f551808 100644 --- a/app/features/calendar/actions/calendar.new.server.ts +++ b/app/features/calendar/actions/calendar.new.server.ts @@ -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, diff --git a/app/features/calendar/calendar-schemas.server.ts b/app/features/calendar/calendar-schemas.server.ts index a62448a10..f6e71f0d2 100644 --- a/app/features/calendar/calendar-schemas.server.ts +++ b/app/features/calendar/calendar-schemas.server.ts @@ -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, diff --git a/app/features/calendar/routes/calendar.new.tsx b/app/features/calendar/routes/calendar.new.tsx index 30c3cbefa..f1685bea9 100644 --- a/app/features/calendar/routes/calendar.new.tsx +++ b/app/features/calendar/routes/calendar.new.tsx @@ -258,7 +258,6 @@ function EventForm() { isInvitational={isInvitational} setIsInvitational={setIsInvitational} /> - {!eventToEdit ? : 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 ( -
- - - - Strict deadlines has 5 minutes less for the target time of each round - (25min Bo3, 35min Bo5 compared to 30min Bo3, 40min Bo5 normal). - -
- ); -} - function TestToggle() { const baseEvent = useBaseEvent(); const [isTest, setIsTest] = React.useState( diff --git a/app/features/img-upload/ImageRepository.server.test.ts b/app/features/img-upload/ImageRepository.server.test.ts index 0a92b3365..a98a7fefd 100644 --- a/app/features/img-upload/ImageRepository.server.test.ts +++ b/app/features/img-upload/ImageRepository.server.test.ts @@ -65,7 +65,6 @@ const createCalendarEvent = async (authorId: number, avatarImgId?: number) => { tags: null, mapPickingStyle: "AUTO_SZ", bracketProgression: null, - deadlines: "DEFAULT", rules: null, avatarImgId, }); diff --git a/app/features/tournament-bracket/actions/to.$id.matches.$mid.server.ts b/app/features/tournament-bracket/actions/to.$id.matches.$mid.server.ts index 5bbfdc671..3e0799de3 100644 --- a/app/features/tournament-bracket/actions/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-bracket/actions/to.$id.matches.$mid.server.ts @@ -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); } diff --git a/app/features/tournament-bracket/components/Bracket/Match.tsx b/app/features/tournament-bracket/components/Bracket/Match.tsx index dc449729a..ca8208c00 100644 --- a/app/features/tournament-bracket/components/Bracket/Match.tsx +++ b/app/features/tournament-bracket/components/Bracket/Match.tsx @@ -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; @@ -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) {
+ {!props.hideMatchTimer ? ( + + ) : null}
); } @@ -96,7 +104,7 @@ function MatchHeader({ match, type, roundNumber, group }: MatchProps) { > Match is scheduled to be casted - ) : hasStreams() ? ( + ) : hasStreams() && match.startedAt ? ( { 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) { ); } + +function MatchTimer({ match, bracket }: Pick) { + 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 ( +
+
+ {displayText} +
+
+ ); +} diff --git a/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx b/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx index b0939da5f..26f6d112e 100644 --- a/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx +++ b/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx @@ -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>; }) { 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} - {hasDeadline ? : null} + {roundStartedAt && matches && matches.length > 0 ? ( + + ) : null} ) : 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>; +}) { + const [now, setNow] = React.useState(new Date()); - if (!deadline) return null; + React.useEffect(() => { + const interval = setInterval(() => { + setNow(new Date()); + }, 60000); - return ( -
- DL {formatTime(deadline)} -
+ 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
{displayText}
; } function useLeagueWeekStart(roundId: number) { diff --git a/app/features/tournament-bracket/components/Bracket/RoundRobin.tsx b/app/features/tournament-bracket/components/Bracket/RoundRobin.tsx index 1b5ea7c0b..6fa75e4bd 100644 --- a/app/features/tournament-bracket/components/Bracket/RoundRobin.tsx +++ b/app/features/tournament-bracket/components/Bracket/RoundRobin.tsx @@ -28,7 +28,7 @@ export function RoundRobinBracket({ bracket }: { bracket: BracketType }) { }); return ( -
+

{groupName}

+ 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 ? ( @@ -223,6 +238,7 @@ export function SwissBracket({ bracket={bracket} type="groups" group={selectedGroup.groupName.split(" ")[1]} + hideMatchTimer /> ); })} diff --git a/app/features/tournament-bracket/components/Bracket/bracket.css b/app/features/tournament-bracket/components/Bracket/bracket.css index 3da7c3d88..fecbace0c 100644 --- a/app/features/tournament-bracket/components/Bracket/bracket.css +++ b/app/features/tournament-bracket/components/Bracket/bracket.css @@ -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 { diff --git a/app/features/tournament-bracket/components/Bracket/useDeadline.ts b/app/features/tournament-bracket/components/Bracket/useDeadline.ts deleted file mode 100644 index 1540a527f..000000000 --- a/app/features/tournament-bracket/components/Bracket/useDeadline.ts +++ /dev/null @@ -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), - ); -} diff --git a/app/features/tournament-bracket/components/DeadlineInfoPopover.tsx b/app/features/tournament-bracket/components/DeadlineInfoPopover.tsx new file mode 100644 index 000000000..b0c3d0187 --- /dev/null +++ b/app/features/tournament-bracket/components/DeadlineInfoPopover.tsx @@ -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" ? ( + + ! + + ) : status === "error" ? ( + + ! + + ) : null; + + return ( +
+ + {t("tournament:match.deadline.explanation")} + + {warningIndicator} +
+ ); +} diff --git a/app/features/tournament-bracket/components/MatchTimer.module.css b/app/features/tournament-bracket/components/MatchTimer.module.css new file mode 100644 index 000000000..20295b6d4 --- /dev/null +++ b/app/features/tournament-bracket/components/MatchTimer.module.css @@ -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; +} diff --git a/app/features/tournament-bracket/components/MatchTimer.tsx b/app/features/tournament-bracket/components/MatchTimer.tsx new file mode 100644 index 000000000..a86304193 --- /dev/null +++ b/app/features/tournament-bracket/components/MatchTimer.tsx @@ -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 ( +
+
+
+ + {gameMarkers.map((marker) => ( +
+
+ G{marker.gameNumber} +
+
+
+ Start +
+
+ ))} + +
+
+ Max +
+
+ {totalMinutes}min +
+
+
+
+ ); +} diff --git a/app/features/tournament-bracket/components/StartedMatch.tsx b/app/features/tournament-bracket/components/StartedMatch.tsx index 258ffc2fe..162fe62ec 100644 --- a/app/features/tournament-bracket/components/StartedMatch.tsx +++ b/app/features/tournament-bracket/components/StartedMatch.tsx @@ -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["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({
)} + {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" ? ( + + ) : null} - {type === "EDIT" || presentational ? ( + {!waitingForPreviousMatch && (type === "EDIT" || presentational) ? ( (); 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({
+ ) : waitingForPreviousMatch ? ( +
+
+
+ Previous match ongoing +
+
+ Match will be reportable when both teams are ready to play +
+
+
) : waitingForActiveRosterSelectionFor ? (
@@ -383,6 +420,15 @@ function FancyStageBanner({ : waitingForActiveRosterSelectionFor}
+ {data.match.startedAt && + !tournament.isLeagueDivision && + (waitingForActiveRosterSelectionFor || !stage || inBanPhase) ? ( + + ) : null}
) : (
+ {data.match.startedAt && !data.matchIsOver ? ( + + ) : null} {children} )} + {(tournament.isOrganizer(user) || + teams.some((t) => t.members.some((m) => m.userId === user?.id))) && + !tournament.isLeagueDivision && + !matchIsLocked && + data.match.startedAt && + !data.matchIsOver ? ( + + ) : null} {infos && (
{infos.filter(Boolean).map((info, i) => ( @@ -790,3 +854,85 @@ function ScreenBanIcons({ banned }: { banned: boolean }) {
); } + +function EndSetPopover({ + teams, +}: { + teams: [TournamentDataTeam, TournamentDataTeam]; +}) { + const { t } = useTranslation(["tournament"]); + const [selectedWinner, setSelectedWinner] = React.useState< + number | null | undefined + >(undefined); + + return ( + + {t("tournament:match.action.endSet")} + + } + > +
+
+ + + + + + + +
+ + + + + {t("tournament:match.action.confirmEndSet")} + +
+
+ ); +} diff --git a/app/features/tournament-bracket/core/Bracket.ts b/app/features/tournament-bracket/core/Bracket.ts deleted file mode 100644 index a84261ae4..000000000 --- a/app/features/tournament-bracket/core/Bracket.ts +++ /dev/null @@ -1,1642 +0,0 @@ -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 { assertUnreachable } from "~/utils/types"; -import { cutToNDecimalPlaces } from "../../../utils/number"; -import { fillWithNullTillPowerOfTwo } from "../tournament-bracket-utils"; -import { getTournamentManager } from "./brackets-manager"; -import * as Progression from "./Progression"; -import { calculateTeamStatus } from "./Swiss"; -import type { OptionalIdObject, Tournament } from "./Tournament"; -import type { TournamentDataTeam } from "./Tournament.server"; -import type { BracketMapCounts } from "./toMapList"; - -interface CreateBracketArgs { - id: number; - /** Index of the bracket in the bracket progression */ - 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; // 1st, 2nd, 3rd, 4th, 5th, 5th... - groupId?: number; - stats?: { - setWins: number; - setLosses: number; - mapWins: number; - mapLosses: number; - points: number; - // first tiebreaker in round robin - winsAgainstTied: number; - // first tiebreaker in swiss - lossesAgainstTied?: number; - opponentSetWinPercentage?: number; - opponentMapWinPercentage?: number; - }; -} - -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) { - 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(); - - // we need some number but does not matter what it is as the manager only contains one tournament - 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, - }; - }); - } - - static create( - 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); - } - } - } - - defaultRoundBestOfs(_data: TournamentManagerDataSet): BracketMapCounts { - throw new Error("not implemented"); - } -} - -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); - } -} - -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, - }; - } -} - -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" && - typeof winner.score === "number" && - typeof loser.score === "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", - ); - } - - updateTeam({ - teamId: winner.id, - setWins: 1, - setLosses: 0, - mapWins: winner.score, - mapLosses: loser.score, - points: winner.totalPoints ?? 0, - }); - updateTeam({ - teamId: loser.id, - setWins: 0, - setLosses: 1, - mapWins: loser.score, - mapLosses: winner.score, - 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; - } -} - -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(); - - 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; - } -} diff --git a/app/features/tournament-bracket/core/Bracket/Bracket.ts b/app/features/tournament-bracket/core/Bracket/Bracket.ts new file mode 100644 index 000000000..7d0683a24 --- /dev/null +++ b/app/features/tournament-bracket/core/Bracket/Bracket.ts @@ -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) { + 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(); + + 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"); + } +} diff --git a/app/features/tournament-bracket/core/Bracket/DoubleEliminationBracket.ts b/app/features/tournament-bracket/core/Bracket/DoubleEliminationBracket.ts new file mode 100644 index 000000000..64b9e42b9 --- /dev/null +++ b/app/features/tournament-bracket/core/Bracket/DoubleEliminationBracket.ts @@ -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, + }; + } +} diff --git a/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts b/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts new file mode 100644 index 000000000..c0ec90f4a --- /dev/null +++ b/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts @@ -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; + } +} diff --git a/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts b/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts new file mode 100644 index 000000000..ae39da34e --- /dev/null +++ b/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts @@ -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); + } +} diff --git a/app/features/tournament-bracket/core/Bracket/SwissBracket.ts b/app/features/tournament-bracket/core/Bracket/SwissBracket.ts new file mode 100644 index 000000000..d213912d5 --- /dev/null +++ b/app/features/tournament-bracket/core/Bracket/SwissBracket.ts @@ -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(); + + 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 []; + } +} diff --git a/app/features/tournament-bracket/core/Bracket/index.ts b/app/features/tournament-bracket/core/Bracket/index.ts new file mode 100644 index 000000000..55c4e907b --- /dev/null +++ b/app/features/tournament-bracket/core/Bracket/index.ts @@ -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); + } + } +} diff --git a/app/features/tournament-bracket/core/Deadline.test.ts b/app/features/tournament-bracket/core/Deadline.test.ts new file mode 100644 index 000000000..f4d362031 --- /dev/null +++ b/app/features/tournament-bracket/core/Deadline.test.ts @@ -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"); + }); +}); diff --git a/app/features/tournament-bracket/core/Deadline.ts b/app/features/tournament-bracket/core/Deadline.ts new file mode 100644 index 000000000..2673bdd18 --- /dev/null +++ b/app/features/tournament-bracket/core/Deadline.ts @@ -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); +} diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index 3c9d0d286..ad2aadbe2 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -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 diff --git a/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts b/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts index 9d365cd0c..179e6dcc9 100644 --- a/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts +++ b/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts @@ -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; diff --git a/app/features/tournament-bracket/core/summarizer.server.ts b/app/features/tournament-bracket/core/summarizer.server.ts index aa4340ecb..ba2f457e8 100644 --- a/app/features/tournament-bracket/core/summarizer.server.ts +++ b/app/features/tournament-bracket/core/summarizer.server.ts @@ -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, diff --git a/app/features/tournament-bracket/core/summarizer.test.ts b/app/features/tournament-bracket/core/summarizer.test.ts index e948a38bf..979aaafa5 100644 --- a/app/features/tournament-bracket/core/summarizer.test.ts +++ b/app/features/tournament-bracket/core/summarizer.test.ts @@ -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); + }); }); diff --git a/app/features/tournament-bracket/core/tests/mocks-li.ts b/app/features/tournament-bracket/core/tests/mocks-li.ts index c1b18382b..40f6608b3 100644 --- a/app/features/tournament-bracket/core/tests/mocks-li.ts +++ b/app/features/tournament-bracket/core/tests/mocks-li.ts @@ -666,8 +666,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13348, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734201381, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37410, @@ -686,8 +685,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13348, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200847, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37411, @@ -706,8 +704,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13348, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200364, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37412, @@ -726,8 +723,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13348, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734201015, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37413, @@ -746,8 +742,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13348, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734201054, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37414, @@ -766,8 +761,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13348, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200952, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37415, @@ -786,8 +780,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13348, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200766, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37416, @@ -806,8 +799,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13348, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734199627, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37417, @@ -826,8 +818,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13348, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200641, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37418, @@ -846,8 +837,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13348, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734201215, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37419, @@ -866,8 +856,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13348, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200654, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37420, @@ -886,8 +875,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13348, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200774, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37421, @@ -906,8 +894,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13348, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200899, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37422, @@ -926,8 +913,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13348, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200927, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37423, @@ -946,8 +932,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13348, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200677, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37424, @@ -966,8 +951,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13348, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734201413, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37425, @@ -986,8 +970,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13348, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200827, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37426, @@ -1006,8 +989,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13348, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734201138, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37427, @@ -1026,8 +1008,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13348, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200622, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37428, @@ -1046,8 +1027,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13348, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200980, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37429, @@ -1066,8 +1046,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13354, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200773, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37430, @@ -1086,8 +1065,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13354, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200291, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37431, @@ -1106,8 +1084,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13354, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734201239, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37432, @@ -1126,8 +1103,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13354, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200733, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37433, @@ -1146,8 +1122,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13354, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200812, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37434, @@ -1166,8 +1141,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13354, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200467, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37435, @@ -1186,8 +1160,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13354, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200507, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37436, @@ -1206,8 +1179,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13354, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200519, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37437, @@ -1226,8 +1198,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13354, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200986, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37438, @@ -1246,8 +1217,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13354, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200966, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37439, @@ -1266,8 +1236,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13354, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734201110, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37440, @@ -1286,8 +1255,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13354, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200638, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37441, @@ -1306,8 +1274,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13354, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200935, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37442, @@ -1326,8 +1293,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13354, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734201011, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37443, @@ -1346,8 +1312,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13354, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200958, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37444, @@ -1366,8 +1331,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13354, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200548, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37445, @@ -1386,8 +1350,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13354, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734201082, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37446, @@ -1406,8 +1369,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13354, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734201009, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37447, @@ -1426,8 +1388,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13354, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200786, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37448, @@ -1446,8 +1407,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13354, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734200846, - createdAt: 1734199362, + startedAt: 1734199362, }, { id: 37449, @@ -1466,8 +1426,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13349, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734202992, - createdAt: 1734201461, + startedAt: 1734201461, }, { id: 37450, @@ -1486,8 +1445,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13349, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734203240, - createdAt: 1734201461, + startedAt: 1734201461, }, { id: 37451, @@ -1506,8 +1464,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13349, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734203405, - createdAt: 1734201461, + startedAt: 1734201461, }, { id: 37452, @@ -1526,8 +1483,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13349, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734203162, - createdAt: 1734201461, + startedAt: 1734201461, }, { id: 37453, @@ -1546,8 +1502,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13349, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734202823, - createdAt: 1734201461, + startedAt: 1734201461, }, { id: 37454, @@ -1566,8 +1521,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13349, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734202969, - createdAt: 1734201461, + startedAt: 1734201461, }, { id: 37455, @@ -1586,8 +1540,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13349, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734203020, - createdAt: 1734201461, + startedAt: 1734201461, }, { id: 37456, @@ -1606,8 +1559,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13349, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734202993, - createdAt: 1734201461, + startedAt: 1734201461, }, { id: 37457, @@ -1626,8 +1578,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13349, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734203011, - createdAt: 1734201461, + startedAt: 1734201461, }, { id: 37458, @@ -1646,8 +1597,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13349, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734202852, - createdAt: 1734201461, + startedAt: 1734201461, }, { id: 37459, @@ -1666,8 +1616,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13349, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734202837, - createdAt: 1734201461, + startedAt: 1734201461, }, { id: 37460, @@ -1686,8 +1635,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13349, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734202895, - createdAt: 1734201461, + startedAt: 1734201461, }, { id: 37461, @@ -1706,8 +1654,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13349, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734202893, - createdAt: 1734201461, + startedAt: 1734201461, }, { id: 37462, @@ -1726,8 +1673,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13349, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734202350, - createdAt: 1734201461, + startedAt: 1734201461, }, { id: 37463, @@ -1746,8 +1692,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13349, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734203127, - createdAt: 1734201461, + startedAt: 1734201461, }, { id: 37464, @@ -1766,8 +1711,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13349, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734202836, - createdAt: 1734201461, + startedAt: 1734201461, }, { id: 37465, @@ -1786,8 +1730,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13349, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734202713, - createdAt: 1734201461, + startedAt: 1734201461, }, { id: 37466, @@ -1806,8 +1749,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13349, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734203259, - createdAt: 1734201461, + startedAt: 1734201461, }, { id: 37467, @@ -1826,8 +1768,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13349, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734203213, - createdAt: 1734201461, + startedAt: 1734201461, }, { id: 37468, @@ -1840,8 +1781,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13349, stage_id: 1420, status: 2, - lastGameFinishedAt: null, - createdAt: 1734201461, + startedAt: 1734201461, }, { id: 37469, @@ -1860,8 +1800,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13355, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734202887, - createdAt: 1734201464, + startedAt: 1734201464, }, { id: 37470, @@ -1880,8 +1819,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13355, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734202670, - createdAt: 1734201464, + startedAt: 1734201464, }, { id: 37471, @@ -1900,8 +1838,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13355, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734203090, - createdAt: 1734201464, + startedAt: 1734201464, }, { id: 37472, @@ -1920,8 +1857,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13355, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734203293, - createdAt: 1734201464, + startedAt: 1734201464, }, { id: 37473, @@ -1940,8 +1876,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13355, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734202990, - createdAt: 1734201464, + startedAt: 1734201464, }, { id: 37474, @@ -1960,8 +1895,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13355, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734203133, - createdAt: 1734201464, + startedAt: 1734201464, }, { id: 37475, @@ -1980,8 +1914,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13355, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734202809, - createdAt: 1734201464, + startedAt: 1734201464, }, { id: 37476, @@ -2000,8 +1933,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13355, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734203850, - createdAt: 1734201464, + startedAt: 1734201464, }, { id: 37477, @@ -2020,8 +1952,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13355, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734203018, - createdAt: 1734201464, + startedAt: 1734201464, }, { id: 37478, @@ -2040,8 +1971,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13355, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734202505, - createdAt: 1734201464, + startedAt: 1734201464, }, { id: 37479, @@ -2060,8 +1990,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13355, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734203016, - createdAt: 1734201464, + startedAt: 1734201464, }, { id: 37480, @@ -2080,8 +2009,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13355, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734203065, - createdAt: 1734201464, + startedAt: 1734201464, }, { id: 37481, @@ -2100,8 +2028,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13355, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734203006, - createdAt: 1734201464, + startedAt: 1734201464, }, { id: 37482, @@ -2120,8 +2047,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13355, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734203042, - createdAt: 1734201464, + startedAt: 1734201464, }, { id: 37483, @@ -2140,8 +2066,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13355, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734202639, - createdAt: 1734201464, + startedAt: 1734201464, }, { id: 37484, @@ -2160,8 +2085,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13355, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734203145, - createdAt: 1734201464, + startedAt: 1734201464, }, { id: 37485, @@ -2180,8 +2104,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13355, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734202725, - createdAt: 1734201464, + startedAt: 1734201464, }, { id: 37486, @@ -2200,8 +2123,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13355, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734203118, - createdAt: 1734201464, + startedAt: 1734201464, }, { id: 37487, @@ -2220,8 +2142,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13355, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734203125, - createdAt: 1734201464, + startedAt: 1734201464, }, { id: 37488, @@ -2240,8 +2161,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13355, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734202716, - createdAt: 1734201464, + startedAt: 1734201464, }, { id: 37489, @@ -2260,8 +2180,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13350, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205314, - createdAt: 1734203865, + startedAt: 1734203865, }, { id: 37490, @@ -2280,8 +2199,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13350, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205187, - createdAt: 1734203865, + startedAt: 1734203865, }, { id: 37491, @@ -2300,8 +2218,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13350, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205900, - createdAt: 1734203865, + startedAt: 1734203865, }, { id: 37492, @@ -2320,8 +2237,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13350, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205160, - createdAt: 1734203865, + startedAt: 1734203865, }, { id: 37493, @@ -2340,8 +2256,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13350, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205328, - createdAt: 1734203865, + startedAt: 1734203865, }, { id: 37494, @@ -2360,8 +2275,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13350, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205415, - createdAt: 1734203865, + startedAt: 1734203865, }, { id: 37495, @@ -2380,8 +2294,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13350, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205534, - createdAt: 1734203865, + startedAt: 1734203865, }, { id: 37496, @@ -2400,8 +2313,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13350, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205537, - createdAt: 1734203865, + startedAt: 1734203865, }, { id: 37497, @@ -2420,8 +2332,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13350, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205596, - createdAt: 1734203865, + startedAt: 1734203865, }, { id: 37498, @@ -2440,8 +2351,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13350, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205257, - createdAt: 1734203865, + startedAt: 1734203865, }, { id: 37499, @@ -2460,8 +2370,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13350, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205521, - createdAt: 1734203865, + startedAt: 1734203865, }, { id: 37500, @@ -2480,8 +2389,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13350, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734204996, - createdAt: 1734203865, + startedAt: 1734203865, }, { id: 37501, @@ -2500,8 +2408,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13350, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205846, - createdAt: 1734203865, + startedAt: 1734203865, }, { id: 37502, @@ -2520,8 +2427,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13350, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205069, - createdAt: 1734203865, + startedAt: 1734203865, }, { id: 37503, @@ -2540,8 +2446,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13350, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205202, - createdAt: 1734203865, + startedAt: 1734203865, }, { id: 37504, @@ -2560,8 +2465,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13350, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205292, - createdAt: 1734203865, + startedAt: 1734203865, }, { id: 37505, @@ -2580,8 +2484,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13350, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205696, - createdAt: 1734203865, + startedAt: 1734203865, }, { id: 37506, @@ -2600,8 +2503,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13350, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205099, - createdAt: 1734203865, + startedAt: 1734203865, }, { id: 37507, @@ -2620,8 +2522,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13350, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205086, - createdAt: 1734203865, + startedAt: 1734203865, }, { id: 37508, @@ -2640,8 +2541,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13356, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205480, - createdAt: 1734203867, + startedAt: 1734203867, }, { id: 37509, @@ -2660,8 +2560,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13356, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205405, - createdAt: 1734203867, + startedAt: 1734203867, }, { id: 37510, @@ -2680,8 +2579,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13356, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205519, - createdAt: 1734203867, + startedAt: 1734203867, }, { id: 37511, @@ -2700,8 +2598,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13356, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205437, - createdAt: 1734203867, + startedAt: 1734203867, }, { id: 37512, @@ -2720,8 +2617,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13356, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205082, - createdAt: 1734203867, + startedAt: 1734203867, }, { id: 37513, @@ -2740,8 +2636,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13356, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205410, - createdAt: 1734203867, + startedAt: 1734203867, }, { id: 37514, @@ -2760,8 +2655,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13356, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205330, - createdAt: 1734203867, + startedAt: 1734203867, }, { id: 37515, @@ -2780,8 +2674,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13356, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205198, - createdAt: 1734203867, + startedAt: 1734203867, }, { id: 37516, @@ -2800,8 +2693,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13356, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205001, - createdAt: 1734203867, + startedAt: 1734203867, }, { id: 37517, @@ -2820,8 +2712,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13356, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205352, - createdAt: 1734203867, + startedAt: 1734203867, }, { id: 37518, @@ -2840,8 +2731,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13356, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205392, - createdAt: 1734203867, + startedAt: 1734203867, }, { id: 37519, @@ -2860,8 +2750,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13356, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205455, - createdAt: 1734203867, + startedAt: 1734203867, }, { id: 37520, @@ -2880,8 +2769,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13356, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205401, - createdAt: 1734203867, + startedAt: 1734203867, }, { id: 37521, @@ -2900,8 +2788,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13356, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205800, - createdAt: 1734203867, + startedAt: 1734203867, }, { id: 37522, @@ -2920,8 +2807,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13356, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205609, - createdAt: 1734203867, + startedAt: 1734203867, }, { id: 37523, @@ -2940,8 +2826,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13356, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205654, - createdAt: 1734203867, + startedAt: 1734203867, }, { id: 37524, @@ -2960,8 +2845,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13356, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734205446, - createdAt: 1734203867, + startedAt: 1734203867, }, { id: 37525, @@ -2980,8 +2864,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13356, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734204810, - createdAt: 1734203867, + startedAt: 1734203867, }, { id: 37526, @@ -3000,8 +2883,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13356, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734204985, - createdAt: 1734203867, + startedAt: 1734203867, }, { id: 37591, @@ -3020,8 +2902,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13357, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207405, - createdAt: 1734205934, + startedAt: 1734205934, }, { id: 37592, @@ -3040,8 +2921,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13357, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207190, - createdAt: 1734205934, + startedAt: 1734205934, }, { id: 37593, @@ -3060,8 +2940,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13357, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207437, - createdAt: 1734205934, + startedAt: 1734205934, }, { id: 37594, @@ -3080,8 +2959,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13357, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207475, - createdAt: 1734205934, + startedAt: 1734205934, }, { id: 37595, @@ -3100,8 +2978,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13357, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207326, - createdAt: 1734205934, + startedAt: 1734205934, }, { id: 37596, @@ -3120,8 +2997,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13357, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734206995, - createdAt: 1734205934, + startedAt: 1734205934, }, { id: 37597, @@ -3140,8 +3016,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13357, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207335, - createdAt: 1734205934, + startedAt: 1734205934, }, { id: 37598, @@ -3160,8 +3035,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13357, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207538, - createdAt: 1734205934, + startedAt: 1734205934, }, { id: 37599, @@ -3180,8 +3054,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13357, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207398, - createdAt: 1734205934, + startedAt: 1734205934, }, { id: 37600, @@ -3200,8 +3073,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13357, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207301, - createdAt: 1734205934, + startedAt: 1734205934, }, { id: 37601, @@ -3220,8 +3092,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13357, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207472, - createdAt: 1734205934, + startedAt: 1734205934, }, { id: 37602, @@ -3240,8 +3111,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13357, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207294, - createdAt: 1734205934, + startedAt: 1734205934, }, { id: 37603, @@ -3260,8 +3130,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13357, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207432, - createdAt: 1734205934, + startedAt: 1734205934, }, { id: 37604, @@ -3280,8 +3149,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13357, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207251, - createdAt: 1734205934, + startedAt: 1734205934, }, { id: 37605, @@ -3300,8 +3168,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13357, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207495, - createdAt: 1734205934, + startedAt: 1734205934, }, { id: 37606, @@ -3320,8 +3187,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13357, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207088, - createdAt: 1734205934, + startedAt: 1734205934, }, { id: 37607, @@ -3340,8 +3206,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13357, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207599, - createdAt: 1734205934, + startedAt: 1734205934, }, { id: 37608, @@ -3360,8 +3225,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13357, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207258, - createdAt: 1734205934, + startedAt: 1734205934, }, { id: 37609, @@ -3380,8 +3244,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13357, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207290, - createdAt: 1734205934, + startedAt: 1734205934, }, { id: 37610, @@ -3400,8 +3263,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13351, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207300, - createdAt: 1734205937, + startedAt: 1734205937, }, { id: 37611, @@ -3420,8 +3282,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13351, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207242, - createdAt: 1734205937, + startedAt: 1734205937, }, { id: 37612, @@ -3440,8 +3301,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13351, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207472, - createdAt: 1734205937, + startedAt: 1734205937, }, { id: 37613, @@ -3460,8 +3320,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13351, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207587, - createdAt: 1734205937, + startedAt: 1734205937, }, { id: 37614, @@ -3480,8 +3339,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13351, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207370, - createdAt: 1734205937, + startedAt: 1734205937, }, { id: 37615, @@ -3500,8 +3358,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13351, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734206978, - createdAt: 1734205937, + startedAt: 1734205937, }, { id: 37616, @@ -3520,8 +3377,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13351, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207448, - createdAt: 1734205937, + startedAt: 1734205937, }, { id: 37617, @@ -3540,8 +3396,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13351, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207665, - createdAt: 1734205937, + startedAt: 1734205937, }, { id: 37618, @@ -3560,8 +3415,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13351, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207565, - createdAt: 1734205937, + startedAt: 1734205937, }, { id: 37619, @@ -3580,8 +3434,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13351, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207541, - createdAt: 1734205937, + startedAt: 1734205937, }, { id: 37620, @@ -3600,8 +3453,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13351, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207654, - createdAt: 1734205937, + startedAt: 1734205937, }, { id: 37621, @@ -3620,8 +3472,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13351, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734206287, - createdAt: 1734205937, + startedAt: 1734205937, }, { id: 37622, @@ -3640,8 +3491,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13351, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207527, - createdAt: 1734205937, + startedAt: 1734205937, }, { id: 37623, @@ -3660,8 +3510,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13351, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207255, - createdAt: 1734205937, + startedAt: 1734205937, }, { id: 37624, @@ -3680,8 +3529,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13351, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207477, - createdAt: 1734205937, + startedAt: 1734205937, }, { id: 37625, @@ -3700,8 +3548,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13351, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207914, - createdAt: 1734205937, + startedAt: 1734205937, }, { id: 37626, @@ -3720,8 +3567,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13351, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207454, - createdAt: 1734205937, + startedAt: 1734205937, }, { id: 37627, @@ -3740,8 +3586,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13351, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207384, - createdAt: 1734205937, + startedAt: 1734205937, }, { id: 37628, @@ -3760,8 +3605,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13351, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734207463, - createdAt: 1734205937, + startedAt: 1734205937, }, { id: 37629, @@ -3780,8 +3624,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13352, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209427, - createdAt: 1734207931, + startedAt: 1734207931, }, { id: 37630, @@ -3800,8 +3643,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13352, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209422, - createdAt: 1734207931, + startedAt: 1734207931, }, { id: 37631, @@ -3820,8 +3662,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13352, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209455, - createdAt: 1734207931, + startedAt: 1734207931, }, { id: 37632, @@ -3840,8 +3681,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13352, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209429, - createdAt: 1734207931, + startedAt: 1734207931, }, { id: 37633, @@ -3860,8 +3700,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13352, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209428, - createdAt: 1734207931, + startedAt: 1734207931, }, { id: 37634, @@ -3880,8 +3719,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13352, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209506, - createdAt: 1734207931, + startedAt: 1734207931, }, { id: 37635, @@ -3900,8 +3738,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13352, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734210134, - createdAt: 1734207931, + startedAt: 1734207931, }, { id: 37636, @@ -3920,8 +3757,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13352, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209376, - createdAt: 1734207931, + startedAt: 1734207931, }, { id: 37637, @@ -3940,8 +3776,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13352, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734208989, - createdAt: 1734207931, + startedAt: 1734207931, }, { id: 37638, @@ -3960,8 +3795,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13352, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209472, - createdAt: 1734207931, + startedAt: 1734207931, }, { id: 37639, @@ -3980,8 +3814,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13352, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209546, - createdAt: 1734207931, + startedAt: 1734207931, }, { id: 37640, @@ -4000,8 +3833,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13352, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209511, - createdAt: 1734207931, + startedAt: 1734207931, }, { id: 37641, @@ -4020,8 +3852,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13352, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734210160, - createdAt: 1734207931, + startedAt: 1734207931, }, { id: 37642, @@ -4040,8 +3871,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13352, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209669, - createdAt: 1734207931, + startedAt: 1734207931, }, { id: 37643, @@ -4060,8 +3890,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13352, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209037, - createdAt: 1734207931, + startedAt: 1734207931, }, { id: 37644, @@ -4080,8 +3909,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13352, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209907, - createdAt: 1734207931, + startedAt: 1734207931, }, { id: 37645, @@ -4100,8 +3928,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13352, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209418, - createdAt: 1734207931, + startedAt: 1734207931, }, { id: 37646, @@ -4120,8 +3947,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13352, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209624, - createdAt: 1734207931, + startedAt: 1734207931, }, { id: 37647, @@ -4134,8 +3960,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13352, stage_id: 1420, status: 2, - lastGameFinishedAt: null, - createdAt: 1734207931, + startedAt: 1734207931, }, { id: 37648, @@ -4154,8 +3979,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13358, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734208969, - createdAt: 1734207933, + startedAt: 1734207933, }, { id: 37649, @@ -4174,8 +3998,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13358, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209574, - createdAt: 1734207933, + startedAt: 1734207933, }, { id: 37650, @@ -4194,8 +4017,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13358, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209389, - createdAt: 1734207933, + startedAt: 1734207933, }, { id: 37651, @@ -4214,8 +4036,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13358, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209008, - createdAt: 1734207933, + startedAt: 1734207933, }, { id: 37652, @@ -4234,8 +4055,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13358, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209727, - createdAt: 1734207933, + startedAt: 1734207933, }, { id: 37653, @@ -4254,8 +4074,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13358, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209389, - createdAt: 1734207933, + startedAt: 1734207933, }, { id: 37654, @@ -4274,8 +4093,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13358, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209267, - createdAt: 1734207933, + startedAt: 1734207933, }, { id: 37655, @@ -4294,8 +4112,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13358, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209646, - createdAt: 1734207933, + startedAt: 1734207933, }, { id: 37656, @@ -4314,8 +4131,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13358, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209344, - createdAt: 1734207933, + startedAt: 1734207933, }, { id: 37657, @@ -4334,8 +4150,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13358, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209405, - createdAt: 1734207933, + startedAt: 1734207933, }, { id: 37658, @@ -4354,8 +4169,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13358, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209283, - createdAt: 1734207933, + startedAt: 1734207933, }, { id: 37659, @@ -4374,8 +4188,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13358, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209500, - createdAt: 1734207933, + startedAt: 1734207933, }, { id: 37660, @@ -4394,8 +4207,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13358, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209709, - createdAt: 1734207933, + startedAt: 1734207933, }, { id: 37661, @@ -4414,8 +4226,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13358, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209910, - createdAt: 1734207933, + startedAt: 1734207933, }, { id: 37662, @@ -4434,8 +4245,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13358, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209045, - createdAt: 1734207933, + startedAt: 1734207933, }, { id: 37663, @@ -4454,8 +4264,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13358, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209776, - createdAt: 1734207933, + startedAt: 1734207933, }, { id: 37664, @@ -4474,8 +4283,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13358, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209957, - createdAt: 1734207933, + startedAt: 1734207933, }, { id: 37665, @@ -4494,8 +4302,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13358, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734209365, - createdAt: 1734207933, + startedAt: 1734207933, }, { id: 37666, @@ -4514,8 +4321,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13353, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211697, - createdAt: 1734210194, + startedAt: 1734210194, }, { id: 37667, @@ -4534,8 +4340,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13353, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211491, - createdAt: 1734210194, + startedAt: 1734210194, }, { id: 37668, @@ -4554,8 +4359,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13353, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211418, - createdAt: 1734210194, + startedAt: 1734210194, }, { id: 37669, @@ -4574,8 +4378,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13353, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211566, - createdAt: 1734210194, + startedAt: 1734210194, }, { id: 37670, @@ -4594,8 +4397,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13353, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211758, - createdAt: 1734210194, + startedAt: 1734210194, }, { id: 37671, @@ -4614,8 +4416,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13353, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211517, - createdAt: 1734210194, + startedAt: 1734210194, }, { id: 37672, @@ -4634,8 +4435,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13353, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211690, - createdAt: 1734210194, + startedAt: 1734210194, }, { id: 37673, @@ -4654,8 +4454,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13353, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211495, - createdAt: 1734210194, + startedAt: 1734210194, }, { id: 37674, @@ -4674,8 +4473,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13353, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211631, - createdAt: 1734210194, + startedAt: 1734210194, }, { id: 37675, @@ -4694,8 +4492,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13353, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211487, - createdAt: 1734210194, + startedAt: 1734210194, }, { id: 37676, @@ -4714,8 +4511,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13353, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211685, - createdAt: 1734210194, + startedAt: 1734210194, }, { id: 37677, @@ -4734,8 +4530,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13353, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211612, - createdAt: 1734210194, + startedAt: 1734210194, }, { id: 37678, @@ -4754,8 +4549,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13353, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211819, - createdAt: 1734210194, + startedAt: 1734210194, }, { id: 37679, @@ -4774,8 +4568,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13353, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734210661, - createdAt: 1734210194, + startedAt: 1734210194, }, { id: 37680, @@ -4794,8 +4587,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13353, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211695, - createdAt: 1734210194, + startedAt: 1734210194, }, { id: 37681, @@ -4814,8 +4606,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13353, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211306, - createdAt: 1734210194, + startedAt: 1734210194, }, { id: 37682, @@ -4834,8 +4625,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13353, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211835, - createdAt: 1734210194, + startedAt: 1734210194, }, { id: 37683, @@ -4854,8 +4644,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13353, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211736, - createdAt: 1734210194, + startedAt: 1734210194, }, { id: 37684, @@ -4874,8 +4663,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13359, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211836, - createdAt: 1734210198, + startedAt: 1734210198, }, { id: 37685, @@ -4894,8 +4682,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13359, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211945, - createdAt: 1734210198, + startedAt: 1734210198, }, { id: 37686, @@ -4914,8 +4701,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13359, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211860, - createdAt: 1734210198, + startedAt: 1734210198, }, { id: 37687, @@ -4934,8 +4720,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13359, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734212033, - createdAt: 1734210198, + startedAt: 1734210198, }, { id: 37688, @@ -4954,8 +4739,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13359, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211894, - createdAt: 1734210198, + startedAt: 1734210198, }, { id: 37689, @@ -4974,8 +4758,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13359, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211502, - createdAt: 1734210198, + startedAt: 1734210198, }, { id: 37690, @@ -4994,8 +4777,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13359, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211861, - createdAt: 1734210198, + startedAt: 1734210198, }, { id: 37691, @@ -5014,8 +4796,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13359, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211864, - createdAt: 1734210198, + startedAt: 1734210198, }, { id: 37692, @@ -5034,8 +4815,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13359, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211849, - createdAt: 1734210198, + startedAt: 1734210198, }, { id: 37693, @@ -5054,8 +4834,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13359, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211699, - createdAt: 1734210198, + startedAt: 1734210198, }, { id: 37694, @@ -5074,8 +4853,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13359, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734210815, - createdAt: 1734210198, + startedAt: 1734210198, }, { id: 37695, @@ -5094,8 +4872,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13359, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211885, - createdAt: 1734210198, + startedAt: 1734210198, }, { id: 37696, @@ -5114,8 +4891,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13359, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211774, - createdAt: 1734210198, + startedAt: 1734210198, }, { id: 37697, @@ -5134,8 +4910,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13359, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211998, - createdAt: 1734210198, + startedAt: 1734210198, }, { id: 37698, @@ -5154,8 +4929,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13359, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211107, - createdAt: 1734210198, + startedAt: 1734210198, }, { id: 37699, @@ -5174,8 +4948,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13359, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734210313, - createdAt: 1734210198, + startedAt: 1734210198, }, { id: 37700, @@ -5194,8 +4967,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13359, stage_id: 1420, status: 4, - lastGameFinishedAt: 1734211125, - createdAt: 1734210198, + startedAt: 1734210198, }, { id: 37875, @@ -5216,8 +4988,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13452, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734287859, - createdAt: null, + startedAt: null, }, { id: 37876, @@ -5238,8 +5009,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13452, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734287685, - createdAt: null, + startedAt: null, }, { id: 37877, @@ -5260,8 +5030,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13452, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734287323, - createdAt: null, + startedAt: null, }, { id: 37878, @@ -5282,8 +5051,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13452, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734287563, - createdAt: null, + startedAt: null, }, { id: 37879, @@ -5304,8 +5072,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13452, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734287264, - createdAt: null, + startedAt: null, }, { id: 37880, @@ -5326,8 +5093,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13452, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734288062, - createdAt: null, + startedAt: null, }, { id: 37881, @@ -5348,8 +5114,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13452, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734288023, - createdAt: null, + startedAt: null, }, { id: 37882, @@ -5370,8 +5135,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13452, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734287344, - createdAt: null, + startedAt: null, }, { id: 37883, @@ -5390,8 +5154,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13453, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734288866, - createdAt: null, + startedAt: null, }, { id: 37884, @@ -5410,8 +5173,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13453, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734289241, - createdAt: null, + startedAt: null, }, { id: 37885, @@ -5430,8 +5192,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13453, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734289085, - createdAt: null, + startedAt: null, }, { id: 37886, @@ -5450,8 +5211,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13453, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734288631, - createdAt: null, + startedAt: null, }, { id: 37887, @@ -5470,8 +5230,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13454, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734291666, - createdAt: null, + startedAt: null, }, { id: 37888, @@ -5490,8 +5249,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13454, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734290374, - createdAt: null, + startedAt: null, }, { id: 37889, @@ -5510,8 +5268,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13455, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734294212, - createdAt: null, + startedAt: null, }, { id: 37890, @@ -5532,8 +5289,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13456, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734289063, - createdAt: null, + startedAt: null, }, { id: 37891, @@ -5554,8 +5310,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13456, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734288695, - createdAt: null, + startedAt: null, }, { id: 37892, @@ -5576,8 +5331,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13456, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734290198, - createdAt: null, + startedAt: null, }, { id: 37893, @@ -5598,8 +5352,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13456, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734289466, - createdAt: null, + startedAt: null, }, { id: 37894, @@ -5619,8 +5372,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13457, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734291096, - createdAt: null, + startedAt: null, }, { id: 37895, @@ -5640,8 +5392,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13457, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734290483, - createdAt: null, + startedAt: null, }, { id: 37896, @@ -5661,8 +5412,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13457, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734291569, - createdAt: null, + startedAt: null, }, { id: 37897, @@ -5682,8 +5432,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13457, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734290847, - createdAt: null, + startedAt: null, }, { id: 37898, @@ -5702,8 +5451,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13458, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734292036, - createdAt: null, + startedAt: null, }, { id: 37899, @@ -5722,8 +5470,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13458, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734293164, - createdAt: null, + startedAt: null, }, { id: 37900, @@ -5743,8 +5490,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13459, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734293878, - createdAt: null, + startedAt: null, }, { id: 37901, @@ -5764,8 +5510,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13459, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734294533, - createdAt: null, + startedAt: null, }, { id: 37902, @@ -5784,8 +5529,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13460, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734295743, - createdAt: null, + startedAt: null, }, { id: 37903, @@ -5805,8 +5549,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13461, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734297703, - createdAt: null, + startedAt: null, }, { id: 37904, @@ -5826,8 +5569,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13462, stage_id: 1429, status: 4, - lastGameFinishedAt: 1734299326, - createdAt: null, + startedAt: null, }, { id: 37905, @@ -5842,8 +5584,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13463, stage_id: 1429, status: 0, - lastGameFinishedAt: null, - createdAt: null, + startedAt: null, }, { id: 37906, @@ -5864,8 +5605,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13464, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734287457, - createdAt: null, + startedAt: null, }, { id: 37907, @@ -5886,8 +5626,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13464, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734287470, - createdAt: null, + startedAt: null, }, { id: 37908, @@ -5908,8 +5647,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13464, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734287227, - createdAt: null, + startedAt: null, }, { id: 37909, @@ -5930,8 +5668,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13464, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734287925, - createdAt: null, + startedAt: null, }, { id: 37910, @@ -5952,8 +5689,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13464, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734287691, - createdAt: null, + startedAt: null, }, { id: 37911, @@ -5974,8 +5710,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13464, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734288516, - createdAt: null, + startedAt: null, }, { id: 37912, @@ -5996,8 +5731,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13464, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734287452, - createdAt: null, + startedAt: null, }, { id: 37913, @@ -6018,8 +5752,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13464, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734287457, - createdAt: null, + startedAt: null, }, { id: 37914, @@ -6038,8 +5771,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13465, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734288535, - createdAt: null, + startedAt: null, }, { id: 37915, @@ -6058,8 +5790,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13465, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734289712, - createdAt: null, + startedAt: null, }, { id: 37916, @@ -6078,8 +5809,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13465, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734289334, - createdAt: null, + startedAt: null, }, { id: 37917, @@ -6098,8 +5828,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13465, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734289151, - createdAt: null, + startedAt: null, }, { id: 37918, @@ -6118,8 +5847,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13466, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734291648, - createdAt: null, + startedAt: null, }, { id: 37919, @@ -6138,8 +5866,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13466, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734291888, - createdAt: null, + startedAt: null, }, { id: 37920, @@ -6158,8 +5885,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13467, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734294728, - createdAt: null, + startedAt: null, }, { id: 37921, @@ -6180,8 +5906,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13468, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734288552, - createdAt: null, + startedAt: null, }, { id: 37922, @@ -6202,8 +5927,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13468, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734288890, - createdAt: null, + startedAt: null, }, { id: 37923, @@ -6224,8 +5948,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13468, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734289711, - createdAt: null, + startedAt: null, }, { id: 37924, @@ -6246,8 +5969,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13468, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734289042, - createdAt: null, + startedAt: null, }, { id: 37925, @@ -6267,8 +5989,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13469, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734291609, - createdAt: null, + startedAt: null, }, { id: 37926, @@ -6288,8 +6009,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13469, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734289831, - createdAt: null, + startedAt: null, }, { id: 37927, @@ -6309,8 +6029,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13469, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734291399, - createdAt: null, + startedAt: null, }, { id: 37928, @@ -6330,8 +6049,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13469, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734290954, - createdAt: null, + startedAt: null, }, { id: 37929, @@ -6350,8 +6068,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13470, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734292981, - createdAt: null, + startedAt: null, }, { id: 37930, @@ -6370,8 +6087,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13470, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734293211, - createdAt: null, + startedAt: null, }, { id: 37931, @@ -6391,8 +6107,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13471, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734294182, - createdAt: null, + startedAt: null, }, { id: 37932, @@ -6412,8 +6127,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13471, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734294104, - createdAt: null, + startedAt: null, }, { id: 37933, @@ -6432,8 +6146,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13472, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734295760, - createdAt: null, + startedAt: null, }, { id: 37934, @@ -6453,8 +6166,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13473, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734298350, - createdAt: null, + startedAt: null, }, { id: 37935, @@ -6474,8 +6186,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13474, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734300494, - createdAt: null, + startedAt: null, }, { id: 37936, @@ -6494,8 +6205,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13475, stage_id: 1430, status: 4, - lastGameFinishedAt: 1734302545, - createdAt: null, + startedAt: null, }, { id: 37937, @@ -6516,8 +6226,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13476, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734287278, - createdAt: null, + startedAt: null, }, { id: 37938, @@ -6538,8 +6247,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13476, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734287728, - createdAt: null, + startedAt: null, }, { id: 37939, @@ -6560,8 +6268,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13476, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734287903, - createdAt: null, + startedAt: null, }, { id: 37940, @@ -6582,8 +6289,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13476, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734287524, - createdAt: null, + startedAt: null, }, { id: 37941, @@ -6604,8 +6310,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13476, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734287488, - createdAt: null, + startedAt: null, }, { id: 37942, @@ -6626,8 +6331,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13476, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734287479, - createdAt: null, + startedAt: null, }, { id: 37943, @@ -6648,8 +6352,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13476, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734287120, - createdAt: null, + startedAt: null, }, { id: 37944, @@ -6670,8 +6373,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13476, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734288074, - createdAt: null, + startedAt: null, }, { id: 37945, @@ -6690,8 +6392,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13477, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734288706, - createdAt: null, + startedAt: null, }, { id: 37946, @@ -6710,8 +6411,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13477, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734289633, - createdAt: null, + startedAt: null, }, { id: 37947, @@ -6730,8 +6430,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13477, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734288729, - createdAt: null, + startedAt: null, }, { id: 37948, @@ -6750,8 +6449,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13477, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734288929, - createdAt: null, + startedAt: null, }, { id: 37949, @@ -6770,8 +6468,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13478, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734292409, - createdAt: null, + startedAt: null, }, { id: 37950, @@ -6790,8 +6487,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13478, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734291336, - createdAt: null, + startedAt: null, }, { id: 37951, @@ -6810,8 +6506,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13479, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734295383, - createdAt: null, + startedAt: null, }, { id: 37952, @@ -6832,8 +6527,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13480, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734288788, - createdAt: null, + startedAt: null, }, { id: 37953, @@ -6854,8 +6548,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13480, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734289037, - createdAt: null, + startedAt: null, }, { id: 37954, @@ -6876,8 +6569,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13480, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734289147, - createdAt: null, + startedAt: null, }, { id: 37955, @@ -6898,8 +6590,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13480, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734288878, - createdAt: null, + startedAt: null, }, { id: 37956, @@ -6919,8 +6610,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13481, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734291046, - createdAt: null, + startedAt: null, }, { id: 37957, @@ -6940,8 +6630,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13481, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734290444, - createdAt: null, + startedAt: null, }, { id: 37958, @@ -6961,8 +6650,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13481, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734290198, - createdAt: null, + startedAt: null, }, { id: 37959, @@ -6982,8 +6670,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13481, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734289813, - createdAt: null, + startedAt: null, }, { id: 37960, @@ -7002,8 +6689,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13482, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734292299, - createdAt: null, + startedAt: null, }, { id: 37961, @@ -7022,8 +6708,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13482, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734291700, - createdAt: null, + startedAt: null, }, { id: 37962, @@ -7043,8 +6728,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13483, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734293873, - createdAt: null, + startedAt: null, }, { id: 37963, @@ -7064,8 +6748,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13483, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734293799, - createdAt: null, + startedAt: null, }, { id: 37964, @@ -7084,8 +6767,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13484, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734295091, - createdAt: null, + startedAt: null, }, { id: 37965, @@ -7105,8 +6787,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13485, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734296693, - createdAt: null, + startedAt: null, }, { id: 37966, @@ -7126,8 +6807,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13486, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734298720, - createdAt: null, + startedAt: null, }, { id: 37967, @@ -7146,8 +6826,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ round_id: 13487, stage_id: 1431, status: 4, - lastGameFinishedAt: 1734300174, - createdAt: null, + startedAt: null, }, ], }, @@ -7210,7 +6889,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ ], thirdPlaceMatch: false, isRanked: false, - deadlines: "DEFAULT", isInvitational: false, enableNoScreenToggle: true, enableSubs: false, diff --git a/app/features/tournament-bracket/core/tests/mocks-sos.ts b/app/features/tournament-bracket/core/tests/mocks-sos.ts index ec6b766dd..b732c1869 100644 --- a/app/features/tournament-bracket/core/tests/mocks-sos.ts +++ b/app/features/tournament-bracket/core/tests/mocks-sos.ts @@ -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, diff --git a/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts b/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts index d40e144d4..424dceaea 100644 --- a/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts +++ b/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts @@ -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, diff --git a/app/features/tournament-bracket/core/tests/mocks.ts b/app/features/tournament-bracket/core/tests/mocks.ts index 5ad92e874..3db577133 100644 --- a/app/features/tournament-bracket/core/tests/mocks.ts +++ b/app/features/tournament-bracket/core/tests/mocks.ts @@ -771,7 +771,6 @@ export const PADDLING_POOL_257 = () => round_id: 386, stage_id: 28, status: 2, - lastGameFinishedAt: null, }, { id: 1719, @@ -794,7 +793,6 @@ export const PADDLING_POOL_257 = () => round_id: 386, stage_id: 28, status: 4, - lastGameFinishedAt: 1709749497, }, { id: 1720, @@ -808,7 +806,6 @@ export const PADDLING_POOL_257 = () => round_id: 387, stage_id: 28, status: 2, - lastGameFinishedAt: null, }, { id: 1721, @@ -831,7 +828,6 @@ export const PADDLING_POOL_257 = () => round_id: 387, stage_id: 28, status: 4, - lastGameFinishedAt: 1709750660, }, { id: 1722, @@ -845,7 +841,6 @@ export const PADDLING_POOL_257 = () => round_id: 388, stage_id: 28, status: 2, - lastGameFinishedAt: null, }, { id: 1723, @@ -868,7 +863,6 @@ export const PADDLING_POOL_257 = () => round_id: 388, stage_id: 28, status: 4, - lastGameFinishedAt: 1709751613, }, { id: 1724, @@ -891,7 +885,6 @@ export const PADDLING_POOL_257 = () => round_id: 389, stage_id: 28, status: 4, - lastGameFinishedAt: 1709748774, }, { id: 1725, @@ -914,7 +907,6 @@ export const PADDLING_POOL_257 = () => round_id: 389, stage_id: 28, status: 4, - lastGameFinishedAt: 1709749082, }, { id: 1726, @@ -937,7 +929,6 @@ export const PADDLING_POOL_257 = () => round_id: 390, stage_id: 28, status: 4, - lastGameFinishedAt: 1709750234, }, { id: 1727, @@ -960,7 +951,6 @@ export const PADDLING_POOL_257 = () => round_id: 390, stage_id: 28, status: 4, - lastGameFinishedAt: 1709750033, }, { id: 1728, @@ -983,7 +973,6 @@ export const PADDLING_POOL_257 = () => round_id: 391, stage_id: 28, status: 4, - lastGameFinishedAt: 1709750833, }, { id: 1729, @@ -1006,7 +995,6 @@ export const PADDLING_POOL_257 = () => round_id: 391, stage_id: 28, status: 4, - lastGameFinishedAt: 1709751416, }, { id: 1730, @@ -1029,7 +1017,6 @@ export const PADDLING_POOL_257 = () => round_id: 392, stage_id: 28, status: 4, - lastGameFinishedAt: 1709749391, }, { id: 1731, @@ -1052,7 +1039,6 @@ export const PADDLING_POOL_257 = () => round_id: 392, stage_id: 28, status: 4, - lastGameFinishedAt: 1709749496, }, { id: 1732, @@ -1075,7 +1061,6 @@ export const PADDLING_POOL_257 = () => round_id: 393, stage_id: 28, status: 4, - lastGameFinishedAt: 1709750563, }, { id: 1733, @@ -1098,7 +1083,6 @@ export const PADDLING_POOL_257 = () => round_id: 393, stage_id: 28, status: 4, - lastGameFinishedAt: 1709750568, }, { id: 1734, @@ -1121,7 +1105,6 @@ export const PADDLING_POOL_257 = () => round_id: 394, stage_id: 28, status: 4, - lastGameFinishedAt: 1709752177, }, { id: 1735, @@ -1144,7 +1127,6 @@ export const PADDLING_POOL_257 = () => round_id: 394, stage_id: 28, status: 4, - lastGameFinishedAt: 1709751545, }, { id: 1736, @@ -1167,7 +1149,6 @@ export const PADDLING_POOL_257 = () => round_id: 395, stage_id: 28, status: 4, - lastGameFinishedAt: 1709749457, }, { id: 1737, @@ -1190,7 +1171,6 @@ export const PADDLING_POOL_257 = () => round_id: 395, stage_id: 28, status: 4, - lastGameFinishedAt: 1709749856, }, { id: 1738, @@ -1213,7 +1193,6 @@ export const PADDLING_POOL_257 = () => round_id: 396, stage_id: 28, status: 4, - lastGameFinishedAt: 1709751076, }, { id: 1739, @@ -1236,7 +1215,6 @@ export const PADDLING_POOL_257 = () => round_id: 396, stage_id: 28, status: 4, - lastGameFinishedAt: 1709750839, }, { id: 1740, @@ -1259,7 +1237,6 @@ export const PADDLING_POOL_257 = () => round_id: 397, stage_id: 28, status: 4, - lastGameFinishedAt: 1709751953, }, { id: 1741, @@ -1282,7 +1259,6 @@ export const PADDLING_POOL_257 = () => round_id: 397, stage_id: 28, status: 4, - lastGameFinishedAt: 1709752142, }, { id: 1742, @@ -1305,7 +1281,6 @@ export const PADDLING_POOL_257 = () => round_id: 398, stage_id: 28, status: 4, - lastGameFinishedAt: 1709748829, }, { id: 1743, @@ -1328,7 +1303,6 @@ export const PADDLING_POOL_257 = () => round_id: 398, stage_id: 28, status: 4, - lastGameFinishedAt: 1709749581, }, { id: 1744, @@ -1351,7 +1325,6 @@ export const PADDLING_POOL_257 = () => round_id: 399, stage_id: 28, status: 4, - lastGameFinishedAt: 1709750712, }, { id: 1745, @@ -1374,7 +1347,6 @@ export const PADDLING_POOL_257 = () => round_id: 399, stage_id: 28, status: 4, - lastGameFinishedAt: 1709750613, }, { id: 1746, @@ -1397,7 +1369,6 @@ export const PADDLING_POOL_257 = () => round_id: 400, stage_id: 28, status: 4, - lastGameFinishedAt: 1709752018, }, { id: 1747, @@ -1420,7 +1391,6 @@ export const PADDLING_POOL_257 = () => round_id: 400, stage_id: 28, status: 4, - lastGameFinishedAt: 1709752201, }, { id: 1748, @@ -1443,7 +1413,6 @@ export const PADDLING_POOL_257 = () => round_id: 401, stage_id: 28, status: 4, - lastGameFinishedAt: 1709749242, }, { id: 1749, @@ -1466,7 +1435,6 @@ export const PADDLING_POOL_257 = () => round_id: 401, stage_id: 28, status: 4, - lastGameFinishedAt: 1709750696, }, { id: 1750, @@ -1489,7 +1457,6 @@ export const PADDLING_POOL_257 = () => round_id: 402, stage_id: 28, status: 4, - lastGameFinishedAt: 1709750278, }, { id: 1751, @@ -1512,7 +1479,6 @@ export const PADDLING_POOL_257 = () => round_id: 402, stage_id: 28, status: 4, - lastGameFinishedAt: 1709750402, }, { id: 1752, @@ -1535,7 +1501,6 @@ export const PADDLING_POOL_257 = () => round_id: 403, stage_id: 28, status: 4, - lastGameFinishedAt: 1709751782, }, { id: 1753, @@ -1558,7 +1523,6 @@ export const PADDLING_POOL_257 = () => round_id: 403, stage_id: 28, status: 4, - lastGameFinishedAt: 1709751817, }, { id: 1754, @@ -1581,7 +1545,6 @@ export const PADDLING_POOL_257 = () => round_id: 404, stage_id: 28, status: 4, - lastGameFinishedAt: 1709749631, }, { id: 1755, @@ -1604,7 +1567,6 @@ export const PADDLING_POOL_257 = () => round_id: 404, stage_id: 28, status: 4, - lastGameFinishedAt: 1709749052, }, { id: 1756, @@ -1627,7 +1589,6 @@ export const PADDLING_POOL_257 = () => round_id: 405, stage_id: 28, status: 4, - lastGameFinishedAt: 1709750943, }, { id: 1757, @@ -1650,7 +1611,6 @@ export const PADDLING_POOL_257 = () => round_id: 405, stage_id: 28, status: 4, - lastGameFinishedAt: 1709750521, }, { id: 1758, @@ -1673,7 +1633,6 @@ export const PADDLING_POOL_257 = () => round_id: 406, stage_id: 28, status: 4, - lastGameFinishedAt: 1709751679, }, { id: 1759, @@ -1696,7 +1655,6 @@ export const PADDLING_POOL_257 = () => round_id: 406, stage_id: 28, status: 4, - lastGameFinishedAt: 1709752680, }, { id: 1760, @@ -1719,7 +1677,6 @@ export const PADDLING_POOL_257 = () => round_id: 407, stage_id: 28, status: 4, - lastGameFinishedAt: 1709749124, }, { id: 1761, @@ -1742,7 +1699,6 @@ export const PADDLING_POOL_257 = () => round_id: 407, stage_id: 28, status: 4, - lastGameFinishedAt: 1709749206, }, { id: 1762, @@ -1765,7 +1721,6 @@ export const PADDLING_POOL_257 = () => round_id: 408, stage_id: 28, status: 4, - lastGameFinishedAt: 1709749994, }, { id: 1763, @@ -1788,7 +1743,6 @@ export const PADDLING_POOL_257 = () => round_id: 408, stage_id: 28, status: 4, - lastGameFinishedAt: 1709750105, }, { id: 1764, @@ -1811,7 +1765,6 @@ export const PADDLING_POOL_257 = () => round_id: 409, stage_id: 28, status: 4, - lastGameFinishedAt: 1709751062, }, { id: 1765, @@ -1834,7 +1787,6 @@ export const PADDLING_POOL_257 = () => round_id: 409, stage_id: 28, status: 4, - lastGameFinishedAt: 1709751329, }, { id: 1766, @@ -1857,7 +1809,6 @@ export const PADDLING_POOL_257 = () => round_id: 410, stage_id: 28, status: 4, - lastGameFinishedAt: 1709749041, }, { id: 1767, @@ -1880,7 +1831,6 @@ export const PADDLING_POOL_257 = () => round_id: 410, stage_id: 28, status: 4, - lastGameFinishedAt: 1709749783, }, { id: 1768, @@ -1903,7 +1853,6 @@ export const PADDLING_POOL_257 = () => round_id: 411, stage_id: 28, status: 4, - lastGameFinishedAt: 1709750554, }, { id: 1769, @@ -1926,7 +1875,6 @@ export const PADDLING_POOL_257 = () => round_id: 411, stage_id: 28, status: 4, - lastGameFinishedAt: 1709750930, }, { id: 1770, @@ -1949,7 +1897,6 @@ export const PADDLING_POOL_257 = () => round_id: 412, stage_id: 28, status: 4, - lastGameFinishedAt: 1709751944, }, { id: 1771, @@ -1972,7 +1919,6 @@ export const PADDLING_POOL_257 = () => round_id: 412, stage_id: 28, status: 4, - lastGameFinishedAt: 1709752657, }, ], }, @@ -6751,8 +6697,7 @@ export const PADDLING_POOL_255 = () => round_id: 285, stage_id: 20, status: 2, - lastGameFinishedAt: null, - createdAt: null, + startedAt: null, }, { id: 1427, @@ -6775,8 +6720,7 @@ export const PADDLING_POOL_255 = () => round_id: 285, stage_id: 20, status: 4, - lastGameFinishedAt: 1708539630, - createdAt: null, + startedAt: null, }, { id: 1428, @@ -6790,8 +6734,7 @@ export const PADDLING_POOL_255 = () => round_id: 286, stage_id: 20, status: 2, - lastGameFinishedAt: null, - createdAt: null, + startedAt: null, }, { id: 1429, @@ -6814,8 +6757,7 @@ export const PADDLING_POOL_255 = () => round_id: 286, stage_id: 20, status: 4, - lastGameFinishedAt: 1708540764, - createdAt: null, + startedAt: null, }, { id: 1430, @@ -6829,8 +6771,7 @@ export const PADDLING_POOL_255 = () => round_id: 287, stage_id: 20, status: 2, - lastGameFinishedAt: null, - createdAt: null, + startedAt: null, }, { id: 1431, @@ -6853,8 +6794,7 @@ export const PADDLING_POOL_255 = () => round_id: 287, stage_id: 20, status: 4, - lastGameFinishedAt: 1708541779, - createdAt: null, + startedAt: null, }, { id: 1432, @@ -6877,8 +6817,7 @@ export const PADDLING_POOL_255 = () => round_id: 288, stage_id: 20, status: 4, - lastGameFinishedAt: 1708539432, - createdAt: null, + startedAt: null, }, { id: 1433, @@ -6901,8 +6840,7 @@ export const PADDLING_POOL_255 = () => round_id: 288, stage_id: 20, status: 4, - lastGameFinishedAt: 1708540260, - createdAt: null, + startedAt: null, }, { id: 1434, @@ -6925,8 +6863,7 @@ export const PADDLING_POOL_255 = () => round_id: 289, stage_id: 20, status: 4, - lastGameFinishedAt: 1708541534, - createdAt: null, + startedAt: null, }, { id: 1435, @@ -6949,8 +6886,7 @@ export const PADDLING_POOL_255 = () => round_id: 289, stage_id: 20, status: 4, - lastGameFinishedAt: 1708541061, - createdAt: null, + startedAt: null, }, { id: 1436, @@ -6973,8 +6909,7 @@ export const PADDLING_POOL_255 = () => round_id: 290, stage_id: 20, status: 4, - lastGameFinishedAt: 1708543160, - createdAt: null, + startedAt: null, }, { id: 1437, @@ -6997,8 +6932,7 @@ export const PADDLING_POOL_255 = () => round_id: 290, stage_id: 20, status: 4, - lastGameFinishedAt: 1708542086, - createdAt: null, + startedAt: null, }, { id: 1438, @@ -7021,8 +6955,7 @@ export const PADDLING_POOL_255 = () => round_id: 291, stage_id: 20, status: 4, - lastGameFinishedAt: 1708540004, - createdAt: null, + startedAt: null, }, { id: 1439, @@ -7045,8 +6978,7 @@ export const PADDLING_POOL_255 = () => round_id: 291, stage_id: 20, status: 4, - lastGameFinishedAt: 1708539745, - createdAt: null, + startedAt: null, }, { id: 1440, @@ -7069,8 +7001,7 @@ export const PADDLING_POOL_255 = () => round_id: 292, stage_id: 20, status: 4, - lastGameFinishedAt: 1708541376, - createdAt: null, + startedAt: null, }, { id: 1441, @@ -7093,8 +7024,7 @@ export const PADDLING_POOL_255 = () => round_id: 292, stage_id: 20, status: 4, - lastGameFinishedAt: 1708541456, - createdAt: null, + startedAt: null, }, { id: 1442, @@ -7117,8 +7047,7 @@ export const PADDLING_POOL_255 = () => round_id: 293, stage_id: 20, status: 4, - lastGameFinishedAt: 1708543165, - createdAt: null, + startedAt: null, }, { id: 1443, @@ -7141,8 +7070,7 @@ export const PADDLING_POOL_255 = () => round_id: 293, stage_id: 20, status: 4, - lastGameFinishedAt: 1708543022, - createdAt: null, + startedAt: null, }, { id: 1444, @@ -7165,8 +7093,7 @@ export const PADDLING_POOL_255 = () => round_id: 294, stage_id: 20, status: 4, - lastGameFinishedAt: 1708539666, - createdAt: null, + startedAt: null, }, { id: 1445, @@ -7189,8 +7116,7 @@ export const PADDLING_POOL_255 = () => round_id: 294, stage_id: 20, status: 4, - lastGameFinishedAt: 1708539674, - createdAt: null, + startedAt: null, }, { id: 1446, @@ -7213,8 +7139,7 @@ export const PADDLING_POOL_255 = () => round_id: 295, stage_id: 20, status: 4, - lastGameFinishedAt: 1708540981, - createdAt: null, + startedAt: null, }, { id: 1447, @@ -7237,8 +7162,7 @@ export const PADDLING_POOL_255 = () => round_id: 295, stage_id: 20, status: 4, - lastGameFinishedAt: 1708540741, - createdAt: null, + startedAt: null, }, { id: 1448, @@ -7261,8 +7185,7 @@ export const PADDLING_POOL_255 = () => round_id: 296, stage_id: 20, status: 4, - lastGameFinishedAt: 1708542080, - createdAt: null, + startedAt: null, }, { id: 1449, @@ -7285,8 +7208,7 @@ export const PADDLING_POOL_255 = () => round_id: 296, stage_id: 20, status: 4, - lastGameFinishedAt: 1708542786, - createdAt: null, + startedAt: null, }, { id: 1450, @@ -7309,8 +7231,7 @@ export const PADDLING_POOL_255 = () => round_id: 297, stage_id: 20, status: 4, - lastGameFinishedAt: 1708539616, - createdAt: null, + startedAt: null, }, { id: 1451, @@ -7333,8 +7254,7 @@ export const PADDLING_POOL_255 = () => round_id: 297, stage_id: 20, status: 4, - lastGameFinishedAt: 1708539686, - createdAt: null, + startedAt: null, }, { id: 1452, @@ -7357,8 +7277,7 @@ export const PADDLING_POOL_255 = () => round_id: 298, stage_id: 20, status: 4, - lastGameFinishedAt: 1708541068, - createdAt: null, + startedAt: null, }, { id: 1453, @@ -7381,8 +7300,7 @@ export const PADDLING_POOL_255 = () => round_id: 298, stage_id: 20, status: 4, - lastGameFinishedAt: 1708540768, - createdAt: null, + startedAt: null, }, { id: 1454, @@ -7405,8 +7323,7 @@ export const PADDLING_POOL_255 = () => round_id: 299, stage_id: 20, status: 4, - lastGameFinishedAt: 1708541871, - createdAt: null, + startedAt: null, }, { id: 1455, @@ -7429,8 +7346,7 @@ export const PADDLING_POOL_255 = () => round_id: 299, stage_id: 20, status: 4, - lastGameFinishedAt: 1708542578, - createdAt: null, + startedAt: null, }, { id: 1456, @@ -7453,8 +7369,7 @@ export const PADDLING_POOL_255 = () => round_id: 300, stage_id: 20, status: 4, - lastGameFinishedAt: 1708539276, - createdAt: null, + startedAt: null, }, { id: 1457, @@ -7477,8 +7392,7 @@ export const PADDLING_POOL_255 = () => round_id: 300, stage_id: 20, status: 4, - lastGameFinishedAt: 1708539414, - createdAt: null, + startedAt: null, }, { id: 1458, @@ -7501,8 +7415,7 @@ export const PADDLING_POOL_255 = () => round_id: 301, stage_id: 20, status: 4, - lastGameFinishedAt: 1708540235, - createdAt: null, + startedAt: null, }, { id: 1459, @@ -7525,8 +7438,7 @@ export const PADDLING_POOL_255 = () => round_id: 301, stage_id: 20, status: 4, - lastGameFinishedAt: 1708541059, - createdAt: null, + startedAt: null, }, { id: 1460, @@ -7549,8 +7461,7 @@ export const PADDLING_POOL_255 = () => round_id: 302, stage_id: 20, status: 4, - lastGameFinishedAt: 1708542478, - createdAt: null, + startedAt: null, }, { id: 1461, @@ -7573,8 +7484,7 @@ export const PADDLING_POOL_255 = () => round_id: 302, stage_id: 20, status: 4, - lastGameFinishedAt: 1708543244, - createdAt: null, + startedAt: null, }, { id: 1462, @@ -7597,8 +7507,7 @@ export const PADDLING_POOL_255 = () => round_id: 303, stage_id: 20, status: 4, - lastGameFinishedAt: 1708540410, - createdAt: null, + startedAt: null, }, { id: 1463, @@ -7621,8 +7530,7 @@ export const PADDLING_POOL_255 = () => round_id: 303, stage_id: 20, status: 4, - lastGameFinishedAt: 1708539690, - createdAt: null, + startedAt: null, }, { id: 1464, @@ -7645,8 +7553,7 @@ export const PADDLING_POOL_255 = () => round_id: 304, stage_id: 20, status: 4, - lastGameFinishedAt: 1708540751, - createdAt: null, + startedAt: null, }, { id: 1465, @@ -7669,8 +7576,7 @@ export const PADDLING_POOL_255 = () => round_id: 304, stage_id: 20, status: 4, - lastGameFinishedAt: 1708541418, - createdAt: null, + startedAt: null, }, { id: 1466, @@ -7693,8 +7599,7 @@ export const PADDLING_POOL_255 = () => round_id: 305, stage_id: 20, status: 4, - lastGameFinishedAt: 1708543162, - createdAt: null, + startedAt: null, }, { id: 1467, @@ -7717,8 +7622,7 @@ export const PADDLING_POOL_255 = () => round_id: 305, stage_id: 20, status: 4, - lastGameFinishedAt: 1708542607, - createdAt: null, + startedAt: null, }, { id: 1468, @@ -7741,8 +7645,7 @@ export const PADDLING_POOL_255 = () => round_id: 306, stage_id: 20, status: 4, - lastGameFinishedAt: 1708540075, - createdAt: null, + startedAt: null, }, { id: 1469, @@ -7765,8 +7668,7 @@ export const PADDLING_POOL_255 = () => round_id: 306, stage_id: 20, status: 4, - lastGameFinishedAt: 1708539852, - createdAt: null, + startedAt: null, }, { id: 1470, @@ -7789,8 +7691,7 @@ export const PADDLING_POOL_255 = () => round_id: 307, stage_id: 20, status: 4, - lastGameFinishedAt: 1708541400, - createdAt: null, + startedAt: null, }, { id: 1471, @@ -7813,8 +7714,7 @@ export const PADDLING_POOL_255 = () => round_id: 307, stage_id: 20, status: 4, - lastGameFinishedAt: 1708541516, - createdAt: null, + startedAt: null, }, { id: 1472, @@ -7837,8 +7737,7 @@ export const PADDLING_POOL_255 = () => round_id: 308, stage_id: 20, status: 4, - lastGameFinishedAt: 1708543015, - createdAt: null, + startedAt: null, }, { id: 1473, @@ -7861,8 +7760,7 @@ export const PADDLING_POOL_255 = () => round_id: 308, stage_id: 20, status: 4, - lastGameFinishedAt: 1708541932, - createdAt: null, + startedAt: null, }, { id: 1474, @@ -7885,8 +7783,7 @@ export const PADDLING_POOL_255 = () => round_id: 309, stage_id: 20, status: 4, - lastGameFinishedAt: 1708540286, - createdAt: null, + startedAt: null, }, { id: 1475, @@ -7909,8 +7806,7 @@ export const PADDLING_POOL_255 = () => round_id: 309, stage_id: 20, status: 4, - lastGameFinishedAt: 1708539762, - createdAt: null, + startedAt: null, }, { id: 1476, @@ -7933,8 +7829,7 @@ export const PADDLING_POOL_255 = () => round_id: 310, stage_id: 20, status: 4, - lastGameFinishedAt: 1708541235, - createdAt: null, + startedAt: null, }, { id: 1477, @@ -7957,8 +7852,7 @@ export const PADDLING_POOL_255 = () => round_id: 310, stage_id: 20, status: 4, - lastGameFinishedAt: 1708541417, - createdAt: null, + startedAt: null, }, { id: 1478, @@ -7981,8 +7875,7 @@ export const PADDLING_POOL_255 = () => round_id: 311, stage_id: 20, status: 4, - lastGameFinishedAt: 1708543181, - createdAt: null, + startedAt: null, }, { id: 1479, @@ -8005,8 +7898,7 @@ export const PADDLING_POOL_255 = () => round_id: 311, stage_id: 20, status: 4, - lastGameFinishedAt: 1708542582, - createdAt: null, + startedAt: null, }, ], }, @@ -13135,7 +13027,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 238, stage_id: 16, status: 4, - lastGameFinishedAt: 1707589021, }, { id: 1235, @@ -13156,7 +13047,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 238, stage_id: 16, status: 4, - lastGameFinishedAt: 1707589634, }, { id: 1236, @@ -13177,7 +13067,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 238, stage_id: 16, status: 4, - lastGameFinishedAt: 1707588906, }, { id: 1237, @@ -13198,7 +13087,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 238, stage_id: 16, status: 4, - lastGameFinishedAt: 1707589294, }, { id: 1238, @@ -13219,7 +13107,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 238, stage_id: 16, status: 4, - lastGameFinishedAt: 1707588995, }, { id: 1239, @@ -13240,7 +13127,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 238, stage_id: 16, status: 4, - lastGameFinishedAt: 1707589729, }, { id: 1240, @@ -13261,7 +13147,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 238, stage_id: 16, status: 4, - lastGameFinishedAt: 1707589516, }, { id: 1241, @@ -13282,7 +13167,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 238, stage_id: 16, status: 4, - lastGameFinishedAt: 1707589393, }, { id: 1242, @@ -13303,7 +13187,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 238, stage_id: 16, status: 4, - lastGameFinishedAt: 1707588828, }, { id: 1243, @@ -13324,7 +13207,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 238, stage_id: 16, status: 4, - lastGameFinishedAt: 1707589814, }, { id: 1244, @@ -13345,7 +13227,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 238, stage_id: 16, status: 4, - lastGameFinishedAt: 1707589031, }, { id: 1245, @@ -13366,7 +13247,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 238, stage_id: 16, status: 4, - lastGameFinishedAt: 1707589204, }, { id: 1246, @@ -13387,7 +13267,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 238, stage_id: 16, status: 4, - lastGameFinishedAt: 1707589445, }, { id: 1247, @@ -13408,7 +13287,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 238, stage_id: 16, status: 4, - lastGameFinishedAt: 1707589553, }, { id: 1248, @@ -13429,7 +13307,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 238, stage_id: 16, status: 4, - lastGameFinishedAt: 1707589054, }, { id: 1249, @@ -13450,7 +13327,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 238, stage_id: 16, status: 4, - lastGameFinishedAt: 1707589495, }, { id: 1250, @@ -13469,7 +13345,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 239, stage_id: 16, status: 4, - lastGameFinishedAt: 1707591800, }, { id: 1251, @@ -13488,7 +13363,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 239, stage_id: 16, status: 4, - lastGameFinishedAt: 1707590789, }, { id: 1252, @@ -13507,7 +13381,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 239, stage_id: 16, status: 4, - lastGameFinishedAt: 1707591628, }, { id: 1253, @@ -13526,7 +13399,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 239, stage_id: 16, status: 4, - lastGameFinishedAt: 1707590375, }, { id: 1254, @@ -13545,7 +13417,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 239, stage_id: 16, status: 4, - lastGameFinishedAt: 1707590742, }, { id: 1255, @@ -13564,7 +13435,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 239, stage_id: 16, status: 4, - lastGameFinishedAt: 1707590906, }, { id: 1256, @@ -13583,7 +13453,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 239, stage_id: 16, status: 4, - lastGameFinishedAt: 1707590730, }, { id: 1257, @@ -13602,7 +13471,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 239, stage_id: 16, status: 4, - lastGameFinishedAt: 1707591717, }, { id: 1258, @@ -13621,7 +13489,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 240, stage_id: 16, status: 4, - lastGameFinishedAt: 1707592635, }, { id: 1259, @@ -13640,7 +13507,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 240, stage_id: 16, status: 4, - lastGameFinishedAt: 1707592784, }, { id: 1260, @@ -13659,7 +13525,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 240, stage_id: 16, status: 4, - lastGameFinishedAt: 1707592662, }, { id: 1261, @@ -13678,7 +13543,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 240, stage_id: 16, status: 4, - lastGameFinishedAt: 1707593990, }, { id: 1262, @@ -13697,7 +13561,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 241, stage_id: 16, status: 4, - lastGameFinishedAt: 1707594546, }, { id: 1263, @@ -13716,7 +13579,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 241, stage_id: 16, status: 4, - lastGameFinishedAt: 1707595955, }, { id: 1264, @@ -13735,7 +13597,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 242, stage_id: 16, status: 4, - lastGameFinishedAt: 1707597655, }, { id: 1265, @@ -13756,7 +13617,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 243, stage_id: 16, status: 4, - lastGameFinishedAt: 1707590584, }, { id: 1266, @@ -13777,7 +13637,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 243, stage_id: 16, status: 4, - lastGameFinishedAt: 1707590726, }, { id: 1267, @@ -13798,7 +13657,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 243, stage_id: 16, status: 4, - lastGameFinishedAt: 1707591858, }, { id: 1268, @@ -13819,7 +13677,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 243, stage_id: 16, status: 4, - lastGameFinishedAt: 1707590964, }, { id: 1269, @@ -13840,7 +13697,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 243, stage_id: 16, status: 4, - lastGameFinishedAt: 1707591980, }, { id: 1270, @@ -13861,7 +13717,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 243, stage_id: 16, status: 4, - lastGameFinishedAt: 1707589935, }, { id: 1271, @@ -13882,7 +13737,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 243, stage_id: 16, status: 4, - lastGameFinishedAt: 1707590457, }, { id: 1272, @@ -13903,7 +13757,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 243, stage_id: 16, status: 4, - lastGameFinishedAt: 1707592130, }, { id: 1273, @@ -13923,7 +13776,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 244, stage_id: 16, status: 4, - lastGameFinishedAt: 1707592779, }, { id: 1274, @@ -13943,7 +13795,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 244, stage_id: 16, status: 4, - lastGameFinishedAt: 1707592214, }, { id: 1275, @@ -13963,7 +13814,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 244, stage_id: 16, status: 4, - lastGameFinishedAt: 1707592819, }, { id: 1276, @@ -13983,7 +13833,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 244, stage_id: 16, status: 4, - lastGameFinishedAt: 1707592160, }, { id: 1277, @@ -14003,7 +13852,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 244, stage_id: 16, status: 4, - lastGameFinishedAt: 1707593326, }, { id: 1278, @@ -14023,7 +13871,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 244, stage_id: 16, status: 4, - lastGameFinishedAt: 1707592610, }, { id: 1279, @@ -14043,7 +13890,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 244, stage_id: 16, status: 4, - lastGameFinishedAt: 1707592011, }, { id: 1280, @@ -14063,7 +13909,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 244, stage_id: 16, status: 4, - lastGameFinishedAt: 1707593412, }, { id: 1281, @@ -14082,7 +13927,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 245, stage_id: 16, status: 4, - lastGameFinishedAt: 1707593628, }, { id: 1282, @@ -14101,7 +13945,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 245, stage_id: 16, status: 4, - lastGameFinishedAt: 1707594375, }, { id: 1283, @@ -14120,7 +13963,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 245, stage_id: 16, status: 4, - lastGameFinishedAt: 1707594716, }, { id: 1284, @@ -14139,7 +13981,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 245, stage_id: 16, status: 4, - lastGameFinishedAt: 1707594341, }, { id: 1285, @@ -14159,7 +14000,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 246, stage_id: 16, status: 4, - lastGameFinishedAt: 1707594947, }, { id: 1286, @@ -14179,7 +14019,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 246, stage_id: 16, status: 4, - lastGameFinishedAt: 1707595999, }, { id: 1287, @@ -14199,7 +14038,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 246, stage_id: 16, status: 4, - lastGameFinishedAt: 1707596071, }, { id: 1288, @@ -14219,7 +14057,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 246, stage_id: 16, status: 4, - lastGameFinishedAt: 1707595168, }, { id: 1289, @@ -14238,7 +14075,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 247, stage_id: 16, status: 4, - lastGameFinishedAt: 1707597073, }, { id: 1290, @@ -14257,7 +14093,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 247, stage_id: 16, status: 4, - lastGameFinishedAt: 1707597266, }, { id: 1291, @@ -14277,7 +14112,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 248, stage_id: 16, status: 4, - lastGameFinishedAt: 1707598455, }, { id: 1292, @@ -14297,7 +14131,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 248, stage_id: 16, status: 4, - lastGameFinishedAt: 1707599065, }, { id: 1293, @@ -14316,7 +14149,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 249, stage_id: 16, status: 4, - lastGameFinishedAt: 1707600564, }, { id: 1294, @@ -14336,7 +14168,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 250, stage_id: 16, status: 4, - lastGameFinishedAt: 1707602653, }, { id: 1295, @@ -14356,7 +14187,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 251, stage_id: 16, status: 4, - lastGameFinishedAt: 1707605053, }, { id: 1296, @@ -14371,7 +14201,6 @@ export const IN_THE_ZONE_32 = ({ round_id: 252, stage_id: 16, status: 2, - lastGameFinishedAt: null, }, ], }, @@ -14446,7 +14275,7 @@ export const IN_THE_ZONE_32 = ({ id: 16, name: "Main bracket", type: "double_elimination", - createdAt: null, + startedAt: null, }, ], teams: [ diff --git a/app/features/tournament-bracket/loaders/to.$id.matches.$mid.server.ts b/app/features/tournament-bracket/loaders/to.$id.matches.$mid.server.ts index 92114f46d..922f2bf6d 100644 --- a/app/features/tournament-bracket/loaders/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-bracket/loaders/to.$id.matches.$mid.server.ts @@ -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, }; }; diff --git a/app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server.ts b/app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server.ts index 92088af1b..28a267198 100644 --- a/app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server.ts +++ b/app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server.ts @@ -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"); diff --git a/app/features/tournament-bracket/queries/deleteMatchPickBanEvents.server.ts b/app/features/tournament-bracket/queries/deleteMatchPickBanEvents.server.ts index 16d036d08..f56acdf14 100644 --- a/app/features/tournament-bracket/queries/deleteMatchPickBanEvents.server.ts +++ b/app/features/tournament-bracket/queries/deleteMatchPickBanEvents.server.ts @@ -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 }); } diff --git a/app/features/tournament-bracket/queries/findMatchById.server.ts b/app/features/tournament-bracket/queries/findMatchById.server.ts index 17be9e6c9..4cfb6e907 100644 --- a/app/features/tournament-bracket/queries/findMatchById.server.ts +++ b/app/features/tournament-bracket/queries/findMatchById.server.ts @@ -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; export const findMatchById = (id: number) => { const row = stm.get({ id }) as - | ((Pick & + | ((Pick< + Tables["TournamentMatch"], + "id" | "groupId" | "chatCode" | "startedAt" | "status" + > & Pick & { 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"]; diff --git a/app/features/tournament-bracket/queries/resetMatchStatus.server.ts b/app/features/tournament-bracket/queries/resetMatchStatus.server.ts new file mode 100644 index 000000000..e0aaa9f80 --- /dev/null +++ b/app/features/tournament-bracket/queries/resetMatchStatus.server.ts @@ -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 }); +} diff --git a/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx b/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx index e212ce8ea..99f3d8d22 100644 --- a/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx +++ b/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx @@ -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 ? : null} + {data.matchIsOver && !data.endedEarly && data.results.length > 0 ? ( + + ) : null} + {data.matchIsOver && data.endedEarly ? : 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(); + 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 ( +
+
+
+
Match ended early
+ {winnerTeam ? ( +
+ The organizer ended this match as it exceeded the time limit. + Winner: {winnerTeam.name} +
+ ) : null} +
+ {tournament.isOrganizer(user) && + tournament.matchCanBeReopened(data.match.id) ? ( +
+ + Reopen match + +
+ ) : null} +
+
+ ); +} diff --git a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts index 1cd559bdb..f7ebf786a 100644 --- a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts +++ b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts @@ -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); diff --git a/app/features/tournament-bracket/tournament-bracket-utils.test.ts b/app/features/tournament-bracket/tournament-bracket-utils.test.ts index 6772b3aee..0b146b1a4 100644 --- a/app/features/tournament-bracket/tournament-bracket-utils.test.ts +++ b/app/features/tournament-bracket/tournament-bracket-utils.test.ts @@ -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); + }); + }); }); diff --git a/app/features/tournament-bracket/tournament-bracket-utils.ts b/app/features/tournament-bracket/tournament-bracket-utils.ts index 9fc67982e..5451edf79 100644 --- a/app/features/tournament-bracket/tournament-bracket-utils.ts +++ b/app/features/tournament-bracket/tournament-bracket-utils.ts @@ -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, diff --git a/app/features/tournament-bracket/tournament-bracket.css b/app/features/tournament-bracket/tournament-bracket.css index 6c7c21a0f..33af10cbb 100644 --- a/app/features/tournament-bracket/tournament-bracket.css +++ b/app/features/tournament-bracket/tournament-bracket.css @@ -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; diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index 170c90b92..932841253 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -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(), })), ) diff --git a/app/features/tournament/queries/createSwissBracketInTransaction.server.ts b/app/features/tournament/queries/createSwissBracketInTransaction.server.ts index 92b46418d..3a9a1441e 100644 --- a/app/features/tournament/queries/createSwissBracketInTransaction.server.ts +++ b/app/features/tournament/queries/createSwissBracketInTransaction.server.ts @@ -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()), }); } } diff --git a/app/features/tournament/tournament-test-utils.ts b/app/features/tournament/tournament-test-utils.ts index 510a3b209..d35a12119 100644 --- a/app/features/tournament/tournament-test-utils.ts +++ b/app/features/tournament/tournament-test-utils.ts @@ -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, diff --git a/app/modules/brackets-manager/base/updater.ts b/app/modules/brackets-manager/base/updater.ts index ad765cf1d..31135535d 100644 --- a/app/modules/brackets-manager/base/updater.ts +++ b/app/modules/brackets-manager/base/updater.ts @@ -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; + } } diff --git a/app/modules/brackets-manager/create.ts b/app/modules/brackets-manager/create.ts index 6c2d2a9ba..56070db93 100644 --- a/app/modules/brackets-manager/create.ts +++ b/app/modules/brackets-manager/create.ts @@ -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): 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): 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): 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); } /** diff --git a/app/modules/brackets-manager/test/round-robin.test.ts b/app/modules/brackets-manager/test/round-robin.test.ts index 06709eccd..f27e52938 100644 --- a/app/modules/brackets-manager/test/round-robin.test.ts +++ b/app/modules/brackets-manager/test/round-robin.test.ts @@ -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("match", 0)!; + const round1Match2 = storage.select("match", 1)!; + const round2Match1 = storage.select("match", 2)!; + const round2Match2 = storage.select("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("match", 2)!; + let round2Match2After = storage.select("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("match", 2)!; + round2Match2After = storage.select("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("match")!; + const allRounds = storage.select("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("match", round2RealMatch.id)!; + expect(round2AfterUpdate.status).toBe(2); // Ready + }); }); diff --git a/app/modules/brackets-model/storage.ts b/app/modules/brackets-model/storage.ts index e24e8566b..02c37ce5f 100644 --- a/app/modules/brackets-model/storage.ts +++ b/app/modules/brackets-model/storage.ts @@ -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; } diff --git a/app/routines/notifyCheckInStart.test.ts b/app/routines/notifyCheckInStart.test.ts index 77eebd613..cc1d6fd1e 100644 --- a/app/routines/notifyCheckInStart.test.ts +++ b/app/routines/notifyCheckInStart.test.ts @@ -32,7 +32,6 @@ async function createTestTournament({ bracketUrl: "https://example.com/bracket", description: null, discordInviteCode, - deadlines: "DEFAULT", name, organizationId: null, rules: null, diff --git a/app/styles/utils.css b/app/styles/utils.css index b81b3c2ca..478a4d9d4 100644 --- a/app/styles/utils.css +++ b/app/styles/utils.css @@ -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; } diff --git a/db-test.sqlite3 b/db-test.sqlite3 index 4ff8d9d88..32ef80776 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/docs/tournament-creation.md b/docs/tournament-creation.md index 974381fc5..9e1d7ff63 100644 --- a/docs/tournament-creation.md +++ b/docs/tournament-creation.md @@ -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. diff --git a/e2e/tournament-bracket.spec.ts b/e2e/tournament-bracket.spec.ts index 2459b4d20..56e79be9a 100644 --- a/e2e/tournament-bracket.spec.ts +++ b/e2e/tournament-bracket.spec.ts @@ -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(); + }); }); diff --git a/locales/da/tournament.json b/locales/da/tournament.json index f428d245e..969620b39 100644 --- a/locales/da/tournament.json +++ b/locales/da/tournament.json @@ -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", diff --git a/locales/de/tournament.json b/locales/de/tournament.json index 4333e68e5..1647e526a 100644 --- a/locales/de/tournament.json +++ b/locales/de/tournament.json @@ -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.", diff --git a/locales/en/tournament.json b/locales/en/tournament.json index ea7fe5620..c31be0a8f 100644 --- a/locales/en/tournament.json +++ b/locales/en/tournament.json @@ -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.", diff --git a/locales/es-ES/tournament.json b/locales/es-ES/tournament.json index 11ee690f3..338e699c9 100644 --- a/locales/es-ES/tournament.json +++ b/locales/es-ES/tournament.json @@ -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.", diff --git a/locales/es-US/tournament.json b/locales/es-US/tournament.json index 03c07687e..1f73f5438 100644 --- a/locales/es-US/tournament.json +++ b/locales/es-US/tournament.json @@ -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.", diff --git a/locales/fr-CA/tournament.json b/locales/fr-CA/tournament.json index 0398f394f..6130e4943 100644 --- a/locales/fr-CA/tournament.json +++ b/locales/fr-CA/tournament.json @@ -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.", diff --git a/locales/fr-EU/tournament.json b/locales/fr-EU/tournament.json index b461218c1..1c6be772b 100644 --- a/locales/fr-EU/tournament.json +++ b/locales/fr-EU/tournament.json @@ -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.", diff --git a/locales/he/tournament.json b/locales/he/tournament.json index 5bd48b5b2..41b4511f7 100644 --- a/locales/he/tournament.json +++ b/locales/he/tournament.json @@ -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": "אין צוות שתואם את קוד ההזמנה.", diff --git a/locales/it/tournament.json b/locales/it/tournament.json index e0de021e9..3997120ad 100644 --- a/locales/it/tournament.json +++ b/locales/it/tournament.json @@ -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.", diff --git a/locales/ja/tournament.json b/locales/ja/tournament.json index 7b88801c3..82e08cf4c 100644 --- a/locales/ja/tournament.json +++ b/locales/ja/tournament.json @@ -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": "この招待コードにあうチームがみつかりません。", diff --git a/locales/ko/tournament.json b/locales/ko/tournament.json index 30a6c12e8..f0af7b595 100644 --- a/locales/ko/tournament.json +++ b/locales/ko/tournament.json @@ -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": "", diff --git a/locales/nl/tournament.json b/locales/nl/tournament.json index a5afe41d5..cb0cd7e10 100644 --- a/locales/nl/tournament.json +++ b/locales/nl/tournament.json @@ -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": "", diff --git a/locales/pl/tournament.json b/locales/pl/tournament.json index 4d48d5e4c..41fad228d 100644 --- a/locales/pl/tournament.json +++ b/locales/pl/tournament.json @@ -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": "", diff --git a/locales/pt-BR/tournament.json b/locales/pt-BR/tournament.json index db811d7b5..bbf8bddc2 100644 --- a/locales/pt-BR/tournament.json +++ b/locales/pt-BR/tournament.json @@ -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.", diff --git a/locales/ru/tournament.json b/locales/ru/tournament.json index 85724f250..b72eadade 100644 --- a/locales/ru/tournament.json +++ b/locales/ru/tournament.json @@ -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": "Нет команды, соответствующей коду приглашения.", diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json index 04d4394ff..50b21a3ab 100644 --- a/locales/zh/tournament.json +++ b/locales/zh/tournament.json @@ -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": "没有队伍与邀请码相匹配。", diff --git a/migrations/108-match-started-at.js b/migrations/108-match-started-at.js new file mode 100644 index 000000000..bee335548 --- /dev/null +++ b/migrations/108-match-started-at.js @@ -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(); + })(); +} diff --git a/scripts/create-league-divisions.ts b/scripts/create-league-divisions.ts index 5fc52820a..7ce779e90 100644 --- a/scripts/create-league-divisions.ts +++ b/scripts/create-league-divisions.ts @@ -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,